Skip to main content

max / makenotwork

32.9 KB · 900 lines History Blame Raw
1 //! Build-time substitution of business assumptions into markdown.
2 //!
3 //! Loads a TOML "source of truth" file, computes a registry of derived values,
4 //! validates internal consistency, and substitutes `{{ dotted.path }}` markers
5 //! in markdown before rendering.
6 //!
7 //! The intended pipeline is:
8 //!
9 //! ```ignore
10 //! let assumptions = Assumptions::load("assumptions.toml")?;
11 //! assumptions.validate()?;
12 //! let resolved = assumptions.substitute(&markdown)?;
13 //! let html = docengine::render_permissive(&resolved);
14 //! ```
15 //!
16 //! Substitution runs on raw markdown before parsing so values may appear
17 //! anywhere: prose, code spans, table cells, link text.
18
19 use std::collections::HashMap;
20 use std::fmt;
21 use std::fs;
22 use std::path::Path;
23
24 use serde::Deserialize;
25
26 // ─── Public types ─────────────────────────────────────────────────────────
27
28 /// Top-level errors returned by [`Assumptions::load`] / [`substitute`].
29 #[derive(Debug)]
30 pub enum AssumptionsError {
31 Io(std::io::Error),
32 Parse(toml::de::Error),
33 Validation(Vec<String>),
34 Substitution { unresolved: Vec<String> },
35 }
36
37 impl fmt::Display for AssumptionsError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Self::Io(e) => write!(f, "I/O error: {e}"),
41 Self::Parse(e) => write!(f, "TOML parse error: {e}"),
42 Self::Validation(failures) => {
43 writeln!(f, "validation failed ({} rule(s)):", failures.len())?;
44 for rule in failures {
45 writeln!(f, " - {rule}")?;
46 }
47 Ok(())
48 }
49 Self::Substitution { unresolved } => {
50 write!(f, "unresolved placeholders: {}", unresolved.join(", "))
51 }
52 }
53 }
54 }
55
56 impl std::error::Error for AssumptionsError {}
57
58 impl From<std::io::Error> for AssumptionsError {
59 fn from(e: std::io::Error) -> Self {
60 Self::Io(e)
61 }
62 }
63
64 impl From<toml::de::Error> for AssumptionsError {
65 fn from(e: toml::de::Error) -> Self {
66 Self::Parse(e)
67 }
68 }
69
70 /// A leaf value from the assumptions table or derived registry.
71 #[derive(Debug, Clone, PartialEq)]
72 pub enum LookupValue {
73 Int(i64),
74 Float(f64),
75 String(String),
76 }
77
78 impl fmt::Display for LookupValue {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::Int(n) => write!(f, "{n}"),
82 Self::Float(x) => f.write_str(&format_float(*x)),
83 Self::String(s) => f.write_str(s),
84 }
85 }
86 }
87
88 /// Format a float with up to 4 decimal places, trimming trailing zeros.
89 ///
90 /// `22.0 -> "22"`, `0.59 -> "0.59"`, `0.0367 -> "0.0367"`, `61960.0 -> "61960"`.
91 fn format_float(x: f64) -> String {
92 let s = format!("{x:.4}");
93 let trimmed = s.trim_end_matches('0').trim_end_matches('.');
94 trimmed.to_string()
95 }
96
97 /// Loaded + validated business assumptions plus a flat lookup table and a
98 /// filter registry.
99 pub struct Assumptions {
100 typed: Typed,
101 lookup: HashMap<String, LookupValue>,
102 filters: HashMap<String, Box<dyn crate::filters::Filter>>,
103 }
104
105 impl Assumptions {
106 /// Load assumptions from a TOML file.
107 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, AssumptionsError> {
108 let text = fs::read_to_string(path)?;
109 Self::parse(&text)
110 }
111
112 /// Parse assumptions from a TOML string.
113 pub fn parse(text: &str) -> Result<Self, AssumptionsError> {
114 let value: toml::Value = toml::from_str(text)?;
115 let typed: Typed = value.clone().try_into()?;
116
117 let mut lookup = HashMap::new();
118 walk_value(&value, String::new(), &mut lookup);
119 insert_derived(&typed, &mut lookup);
120
121 let mut filters: HashMap<String, Box<dyn crate::filters::Filter>> = HashMap::new();
122 crate::filters::register_builtins(&mut filters);
123
124 Ok(Self { typed, lookup, filters })
125 }
126
127 /// Register a custom filter. Overrides any built-in or previously
128 /// registered filter with the same name.
129 ///
130 /// ```ignore
131 /// let a = Assumptions::load(path)?
132 /// .with_filter("k", |v, _args| {
133 /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?;
134 /// Ok(LookupValue::String(format!("{:.0}K", n / 1000.0)))
135 /// });
136 /// ```
137 pub fn with_filter(mut self, name: impl Into<String>, filter: impl crate::filters::Filter + 'static) -> Self {
138 self.filters.insert(name.into(), Box::new(filter));
139 self
140 }
141
142 /// Run all consistency checks. Returns `Err` listing every failed rule.
143 pub fn validate(&self) -> Result<(), AssumptionsError> {
144 let mut failures = Vec::new();
145 let t = &self.typed;
146
147 // Typo guard on fixed costs.
148 if !(100.0 < t.expenses.f_monthly && t.expenses.f_monthly < 10_000.0) {
149 failures.push(format!(
150 "expenses.F_monthly = {} is outside (100, 10000)",
151 t.expenses.f_monthly
152 ));
153 }
154
155 // Tier mix must sum to 1.0.
156 let mix = &t.tier_mix.assumed;
157 let mix_sum = mix.basic_pct + mix.small_files_pct + mix.big_files_pct + mix.everything_pct;
158 if (mix_sum - 1.0).abs() > 1e-6 {
159 failures.push(format!("tier_mix.assumed sums to {mix_sum}, expected 1.0"));
160 }
161
162 // Surplus split must sum to 1.0.
163 let split_sum = t.reserve.surplus_split_reserve + t.reserve.surplus_split_earnback;
164 if (split_sum - 1.0).abs() > 1e-6 {
165 failures.push(format!(
166 "reserve.surplus_split_{{reserve,earnback}} sums to {split_sum}, expected 1.0"
167 ));
168 }
169
170 // Rho bounds.
171 if !(0.0 < t.reserve.rho_annual && t.reserve.rho_annual <= 1.0) {
172 failures.push(format!(
173 "reserve.rho_annual = {} is outside (0, 1]",
174 t.reserve.rho_annual
175 ));
176 }
177 if !(0.0 < t.reserve.rho_incident && t.reserve.rho_incident <= 1.0) {
178 failures.push(format!(
179 "reserve.rho_incident = {} is outside (0, 1]",
180 t.reserve.rho_incident
181 ));
182 }
183 if t.reserve.rho_incident > t.reserve.rho_annual {
184 failures.push(format!(
185 "reserve.rho_incident ({}) > reserve.rho_annual ({})",
186 t.reserve.rho_incident, t.reserve.rho_annual
187 ));
188 }
189
190 // Founding ≤ standard for every tier.
191 let f = &t.tiers.founding;
192 let s = &t.tiers.standard;
193 for (name, fv, sv) in [
194 ("basic", f.basic, s.basic),
195 ("small_files", f.small_files, s.small_files),
196 ("big_files", f.big_files, s.big_files),
197 ("everything", f.everything, s.everything),
198 ] {
199 if fv > sv {
200 failures.push(format!(
201 "tiers.founding.{name} ({fv}) > tiers.standard.{name} ({sv})"
202 ));
203 }
204 }
205
206 // Cohort caps positive.
207 if t.cohort.cap_count <= 0 {
208 failures.push(format!("cohort.cap_count = {} must be > 0", t.cohort.cap_count));
209 }
210 if t.cohort.cap_months <= 0 {
211 failures.push(format!(
212 "cohort.cap_months = {} must be > 0",
213 t.cohort.cap_months
214 ));
215 }
216
217 if failures.is_empty() {
218 Ok(())
219 } else {
220 Err(AssumptionsError::Validation(failures))
221 }
222 }
223
224 /// Substitute `{{ dotted.path }}` placeholders in markdown.
225 ///
226 /// Returns `Err(Substitution)` listing every key that could not be
227 /// resolved. The output is the markdown with all resolved keys replaced;
228 /// unresolved keys are left in place when an error is returned, so callers
229 /// can grep for them.
230 pub fn substitute(&self, markdown: &str) -> Result<String, AssumptionsError> {
231 // Matches `{{ … }}` non-greedily — the inner body may contain spaces,
232 // pipes, parens, and quoted strings (e.g. `{{ x | money("$") }}`).
233 let re = regex_lite::Regex::new(r"\{\{(.*?)\}\}").expect("static regex");
234
235 // Skip matches inside inline code spans and fenced code blocks so that
236 // documentation showing literal `{{ … }}` template syntax (e.g. Tauri
237 // updater URL patterns) is preserved verbatim.
238 let code_ranges = crate::code_spans::code_span_ranges(markdown);
239 let in_code = |start: usize| {
240 code_ranges
241 .iter()
242 .any(|&(s, e)| start >= s && start < e)
243 };
244
245 let mut unresolved: Vec<String> = Vec::new();
246 let mut errors: Vec<String> = Vec::new();
247 let mut out = String::with_capacity(markdown.len());
248 let mut last = 0;
249
250 for m in re.find_iter(markdown) {
251 out.push_str(&markdown[last..m.start()]);
252 last = m.end();
253
254 if in_code(m.start()) {
255 out.push_str(m.as_str());
256 continue;
257 }
258
259 let body = re.captures(m.as_str()).unwrap().get(1).unwrap().as_str();
260 match self.resolve(body) {
261 Ok(Some(v)) => out.push_str(&v.to_string()),
262 Ok(None) => {
263 // Path not found in lookup. Preserve marker; flag for caller.
264 unresolved.push(body.trim().to_string());
265 out.push_str(m.as_str());
266 }
267 Err(e) => {
268 errors.push(format!("`{body}`: {e}"));
269 out.push_str(m.as_str());
270 }
271 }
272 }
273 out.push_str(&markdown[last..]);
274
275 if !errors.is_empty() {
276 return Err(AssumptionsError::Substitution {
277 unresolved: errors,
278 });
279 }
280 if !unresolved.is_empty() {
281 unresolved.sort();
282 unresolved.dedup();
283 return Err(AssumptionsError::Substitution { unresolved });
284 }
285 Ok(out)
286 }
287
288 /// Resolve a single `{{ body }}` expression. Returns:
289 /// - `Ok(Some(v))` when the path resolved and all filters applied cleanly.
290 /// - `Ok(None)` when the path is missing from the lookup table.
291 /// - `Err(_)` for parse errors, unknown filters, or filter failures.
292 fn resolve(&self, body: &str) -> Result<Option<LookupValue>, String> {
293 let expr = crate::filters::parse_expr(body).map_err(|e| e.to_string())?;
294 let Some(initial) = self.lookup.get(expr.path).cloned() else {
295 return Ok(None);
296 };
297 let mut value = initial;
298 for call in &expr.filters {
299 let filter = self
300 .filters
301 .get(call.name)
302 .ok_or_else(|| format!("unknown filter `{}`", call.name))?;
303 value = filter
304 .apply(value, &call.args)
305 .map_err(|e| e.to_string())?;
306 }
307 Ok(Some(value))
308 }
309
310 /// Look up a single key. Useful for testing and for programmatic callers
311 /// that don't want to go through markdown substitution.
312 pub fn get(&self, key: &str) -> Option<&LookupValue> {
313 self.lookup.get(key)
314 }
315
316 /// Iterate over every available key (raw + derived) in arbitrary order.
317 pub fn keys(&self) -> impl Iterator<Item = &str> {
318 self.lookup.keys().map(String::as_str)
319 }
320 }
321
322 // ─── Typed mirror of assumptions.toml ─────────────────────────────────────
323 //
324 // Only the fields needed for validation and derived values. Unknown fields
325 // (e.g. `[expenses.lines]`, `[stripe.connect_express]`) are silently ignored
326 // by serde but still reach the lookup table through `walk_value`.
327
328 #[derive(Debug, Deserialize)]
329 struct Typed {
330 expenses: TExpenses,
331 stripe: TStripe,
332 tiers: TTiers,
333 tier_mix: TTierMix,
334 reserve: TReserve,
335 cohort: TCohort,
336 creator_marginal: TCreatorMarginal,
337 annual_discount: TAnnualDiscount,
338 }
339
340 #[derive(Debug, Deserialize)]
341 struct TExpenses {
342 #[serde(rename = "F_monthly")]
343 f_monthly: f64,
344 }
345
346 #[derive(Debug, Deserialize)]
347 struct TStripe {
348 percent: f64,
349 fixed: f64,
350 dispute_fee: f64,
351 }
352
353 #[derive(Debug, Deserialize)]
354 struct TTiers {
355 founding: TTierPrices,
356 standard: TTierPrices,
357 }
358
359 #[derive(Debug, Deserialize)]
360 struct TTierPrices {
361 basic: f64,
362 small_files: f64,
363 big_files: f64,
364 everything: f64,
365 }
366
367 #[derive(Debug, Deserialize)]
368 struct TTierMix {
369 assumed: TMixWeights,
370 }
371
372 #[derive(Debug, Deserialize)]
373 struct TMixWeights {
374 basic_pct: f64,
375 small_files_pct: f64,
376 big_files_pct: f64,
377 everything_pct: f64,
378 }
379
380 #[derive(Debug, Deserialize)]
381 struct TReserve {
382 #[serde(rename = "T_fixed_months")]
383 t_fixed_months: f64,
384 #[serde(rename = "S_legal")]
385 s_legal: f64,
386 #[serde(rename = "S_shock")]
387 s_shock: f64,
388 #[serde(rename = "R_opp")]
389 r_opp: f64,
390 rho_annual: f64,
391 rho_incident: f64,
392 surplus_split_reserve: f64,
393 surplus_split_earnback: f64,
394 }
395
396 #[derive(Debug, Deserialize)]
397 struct TCohort {
398 cap_count: i64,
399 cap_months: i64,
400 }
401
402 #[derive(Debug, Deserialize)]
403 struct TAnnualDiscount {
404 multiplier: f64,
405 }
406
407 #[derive(Debug, Deserialize)]
408 struct TCreatorMarginal {
409 storage_basic_gb: f64,
410 storage_small_files_gb: f64,
411 storage_big_files_gb: f64,
412 storage_everything_gb: f64,
413 storage_cost_per_gb_per_month: f64,
414 chargeback_rate_tier_subs: f64,
415 }
416
417 // ─── Walking + derived ────────────────────────────────────────────────────
418
419 fn walk_value(value: &toml::Value, prefix: String, out: &mut HashMap<String, LookupValue>) {
420 match value {
421 toml::Value::Table(table) => {
422 for (k, v) in table {
423 let key = if prefix.is_empty() {
424 k.clone()
425 } else {
426 format!("{prefix}.{k}")
427 };
428 walk_value(v, key, out);
429 }
430 }
431 toml::Value::Integer(n) => {
432 out.insert(prefix, LookupValue::Int(*n));
433 }
434 toml::Value::Float(x) => {
435 out.insert(prefix, LookupValue::Float(*x));
436 }
437 toml::Value::String(s) => {
438 out.insert(prefix, LookupValue::String(s.clone()));
439 }
440 // Bools, arrays, datetimes: not substitutable.
441 toml::Value::Boolean(_) | toml::Value::Array(_) | toml::Value::Datetime(_) => {}
442 }
443 }
444
445 fn insert_derived(t: &Typed, out: &mut HashMap<String, LookupValue>) {
446 let mut put = |k: &str, v: f64| {
447 out.insert(format!("derived.{k}"), LookupValue::Float(v));
448 };
449
450 let r_cap = t.reserve.t_fixed_months * t.expenses.f_monthly + t.reserve.s_legal + t.reserve.s_shock;
451 put("R_cap", r_cap);
452
453 // ARPU per rate class.
454 let mix = &t.tier_mix.assumed;
455 let arpu_founding = mix.basic_pct * t.tiers.founding.basic
456 + mix.small_files_pct * t.tiers.founding.small_files
457 + mix.big_files_pct * t.tiers.founding.big_files
458 + mix.everything_pct * t.tiers.founding.everything;
459 let arpu_standard = mix.basic_pct * t.tiers.standard.basic
460 + mix.small_files_pct * t.tiers.standard.small_files
461 + mix.big_files_pct * t.tiers.standard.big_files
462 + mix.everything_pct * t.tiers.standard.everything;
463 put("ARPU_founding", arpu_founding);
464 put("ARPU_standard", arpu_standard);
465
466 // Stripe fee per tier price (creator-facing examples — these are what the
467 // creator pays Stripe on their fan transactions, NOT what MNW pays Stripe).
468 let stripe_fee = |amt: f64| t.stripe.percent * amt + t.stripe.fixed;
469 put("stripe_fee_basic_std", stripe_fee(t.tiers.standard.basic));
470 put("stripe_fee_small_std", stripe_fee(t.tiers.standard.small_files));
471 put("stripe_fee_big_std", stripe_fee(t.tiers.standard.big_files));
472 put("stripe_fee_ev_std", stripe_fee(t.tiers.standard.everything));
473
474 // Annual prices per tier (monthly × 12 × annual_discount.multiplier, rounded
475 // to nearest dollar). Substituted into docs as `${{ derived.annual_*_* }}`
476 // so a price change auto-propagates.
477 let yr = |monthly: f64| (monthly * 12.0 * t.annual_discount.multiplier).round();
478 put("annual_founding_basic", yr(t.tiers.founding.basic));
479 put("annual_founding_small_files", yr(t.tiers.founding.small_files));
480 put("annual_founding_big_files", yr(t.tiers.founding.big_files));
481 put("annual_founding_everything", yr(t.tiers.founding.everything));
482 put("annual_standard_basic", yr(t.tiers.standard.basic));
483 put("annual_standard_small_files", yr(t.tiers.standard.small_files));
484 put("annual_standard_big_files", yr(t.tiers.standard.big_files));
485 put("annual_standard_everything", yr(t.tiers.standard.everything));
486
487 // ── Marginal cost per creator/month, broken down by component ──
488 //
489 // What MNW pays per active creator on top of fixed costs F:
490 //
491 // storage : weighted GB × $/GB/month — Hetzner object storage.
492 // stripe_sub : Stripe processing fee on the creator's tier subscription
493 // (creator→MNW). Weighted across the tier mix. Stripe fees
494 // on fan→creator transactions are $0 to MNW (Connect Std).
495 // chargeback : Expected dispute fee per sub/month (small but real).
496 //
497 // Components are emitted individually so docs can show a breakdown table.
498 // `marginal_avg_{standard,founding}` is the sum per rate class.
499 //
500 // NOT modeled (deliberately): egress (at current scale, within Hetzner's
501 // 20 TB/server free allowance), support time (unmeasured pre-launch — see
502 // A26 in assumptions.md).
503
504 let m = &t.creator_marginal;
505
506 let marginal_storage = (mix.basic_pct * m.storage_basic_gb
507 + mix.small_files_pct * m.storage_small_files_gb
508 + mix.big_files_pct * m.storage_big_files_gb
509 + mix.everything_pct * m.storage_everything_gb)
510 * m.storage_cost_per_gb_per_month;
511 put("marginal_storage", marginal_storage);
512
513 let weighted_stripe_sub = |tiers: &TTierPrices| {
514 mix.basic_pct * stripe_fee(tiers.basic)
515 + mix.small_files_pct * stripe_fee(tiers.small_files)
516 + mix.big_files_pct * stripe_fee(tiers.big_files)
517 + mix.everything_pct * stripe_fee(tiers.everything)
518 };
519 let marginal_stripe_standard = weighted_stripe_sub(&t.tiers.standard);
520 let marginal_stripe_founding = weighted_stripe_sub(&t.tiers.founding);
521 put("marginal_stripe_standard", marginal_stripe_standard);
522 put("marginal_stripe_founding", marginal_stripe_founding);
523
524 // Chargeback expected value: rate × dispute fee. The lost-revenue portion
525 // (disputed charge amount) is treated as a refund of the original sub
526 // payment, not a separate cost — it flows through ARPU naturally if rates
527 // are accurate. Only the $15 dispute fee is incremental.
528 let marginal_chargeback = m.chargeback_rate_tier_subs * t.stripe.dispute_fee;
529 put("marginal_chargeback", marginal_chargeback);
530
531 let marginal_avg_standard = marginal_storage + marginal_stripe_standard + marginal_chargeback;
532 let marginal_avg_founding = marginal_storage + marginal_stripe_founding + marginal_chargeback;
533 put("marginal_avg_standard", marginal_avg_standard);
534 put("marginal_avg_founding", marginal_avg_founding);
535 // Back-compat alias for the previous single-value marginal.
536 put("marginal_avg", marginal_avg_standard);
537
538 // Break-even creator counts (per rate class).
539 let break_even_standard = t.expenses.f_monthly / (arpu_standard - marginal_avg_standard);
540 let break_even_founding = t.expenses.f_monthly / (arpu_founding - marginal_avg_founding);
541 put("break_even_standard", break_even_standard);
542 put("break_even_founding", break_even_founding);
543
544 // Surplus at representative cohort sizes (per rate class, using the matching marginal).
545 let surplus = |n: f64, arpu: f64, marg: f64| n * (arpu - marg) - t.expenses.f_monthly;
546 put("surplus_100_standard", surplus(100.0, arpu_standard, marginal_avg_standard));
547 put("surplus_500_standard", surplus(500.0, arpu_standard, marginal_avg_standard));
548 put("surplus_100_founding", surplus(100.0, arpu_founding, marginal_avg_founding));
549 put("surplus_500_founding", surplus(500.0, arpu_founding, marginal_avg_founding));
550
551 // Fill-time in months to reach R_cap + R_opp at representative cohort sizes
552 // under the standard rate.
553 let target = r_cap + t.reserve.r_opp;
554 let fill_time =
555 |n: f64| target / surplus(n, arpu_standard, marginal_avg_standard);
556 put("fill_time_100", fill_time(100.0));
557 put("fill_time_500", fill_time(500.0));
558 }
559
560 // ─── Tests ────────────────────────────────────────────────────────────────
561
562 #[cfg(test)]
563 mod tests {
564 use super::*;
565
566 // Canonical assumptions.toml lives outside the MNW repo at
567 // ~/Code/_private/docs/mnw/server-internal/business/assumptions.toml (moved
568 // 2026-05-20). include_str! resolves relative to this source file. A clone
569 // of the MNW repo without _private/ alongside will fail to build the
570 // assumptions feature; production never hits this path because main.rs
571 // uses runtime Assumptions::load with ASSUMPTIONS_PATH env override.
572 const FIXTURE: &str = include_str!(
573 "../../../../_private/docs/mnw/server-internal/business/assumptions.toml"
574 );
575
576 fn loaded() -> Assumptions {
577 Assumptions::parse(FIXTURE).expect("fixture parses")
578 }
579
580 #[test]
581 fn fixture_loads_and_validates() {
582 let a = loaded();
583 a.validate().expect("fixture validates");
584 }
585
586 #[test]
587 fn raw_lookup_returns_int_and_float_variants() {
588 let a = loaded();
589 assert_eq!(a.get("expenses.F_monthly"), Some(&LookupValue::Int(580)));
590 assert_eq!(a.get("stripe.percent"), Some(&LookupValue::Float(0.029)));
591 assert_eq!(
592 a.get("cohort.lock_duration"),
593 Some(&LookupValue::String("lifetime".into()))
594 );
595 }
596
597 #[test]
598 fn derived_values_match_worked_examples() {
599 let a = loaded();
600 // R_cap = 12 · 580 + 50000 + 5000 = 61960
601 let r_cap = match a.get("derived.R_cap").unwrap() {
602 LookupValue::Float(x) => *x,
603 v => panic!("expected float, got {v:?}"),
604 };
605 assert!((r_cap - 61960.0).abs() < 1e-6, "R_cap = {r_cap}");
606
607 // ARPU_standard = 0.4·16 + 0.3·24 + 0.2·36 + 0.1·60 = 26.80
608 let arpu = match a.get("derived.ARPU_standard").unwrap() {
609 LookupValue::Float(x) => *x,
610 v => panic!("{v:?}"),
611 };
612 assert!((arpu - 26.80).abs() < 1e-9, "ARPU_standard = {arpu}");
613
614 // stripe_fee_basic_std = 0.029 · 16 + 0.30 = 0.764
615 let fee = match a.get("derived.stripe_fee_basic_std").unwrap() {
616 LookupValue::Float(x) => *x,
617 v => panic!("{v:?}"),
618 };
619 assert!((fee - 0.764).abs() < 1e-9, "stripe_fee_basic_std = {fee}");
620 }
621
622 #[test]
623 fn substitute_replaces_known_keys() {
624 let a = loaded();
625 let out = a
626 .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.")
627 .unwrap();
628 assert_eq!(out, "Fixed monthly costs are $580.");
629 }
630
631 #[test]
632 fn substitute_replaces_derived_keys() {
633 let a = loaded();
634 let out = a.substitute("R_cap = ${{ derived.R_cap }}").unwrap();
635 assert_eq!(out, "R_cap = $61960");
636 }
637
638 #[test]
639 fn substitute_handles_whitespace_in_markers() {
640 let a = loaded();
641 let out = a
642 .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}")
643 .unwrap();
644 assert_eq!(out, "a=580 b=580");
645 }
646
647 #[test]
648 fn marginal_cost_components_decomposition() {
649 let a = loaded();
650 let get_f = |k: &str| match a.get(k).unwrap() {
651 LookupValue::Float(x) => *x,
652 v => panic!("expected float at {k}, got {v:?}"),
653 };
654
655 // Storage: weighted GB × $/GB/mo. 5.64 GB × 0.0065 = 0.03666
656 let storage = get_f("derived.marginal_storage");
657 assert!((storage - 0.03666).abs() < 1e-4, "storage = {storage}");
658
659 // Stripe fee on creator subs at standard rates (weighted by tier mix):
660 // 0.4·0.764 + 0.3·0.996 + 0.2·1.344 + 0.1·2.040 = 1.0772
661 let stripe = get_f("derived.marginal_stripe_standard");
662 assert!((stripe - 1.0772).abs() < 1e-4, "stripe = {stripe}");
663
664 // Chargeback EV: 0.001 × $15 dispute = $0.015
665 let chargeback = get_f("derived.marginal_chargeback");
666 assert!((chargeback - 0.015).abs() < 1e-9, "chargeback = {chargeback}");
667
668 // marginal_avg_standard = sum
669 let avg = get_f("derived.marginal_avg_standard");
670 assert!((avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}");
671
672 // Founding Stripe fee is lower (lower tier prices, less % component):
673 // 0.4·0.532 + 0.3·0.648 + 0.2·0.822 + 0.1·1.170 = 0.6886
674 let stripe_f = get_f("derived.marginal_stripe_founding");
675 assert!((stripe_f - 0.6886).abs() < 1e-4, "stripe_f = {stripe_f}");
676 }
677
678 #[test]
679 fn derived_annual_prices_match_published_values() {
680 let a = loaded();
681 let get_f = |k: &str| match a.get(k).unwrap() {
682 LookupValue::Float(x) => *x,
683 v => panic!("expected float at {k}, got {v:?}"),
684 };
685
686 // Founder: $8/$12/$18/$30 × 12 × 0.9 → rounded.
687 assert_eq!(get_f("derived.annual_founding_basic"), 86.0);
688 assert_eq!(get_f("derived.annual_founding_small_files"), 130.0);
689 assert_eq!(get_f("derived.annual_founding_big_files"), 194.0);
690 assert_eq!(get_f("derived.annual_founding_everything"), 324.0);
691
692 // Standard: $16/$24/$36/$60 × 12 × 0.9 → rounded.
693 assert_eq!(get_f("derived.annual_standard_basic"), 173.0);
694 assert_eq!(get_f("derived.annual_standard_small_files"), 259.0);
695 assert_eq!(get_f("derived.annual_standard_big_files"), 389.0);
696 assert_eq!(get_f("derived.annual_standard_everything"), 648.0);
697 }
698
699 #[test]
700 fn substitute_applies_ceil_filter_to_derived_value() {
701 let a = loaded();
702 let out = a
703 .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.")
704 .unwrap();
705 // break_even_standard ≈ 22.6 → ceil → 23 (with full marginal model at
706 // standard $16/$24/$36/$60: 580 / (26.80 − 1.128) ≈ 22.6).
707 assert_eq!(out, "Break-even at ~23 creators.");
708 }
709
710 #[test]
711 fn substitute_applies_percent_filter() {
712 let a = loaded();
713 let out = a.substitute("Stripe charges {{ stripe.percent | percent }}.").unwrap();
714 assert_eq!(out, "Stripe charges 2.9%.");
715 }
716
717 #[test]
718 fn substitute_applies_money_filter() {
719 let a = loaded();
720 let out = a.substitute("Flat fee: {{ stripe.fixed | money }}.").unwrap();
721 assert_eq!(out, "Flat fee: $0.30.");
722 }
723
724 #[test]
725 fn substitute_chains_filters() {
726 let a = loaded();
727 let out = a
728 .substitute("{{ derived.break_even_standard | round(1) }}")
729 .unwrap();
730 // 580 / (26.80 − ~1.128) ≈ 22.6 at canonical $16/$24/$36/$60.
731 assert_eq!(out, "22.6");
732 }
733
734 #[test]
735 fn substitute_consumer_can_register_custom_filter() {
736 // Closure-based filter: format thousands as "Nk".
737 let a = loaded().with_filter(
738 "kilo",
739 |v: LookupValue, _args: &[crate::filters::FilterArg]| {
740 let n = v.as_f64().ok_or_else(|| {
741 crate::filters::FilterError::type_error("kilo", &v)
742 })?;
743 Ok(LookupValue::String(format!("{:.1}k", n / 1000.0)))
744 },
745 );
746 let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap();
747 assert_eq!(out, "Cap: 62.0k");
748 }
749
750 #[test]
751 fn substitute_unknown_filter_reports_error() {
752 let a = loaded();
753 let err = a
754 .substitute("{{ expenses.F_monthly | nope }}")
755 .unwrap_err();
756 match err {
757 AssumptionsError::Substitution { unresolved } => {
758 assert!(unresolved.iter().any(|m| m.contains("unknown filter")), "{unresolved:?}");
759 }
760 other => panic!("{other:?}"),
761 }
762 }
763
764 #[test]
765 fn substitute_filter_type_mismatch_reports_error() {
766 let a = loaded();
767 let err = a
768 .substitute("{{ cohort.lock_duration | money }}")
769 .unwrap_err();
770 assert!(matches!(err, AssumptionsError::Substitution { .. }));
771 }
772
773 #[test]
774 fn substitute_skips_inline_code() {
775 let a = loaded();
776 let out = a
777 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
778 .unwrap();
779 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
780 }
781
782 #[test]
783 fn substitute_skips_fenced_code_block() {
784 let a = loaded();
785 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
786 let out = a.substitute(input).unwrap();
787 assert!(out.contains("Value: 580"));
788 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
789 }
790
791 #[test]
792 fn substitute_reports_unresolved_keys() {
793 let a = loaded();
794 let err = a
795 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
796 .unwrap_err();
797 match err {
798 AssumptionsError::Substitution { unresolved } => {
799 assert_eq!(unresolved, vec!["also.missing".to_string(), "nope.absent".to_string()]);
800 }
801 other => panic!("expected Substitution, got {other:?}"),
802 }
803 }
804
805 #[test]
806 fn validation_catches_f_monthly_out_of_range() {
807 let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50");
808 let a = Assumptions::parse(&t).unwrap();
809 let err = a.validate().unwrap_err();
810 match err {
811 AssumptionsError::Validation(v) => {
812 assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}");
813 }
814 other => panic!("{other:?}"),
815 }
816 }
817
818 #[test]
819 fn validation_catches_tier_mix_sum_off() {
820 let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50");
821 let a = Assumptions::parse(&t).unwrap();
822 let err = a.validate().unwrap_err();
823 match err {
824 AssumptionsError::Validation(v) => {
825 assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}");
826 }
827 other => panic!("{other:?}"),
828 }
829 }
830
831 #[test]
832 fn validation_catches_founding_above_standard() {
833 // `basic = 8` appears only in [tiers.founding] in the canonical fixture
834 // (standard has `basic = 16`), so this substring is unambiguous.
835 let t = FIXTURE.replace("basic = 8", "basic = 999");
836 let a = Assumptions::parse(&t).unwrap();
837 let err = a.validate().unwrap_err();
838 match err {
839 AssumptionsError::Validation(v) => {
840 assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}");
841 }
842 other => panic!("{other:?}"),
843 }
844 }
845
846 #[test]
847 fn validation_catches_surplus_split_off() {
848 let t = FIXTURE.replace("surplus_split_reserve = 0.20", "surplus_split_reserve = 0.30");
849 let a = Assumptions::parse(&t).unwrap();
850 let err = a.validate().unwrap_err();
851 match err {
852 AssumptionsError::Validation(v) => {
853 assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}");
854 }
855 other => panic!("{other:?}"),
856 }
857 }
858
859 #[test]
860 fn validation_catches_rho_incident_above_annual() {
861 let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75");
862 let a = Assumptions::parse(&t).unwrap();
863 let err = a.validate().unwrap_err();
864 match err {
865 AssumptionsError::Validation(v) => {
866 assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}");
867 }
868 other => panic!("{other:?}"),
869 }
870 }
871
872 #[test]
873 fn float_formatting_strips_trailing_zeros() {
874 assert_eq!(format_float(22.0), "22");
875 assert_eq!(format_float(0.59), "0.59");
876 assert_eq!(format_float(61960.0), "61960");
877 assert_eq!(format_float(0.5), "0.5");
878 assert_eq!(format_float(1.085), "1.085");
879 }
880
881 #[test]
882 fn unknown_fields_are_ignored() {
883 // Unknown sections shouldn't break load; they just won't appear in the
884 // typed view but should still be in the flat lookup.
885 let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n");
886 let a = Assumptions::parse(&extra).unwrap();
887 assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42)));
888 }
889
890 #[test]
891 fn load_from_path_round_trip() {
892 let dir = tempfile::tempdir().unwrap();
893 let path = dir.path().join("a.toml");
894 std::fs::write(&path, FIXTURE).unwrap();
895 let a = Assumptions::load(&path).unwrap();
896 a.validate().unwrap();
897 assert!(a.get("derived.R_cap").is_some());
898 }
899 }
900