Skip to main content

max / makenotwork

32.7 KB · 898 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 ships with the MNW repo at
567 // server/docs/business/assumptions.toml. include_str! resolves relative to
568 // this source file (MNW/shared/docengine/src/), so we walk up to MNW/ then
569 // into server/docs/business/.
570 const FIXTURE: &str = include_str!(
571 "../../../server/docs/business/assumptions.toml"
572 );
573
574 fn loaded() -> Assumptions {
575 Assumptions::parse(FIXTURE).expect("fixture parses")
576 }
577
578 #[test]
579 fn fixture_loads_and_validates() {
580 let a = loaded();
581 a.validate().expect("fixture validates");
582 }
583
584 #[test]
585 fn raw_lookup_returns_int_and_float_variants() {
586 let a = loaded();
587 assert_eq!(a.get("expenses.F_monthly"), Some(&LookupValue::Int(580)));
588 assert_eq!(a.get("stripe.percent"), Some(&LookupValue::Float(0.029)));
589 assert_eq!(
590 a.get("cohort.lock_duration"),
591 Some(&LookupValue::String("lifetime".into()))
592 );
593 }
594
595 #[test]
596 fn derived_values_match_worked_examples() {
597 let a = loaded();
598 // R_cap = 12 · 580 + 50000 + 5000 = 61960
599 let r_cap = match a.get("derived.R_cap").unwrap() {
600 LookupValue::Float(x) => *x,
601 v => panic!("expected float, got {v:?}"),
602 };
603 assert!((r_cap - 61960.0).abs() < 1e-6, "R_cap = {r_cap}");
604
605 // ARPU_standard = 0.4·16 + 0.3·24 + 0.2·36 + 0.1·60 = 26.80
606 let arpu = match a.get("derived.ARPU_standard").unwrap() {
607 LookupValue::Float(x) => *x,
608 v => panic!("{v:?}"),
609 };
610 assert!((arpu - 26.80).abs() < 1e-9, "ARPU_standard = {arpu}");
611
612 // stripe_fee_basic_std = 0.029 · 16 + 0.30 = 0.764
613 let fee = match a.get("derived.stripe_fee_basic_std").unwrap() {
614 LookupValue::Float(x) => *x,
615 v => panic!("{v:?}"),
616 };
617 assert!((fee - 0.764).abs() < 1e-9, "stripe_fee_basic_std = {fee}");
618 }
619
620 #[test]
621 fn substitute_replaces_known_keys() {
622 let a = loaded();
623 let out = a
624 .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.")
625 .unwrap();
626 assert_eq!(out, "Fixed monthly costs are $580.");
627 }
628
629 #[test]
630 fn substitute_replaces_derived_keys() {
631 let a = loaded();
632 let out = a.substitute("R_cap = ${{ derived.R_cap }}").unwrap();
633 assert_eq!(out, "R_cap = $61960");
634 }
635
636 #[test]
637 fn substitute_handles_whitespace_in_markers() {
638 let a = loaded();
639 let out = a
640 .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}")
641 .unwrap();
642 assert_eq!(out, "a=580 b=580");
643 }
644
645 #[test]
646 fn marginal_cost_components_decomposition() {
647 let a = loaded();
648 let get_f = |k: &str| match a.get(k).unwrap() {
649 LookupValue::Float(x) => *x,
650 v => panic!("expected float at {k}, got {v:?}"),
651 };
652
653 // Storage: weighted GB × $/GB/mo. 5.64 GB × 0.0065 = 0.03666
654 let storage = get_f("derived.marginal_storage");
655 assert!((storage - 0.03666).abs() < 1e-4, "storage = {storage}");
656
657 // Stripe fee on creator subs at standard rates (weighted by tier mix):
658 // 0.4·0.764 + 0.3·0.996 + 0.2·1.344 + 0.1·2.040 = 1.0772
659 let stripe = get_f("derived.marginal_stripe_standard");
660 assert!((stripe - 1.0772).abs() < 1e-4, "stripe = {stripe}");
661
662 // Chargeback EV: 0.001 × $15 dispute = $0.015
663 let chargeback = get_f("derived.marginal_chargeback");
664 assert!((chargeback - 0.015).abs() < 1e-9, "chargeback = {chargeback}");
665
666 // marginal_avg_standard = sum
667 let avg = get_f("derived.marginal_avg_standard");
668 assert!((avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}");
669
670 // Founding Stripe fee is lower (lower tier prices, less % component):
671 // 0.4·0.532 + 0.3·0.648 + 0.2·0.822 + 0.1·1.170 = 0.6886
672 let stripe_f = get_f("derived.marginal_stripe_founding");
673 assert!((stripe_f - 0.6886).abs() < 1e-4, "stripe_f = {stripe_f}");
674 }
675
676 #[test]
677 fn derived_annual_prices_match_published_values() {
678 let a = loaded();
679 let get_f = |k: &str| match a.get(k).unwrap() {
680 LookupValue::Float(x) => *x,
681 v => panic!("expected float at {k}, got {v:?}"),
682 };
683
684 // Founder: $8/$12/$18/$30 × 12 × 0.9 → rounded.
685 assert_eq!(get_f("derived.annual_founding_basic"), 86.0);
686 assert_eq!(get_f("derived.annual_founding_small_files"), 130.0);
687 assert_eq!(get_f("derived.annual_founding_big_files"), 194.0);
688 assert_eq!(get_f("derived.annual_founding_everything"), 324.0);
689
690 // Standard: $16/$24/$36/$60 × 12 × 0.9 → rounded.
691 assert_eq!(get_f("derived.annual_standard_basic"), 173.0);
692 assert_eq!(get_f("derived.annual_standard_small_files"), 259.0);
693 assert_eq!(get_f("derived.annual_standard_big_files"), 389.0);
694 assert_eq!(get_f("derived.annual_standard_everything"), 648.0);
695 }
696
697 #[test]
698 fn substitute_applies_ceil_filter_to_derived_value() {
699 let a = loaded();
700 let out = a
701 .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.")
702 .unwrap();
703 // break_even_standard ≈ 22.6 → ceil → 23 (with full marginal model at
704 // standard $16/$24/$36/$60: 580 / (26.80 − 1.128) ≈ 22.6).
705 assert_eq!(out, "Break-even at ~23 creators.");
706 }
707
708 #[test]
709 fn substitute_applies_percent_filter() {
710 let a = loaded();
711 let out = a.substitute("Stripe charges {{ stripe.percent | percent }}.").unwrap();
712 assert_eq!(out, "Stripe charges 2.9%.");
713 }
714
715 #[test]
716 fn substitute_applies_money_filter() {
717 let a = loaded();
718 let out = a.substitute("Flat fee: {{ stripe.fixed | money }}.").unwrap();
719 assert_eq!(out, "Flat fee: $0.30.");
720 }
721
722 #[test]
723 fn substitute_chains_filters() {
724 let a = loaded();
725 let out = a
726 .substitute("{{ derived.break_even_standard | round(1) }}")
727 .unwrap();
728 // 580 / (26.80 − ~1.128) ≈ 22.6 at canonical $16/$24/$36/$60.
729 assert_eq!(out, "22.6");
730 }
731
732 #[test]
733 fn substitute_consumer_can_register_custom_filter() {
734 // Closure-based filter: format thousands as "Nk".
735 let a = loaded().with_filter(
736 "kilo",
737 |v: LookupValue, _args: &[crate::filters::FilterArg]| {
738 let n = v.as_f64().ok_or_else(|| {
739 crate::filters::FilterError::type_error("kilo", &v)
740 })?;
741 Ok(LookupValue::String(format!("{:.1}k", n / 1000.0)))
742 },
743 );
744 let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap();
745 assert_eq!(out, "Cap: 62.0k");
746 }
747
748 #[test]
749 fn substitute_unknown_filter_reports_error() {
750 let a = loaded();
751 let err = a
752 .substitute("{{ expenses.F_monthly | nope }}")
753 .unwrap_err();
754 match err {
755 AssumptionsError::Substitution { unresolved } => {
756 assert!(unresolved.iter().any(|m| m.contains("unknown filter")), "{unresolved:?}");
757 }
758 other => panic!("{other:?}"),
759 }
760 }
761
762 #[test]
763 fn substitute_filter_type_mismatch_reports_error() {
764 let a = loaded();
765 let err = a
766 .substitute("{{ cohort.lock_duration | money }}")
767 .unwrap_err();
768 assert!(matches!(err, AssumptionsError::Substitution { .. }));
769 }
770
771 #[test]
772 fn substitute_skips_inline_code() {
773 let a = loaded();
774 let out = a
775 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
776 .unwrap();
777 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
778 }
779
780 #[test]
781 fn substitute_skips_fenced_code_block() {
782 let a = loaded();
783 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
784 let out = a.substitute(input).unwrap();
785 assert!(out.contains("Value: 580"));
786 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
787 }
788
789 #[test]
790 fn substitute_reports_unresolved_keys() {
791 let a = loaded();
792 let err = a
793 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
794 .unwrap_err();
795 match err {
796 AssumptionsError::Substitution { unresolved } => {
797 assert_eq!(unresolved, vec!["also.missing".to_string(), "nope.absent".to_string()]);
798 }
799 other => panic!("expected Substitution, got {other:?}"),
800 }
801 }
802
803 #[test]
804 fn validation_catches_f_monthly_out_of_range() {
805 let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50");
806 let a = Assumptions::parse(&t).unwrap();
807 let err = a.validate().unwrap_err();
808 match err {
809 AssumptionsError::Validation(v) => {
810 assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}");
811 }
812 other => panic!("{other:?}"),
813 }
814 }
815
816 #[test]
817 fn validation_catches_tier_mix_sum_off() {
818 let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50");
819 let a = Assumptions::parse(&t).unwrap();
820 let err = a.validate().unwrap_err();
821 match err {
822 AssumptionsError::Validation(v) => {
823 assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}");
824 }
825 other => panic!("{other:?}"),
826 }
827 }
828
829 #[test]
830 fn validation_catches_founding_above_standard() {
831 // `basic = 8` appears only in [tiers.founding] in the canonical fixture
832 // (standard has `basic = 16`), so this substring is unambiguous.
833 let t = FIXTURE.replace("basic = 8", "basic = 999");
834 let a = Assumptions::parse(&t).unwrap();
835 let err = a.validate().unwrap_err();
836 match err {
837 AssumptionsError::Validation(v) => {
838 assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}");
839 }
840 other => panic!("{other:?}"),
841 }
842 }
843
844 #[test]
845 fn validation_catches_surplus_split_off() {
846 let t = FIXTURE.replace("surplus_split_reserve = 0.20", "surplus_split_reserve = 0.30");
847 let a = Assumptions::parse(&t).unwrap();
848 let err = a.validate().unwrap_err();
849 match err {
850 AssumptionsError::Validation(v) => {
851 assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}");
852 }
853 other => panic!("{other:?}"),
854 }
855 }
856
857 #[test]
858 fn validation_catches_rho_incident_above_annual() {
859 let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75");
860 let a = Assumptions::parse(&t).unwrap();
861 let err = a.validate().unwrap_err();
862 match err {
863 AssumptionsError::Validation(v) => {
864 assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}");
865 }
866 other => panic!("{other:?}"),
867 }
868 }
869
870 #[test]
871 fn float_formatting_strips_trailing_zeros() {
872 assert_eq!(format_float(22.0), "22");
873 assert_eq!(format_float(0.59), "0.59");
874 assert_eq!(format_float(61960.0), "61960");
875 assert_eq!(format_float(0.5), "0.5");
876 assert_eq!(format_float(1.085), "1.085");
877 }
878
879 #[test]
880 fn unknown_fields_are_ignored() {
881 // Unknown sections shouldn't break load; they just won't appear in the
882 // typed view but should still be in the flat lookup.
883 let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n");
884 let a = Assumptions::parse(&extra).unwrap();
885 assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42)));
886 }
887
888 #[test]
889 fn load_from_path_round_trip() {
890 let dir = tempfile::tempdir().unwrap();
891 let path = dir.path().join("a.toml");
892 std::fs::write(&path, FIXTURE).unwrap();
893 let a = Assumptions::load(&path).unwrap();
894 a.validate().unwrap();
895 assert!(a.get("derived.R_cap").is_some());
896 }
897 }
898