Skip to main content

max / makenotwork

43.0 KB · 1181 lines History Blame Raw
1 //! Build-time substitution of MNW business assumptions into markdown.
2 //!
3 //! <!-- wiki: mnw-assumptions-overview -->
4 //!
5 //! Loads a TOML "source of truth" file, computes a registry of derived values
6 //! (Stripe fee math, tier pricing, break-even, founding-tier discounts, …),
7 //! validates internal consistency, and substitutes `{{ dotted.path }}` markers
8 //! in markdown before rendering.
9 //!
10 //! The generic `{{ path | filter }}` engine lives in the [`subst`] crate; this
11 //! crate is the MNW-specific layer on top: the typed mirror of
12 //! `assumptions.toml`, the derived-value calculator, and the validation rules.
13 //!
14 //! The intended pipeline is:
15 //!
16 //! ```ignore
17 //! let assumptions = Assumptions::load("assumptions.toml")?;
18 //! assumptions.validate()?;
19 //! let resolved = assumptions.substitute(&markdown)?;
20 //! let html = docengine::render_permissive(&resolved);
21 //! ```
22 //!
23 //! Substitution runs on raw markdown before parsing so values may appear
24 //! anywhere: prose, code spans, table cells, link text.
25
26 use std::collections::HashMap;
27 use std::fmt;
28 use std::fs;
29 use std::path::Path;
30
31 use serde::Deserialize;
32 use subst::{SubstError, Substituter, Value};
33
34 // Re-export the generic engine's public surface so consumers can register
35 // custom filters without depending on `subst` directly. `LookupValue` keeps the
36 // historical name for the leaf value type.
37 pub use subst::{Filter, FilterArg, FilterError, Value as LookupValue, code_span_ranges};
38
39 // --- public types
40
41 /// Top-level errors returned by [`Assumptions::load`] / [`substitute`].
42 #[derive(Debug)]
43 pub enum AssumptionsError {
44 Io(std::io::Error),
45 Parse(toml::de::Error),
46 Validation(Vec<String>),
47 Substitution { unresolved: Vec<String> },
48 }
49
50 impl fmt::Display for AssumptionsError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::Io(e) => write!(f, "I/O error: {e}"),
54 Self::Parse(e) => write!(f, "TOML parse error: {e}"),
55 Self::Validation(failures) => {
56 writeln!(f, "validation failed ({} rule(s)):", failures.len())?;
57 for rule in failures {
58 writeln!(f, " - {rule}")?;
59 }
60 Ok(())
61 }
62 Self::Substitution { unresolved } => {
63 write!(f, "unresolved placeholders: {}", unresolved.join(", "))
64 }
65 }
66 }
67 }
68
69 impl std::error::Error for AssumptionsError {}
70
71 impl From<std::io::Error> for AssumptionsError {
72 fn from(e: std::io::Error) -> Self {
73 Self::Io(e)
74 }
75 }
76
77 impl From<toml::de::Error> for AssumptionsError {
78 fn from(e: toml::de::Error) -> Self {
79 Self::Parse(e)
80 }
81 }
82
83 impl From<SubstError> for AssumptionsError {
84 fn from(e: SubstError) -> Self {
85 let SubstError::Unresolved(unresolved) = e;
86 Self::Substitution { unresolved }
87 }
88 }
89
90 /// Loaded + validated business assumptions plus a populated substitution engine.
91 pub struct Assumptions {
92 typed: Typed,
93 subst: Substituter,
94 }
95
96 impl Assumptions {
97 /// Load assumptions from a TOML file.
98 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, AssumptionsError> {
99 let text = fs::read_to_string(path)?;
100 Self::parse(&text)
101 }
102
103 /// Parse assumptions from a TOML string.
104 pub fn parse(text: &str) -> Result<Self, AssumptionsError> {
105 let value: toml::Value = toml::from_str(text)?;
106 let typed: Typed = value.clone().try_into()?;
107
108 let mut lookup = HashMap::new();
109 walk_value(&value, String::new(), &mut lookup);
110 insert_derived(&typed, &mut lookup);
111
112 let mut subst = Substituter::new();
113 for (k, v) in lookup {
114 subst.insert(k, v);
115 }
116
117 Ok(Self { typed, subst })
118 }
119
120 /// Register a custom filter. Overrides any built-in or previously
121 /// registered filter with the same name.
122 ///
123 /// ```ignore
124 /// let a = Assumptions::load(path)?
125 /// .with_filter("k", |v, _args| {
126 /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?;
127 /// Ok(LookupValue::String(format!("{:.0}K", n / 1000.0)))
128 /// });
129 /// ```
130 #[must_use]
131 pub fn with_filter(mut self, name: impl Into<String>, filter: impl Filter + 'static) -> Self {
132 self.subst = self.subst.with_filter(name, filter);
133 self
134 }
135
136 /// Run all consistency checks. Returns `Err` listing every failed rule.
137 pub fn validate(&self) -> Result<(), AssumptionsError> {
138 let mut failures = Vec::new();
139 let typed = &self.typed;
140
141 // Typo guard on fixed costs.
142 if !(100.0 < typed.expenses.f_monthly && typed.expenses.f_monthly < 10_000.0) {
143 failures.push(format!(
144 "expenses.F_monthly = {} is outside (100, 10000)",
145 typed.expenses.f_monthly
146 ));
147 }
148
149 // Tier mix must sum to 1.0.
150 let mix = &typed.tier_mix.assumed;
151 let mix_sum = mix.basic_pct + mix.small_files_pct + mix.big_files_pct + mix.everything_pct;
152 if (mix_sum - 1.0).abs() > 1e-6 {
153 failures.push(format!("tier_mix.assumed sums to {mix_sum}, expected 1.0"));
154 }
155
156 // Surplus split must sum to 1.0.
157 let split_sum = typed.reserve.surplus_split_reserve + typed.reserve.surplus_split_earnback;
158 if (split_sum - 1.0).abs() > 1e-6 {
159 failures.push(format!(
160 "reserve.surplus_split_{{reserve,earnback}} sums to {split_sum}, expected 1.0"
161 ));
162 }
163
164 // Rho bounds.
165 if !(0.0 < typed.reserve.rho_annual && typed.reserve.rho_annual <= 1.0) {
166 failures.push(format!(
167 "reserve.rho_annual = {} is outside (0, 1]",
168 typed.reserve.rho_annual
169 ));
170 }
171 if !(0.0 < typed.reserve.rho_incident && typed.reserve.rho_incident <= 1.0) {
172 failures.push(format!(
173 "reserve.rho_incident = {} is outside (0, 1]",
174 typed.reserve.rho_incident
175 ));
176 }
177 if typed.reserve.rho_incident > typed.reserve.rho_annual {
178 failures.push(format!(
179 "reserve.rho_incident ({}) > reserve.rho_annual ({})",
180 typed.reserve.rho_incident, typed.reserve.rho_annual
181 ));
182 }
183
184 // Founding ≤ standard for every tier.
185 let founding = &typed.tiers.founding;
186 let standard = &typed.tiers.standard;
187 for (name, fv, sv) in [
188 ("basic", founding.basic, standard.basic),
189 ("small_files", founding.small_files, standard.small_files),
190 ("big_files", founding.big_files, standard.big_files),
191 ("everything", founding.everything, standard.everything),
192 ] {
193 if fv > sv {
194 failures.push(format!(
195 "tiers.founding.{name} ({fv}) > tiers.standard.{name} ({sv})"
196 ));
197 }
198 }
199
200 // Cohort caps positive.
201 if typed.cohort.cap_count <= 0 {
202 failures.push(format!(
203 "cohort.cap_count = {} must be > 0",
204 typed.cohort.cap_count
205 ));
206 }
207 if typed.cohort.cap_months <= 0 {
208 failures.push(format!(
209 "cohort.cap_months = {} must be > 0",
210 typed.cohort.cap_months
211 ));
212 }
213
214 // tier_bytes.<k> must match the parsed tier_limits.<k> display string.
215 // Drift here would let a docs edit ("10 MB → 20 MB") ship without the
216 // upload gate agreeing, or vice versa. Binary units (KB = 1024 B).
217 let limits = &typed.tier_limits;
218 let bytes = &typed.tier_bytes;
219 for (name, disp, byt) in [
220 (
221 "basic_per_file",
222 &limits.basic_per_file,
223 bytes.basic_per_file,
224 ),
225 ("basic_total", &limits.basic_total, bytes.basic_total),
226 (
227 "small_files_per_file",
228 &limits.small_files_per_file,
229 bytes.small_files_per_file,
230 ),
231 (
232 "small_files_total",
233 &limits.small_files_total,
234 bytes.small_files_total,
235 ),
236 (
237 "big_files_per_file",
238 &limits.big_files_per_file,
239 bytes.big_files_per_file,
240 ),
241 (
242 "big_files_total",
243 &limits.big_files_total,
244 bytes.big_files_total,
245 ),
246 (
247 "everything_per_file",
248 &limits.everything_per_file,
249 bytes.everything_per_file,
250 ),
251 (
252 "everything_total",
253 &limits.everything_total,
254 bytes.everything_total,
255 ),
256 ] {
257 match parse_size_bytes(disp) {
258 Ok(parsed) if parsed == byt => {}
259 Ok(parsed) => failures.push(format!(
260 "tier_limits.{name} = {disp:?} parses to {parsed} bytes, \
261 but tier_bytes.{name} = {byt}"
262 )),
263 Err(e) => failures.push(format!(
264 "tier_limits.{name} = {disp:?} could not be parsed: {e}"
265 )),
266 }
267 }
268
269 if failures.is_empty() {
270 Ok(())
271 } else {
272 Err(AssumptionsError::Validation(failures))
273 }
274 }
275
276 /// Substitute `{{ dotted.path }}` placeholders in markdown.
277 ///
278 /// Returns `Err(Substitution)` listing every key that could not be
279 /// resolved. The output is the markdown with all resolved keys replaced;
280 /// unresolved keys are left in place when an error is returned, so callers
281 /// can grep for them.
282 pub fn substitute(&self, markdown: &str) -> Result<String, AssumptionsError> {
283 Ok(self.subst.substitute(markdown)?)
284 }
285
286 /// Look up a single key. Useful for testing and for programmatic callers
287 /// that don't want to go through markdown substitution.
288 pub fn get(&self, key: &str) -> Option<&LookupValue> {
289 self.subst.get(key)
290 }
291
292 /// Iterate over every available key (raw + derived) in arbitrary order.
293 pub fn keys(&self) -> impl Iterator<Item = &str> {
294 self.subst.keys()
295 }
296 }
297
298 // --- typed mirror of assumptions.toml
299 //
300 // Only the fields needed for validation and derived values. Unknown fields
301 // (e.g. `[expenses.lines]`, `[stripe.connect_express]`) are silently ignored
302 // by serde but still reach the lookup table through `walk_value`.
303
304 #[derive(Debug, Deserialize)]
305 struct Typed {
306 expenses: TExpenses,
307 stripe: TStripe,
308 tiers: TTiers,
309 tier_mix: TTierMix,
310 tier_limits: TTierLimits,
311 tier_bytes: TTierBytes,
312 reserve: TReserve,
313 cohort: TCohort,
314 creator_marginal: TCreatorMarginal,
315 annual_discount: TAnnualDiscount,
316 }
317
318 #[derive(Debug, Deserialize)]
319 struct TTierLimits {
320 basic_per_file: String,
321 basic_total: String,
322 small_files_per_file: String,
323 small_files_total: String,
324 big_files_per_file: String,
325 big_files_total: String,
326 everything_per_file: String,
327 everything_total: String,
328 }
329
330 #[derive(Debug, Deserialize)]
331 struct TTierBytes {
332 basic_per_file: i64,
333 basic_total: i64,
334 small_files_per_file: i64,
335 small_files_total: i64,
336 big_files_per_file: i64,
337 big_files_total: i64,
338 everything_per_file: i64,
339 everything_total: i64,
340 }
341
342 #[derive(Debug, Deserialize)]
343 struct TExpenses {
344 #[serde(rename = "F_monthly")]
345 f_monthly: f64,
346 }
347
348 #[derive(Debug, Deserialize)]
349 struct TStripe {
350 percent: f64,
351 fixed: f64,
352 dispute_fee: f64,
353 }
354
355 #[derive(Debug, Deserialize)]
356 struct TTiers {
357 founding: TTierPrices,
358 standard: TTierPrices,
359 }
360
361 #[derive(Debug, Deserialize)]
362 struct TTierPrices {
363 basic: f64,
364 small_files: f64,
365 big_files: f64,
366 everything: f64,
367 }
368
369 #[derive(Debug, Deserialize)]
370 struct TTierMix {
371 assumed: TMixWeights,
372 }
373
374 // The `_pct` suffix is the serde wire format: these names are the TOML keys the
375 // assumptions file is written with, so renaming them is a breaking change to the
376 // document format, not a refactor.
377 #[allow(clippy::struct_field_names)]
378 #[derive(Debug, Deserialize)]
379 struct TMixWeights {
380 basic_pct: f64,
381 small_files_pct: f64,
382 big_files_pct: f64,
383 everything_pct: f64,
384 }
385
386 #[derive(Debug, Deserialize)]
387 struct TReserve {
388 #[serde(rename = "T_fixed_months")]
389 t_fixed_months: f64,
390 #[serde(rename = "S_legal")]
391 s_legal: f64,
392 #[serde(rename = "S_shock")]
393 s_shock: f64,
394 #[serde(rename = "R_opp")]
395 r_opp: f64,
396 rho_annual: f64,
397 rho_incident: f64,
398 surplus_split_reserve: f64,
399 surplus_split_earnback: f64,
400 }
401
402 #[derive(Debug, Deserialize)]
403 struct TCohort {
404 cap_count: i64,
405 cap_months: i64,
406 }
407
408 #[derive(Debug, Deserialize)]
409 struct TAnnualDiscount {
410 multiplier: f64,
411 }
412
413 #[derive(Debug, Deserialize)]
414 struct TCreatorMarginal {
415 storage_basic_gb: f64,
416 storage_small_files_gb: f64,
417 storage_big_files_gb: f64,
418 storage_everything_gb: f64,
419 storage_cost_per_gb_per_month: f64,
420 chargeback_rate_tier_subs: f64,
421 }
422
423 // --- walking + derived
424
425 /// Parse a size display string ("10MB", "500GB") to bytes. Binary units
426 /// (KB = 1024 B). Accepts a decimal number and a case-insensitive suffix
427 /// with no space between them, matching the `[tier_limits]` convention.
428 fn parse_size_bytes(s: &str) -> Result<i64, String> {
429 let s = s.trim();
430 let split = s
431 .find(|c: char| c.is_ascii_alphabetic())
432 .ok_or_else(|| format!("no unit suffix in {s:?}"))?;
433 let (num, unit) = s.split_at(split);
434 let num: f64 = num
435 .trim()
436 .parse()
437 .map_err(|e| format!("bad number in {s:?}: {e}"))?;
438 let mult: f64 = match unit.trim().to_ascii_uppercase().as_str() {
439 "B" => 1.0,
440 "KB" => 1024.0,
441 "MB" => 1024.0 * 1024.0,
442 "GB" => 1024.0 * 1024.0 * 1024.0,
443 "TB" => 1024.0_f64.powi(4),
444 other => return Err(format!("unknown unit {other:?} in {s:?}")),
445 };
446 Ok((num * mult).round() as i64)
447 }
448
449 fn walk_value(value: &toml::Value, prefix: String, out: &mut HashMap<String, Value>) {
450 match value {
451 toml::Value::Table(table) => {
452 for (k, v) in table {
453 let key = if prefix.is_empty() {
454 k.clone()
455 } else {
456 format!("{prefix}.{k}")
457 };
458 walk_value(v, key, out);
459 }
460 }
461 toml::Value::Integer(n) => {
462 out.insert(prefix, Value::Int(*n));
463 }
464 toml::Value::Float(x) => {
465 out.insert(prefix, Value::Float(*x));
466 }
467 toml::Value::String(s) => {
468 out.insert(prefix, Value::String(s.clone()));
469 }
470 // Bools, arrays, datetimes: not substitutable.
471 toml::Value::Boolean(_) | toml::Value::Array(_) | toml::Value::Datetime(_) => {}
472 }
473 }
474
475 fn insert_derived(t: &Typed, out: &mut HashMap<String, Value>) {
476 let mut put = |k: &str, v: f64| {
477 out.insert(format!("derived.{k}"), Value::Float(v));
478 };
479
480 let r_cap =
481 t.reserve.t_fixed_months * t.expenses.f_monthly + t.reserve.s_legal + t.reserve.s_shock;
482 put("R_cap", r_cap);
483
484 // ARPU per rate class.
485 let mix = &t.tier_mix.assumed;
486 let arpu_founding = mix.basic_pct * t.tiers.founding.basic
487 + mix.small_files_pct * t.tiers.founding.small_files
488 + mix.big_files_pct * t.tiers.founding.big_files
489 + mix.everything_pct * t.tiers.founding.everything;
490 let arpu_standard = mix.basic_pct * t.tiers.standard.basic
491 + mix.small_files_pct * t.tiers.standard.small_files
492 + mix.big_files_pct * t.tiers.standard.big_files
493 + mix.everything_pct * t.tiers.standard.everything;
494 put("ARPU_founding", arpu_founding);
495 put("ARPU_standard", arpu_standard);
496
497 // Stripe fee per tier price (creator-facing examples — these are what the
498 // creator pays Stripe on their fan transactions, NOT what MNW pays Stripe).
499 let stripe_fee = |amt: f64| t.stripe.percent * amt + t.stripe.fixed;
500 put("stripe_fee_basic_std", stripe_fee(t.tiers.standard.basic));
501 put(
502 "stripe_fee_small_std",
503 stripe_fee(t.tiers.standard.small_files),
504 );
505 put("stripe_fee_big_std", stripe_fee(t.tiers.standard.big_files));
506 put("stripe_fee_ev_std", stripe_fee(t.tiers.standard.everything));
507
508 // Stripe fee + take-home on illustrative sale prices ($1, $2, $5, $10, $25,
509 // $50). Pin the "sale price → fee → you keep" tables in guide/tiers.md and
510 // guide/stripe.md. Values are pre-rounded to whole cents so the `money`
511 // filter's `:.2` format won't drift from float imprecision (e.g. $25 × 2.9%
512 // + $0.30 = 1.02499999… under f64, which naive :.2 would print as "$1.02").
513 // "you keep" is `sale − round(fee)` — arithmetically consistent with what a
514 // reader would compute from the fee column, at the cost of one-cent drift on
515 // $25 relative to the original hand-written doc ($23.97, not $23.98).
516 let round_cents = |x: f64| (x * 100.0).round() / 100.0;
517 for &n in &[1.0_f64, 2.0, 5.0, 10.0, 25.0, 50.0] {
518 let key = n as i64;
519 let fee = round_cents(stripe_fee(n));
520 put(&format!("stripe_fee_on_{key}"), fee);
521 put(&format!("stripe_keep_on_{key}"), n - fee);
522 }
523
524 // Discount percentages as decimals. Render with `| percent(0)` for whole
525 // numbers (e.g. `10%`, `50%`). `annual_discount_pct` kills the hardcoded
526 // "10% off" in pricing.md and tiers.md; `founder_discount_pct` kills the
527 // "50%-off" in guarantees.md. Founder discount uses the Basic tier as the
528 // canonical ratio; all four tiers currently give the same discount and
529 // that invariant is enforced elsewhere by the founding ≤ standard check.
530 put("annual_discount_pct", 1.0 - t.annual_discount.multiplier);
531 put(
532 "founder_discount_pct",
533 1.0 - t.tiers.founding.basic / t.tiers.standard.basic,
534 );
535
536 // Annual prices per tier (monthly × 12 × annual_discount.multiplier, rounded
537 // to nearest dollar). Substituted into docs as `${{ derived.annual_*_* }}`
538 // so a price change auto-propagates.
539 let yr = |monthly: f64| (monthly * 12.0 * t.annual_discount.multiplier).round();
540 put("annual_founding_basic", yr(t.tiers.founding.basic));
541 put(
542 "annual_founding_small_files",
543 yr(t.tiers.founding.small_files),
544 );
545 put("annual_founding_big_files", yr(t.tiers.founding.big_files));
546 put(
547 "annual_founding_everything",
548 yr(t.tiers.founding.everything),
549 );
550 put("annual_standard_basic", yr(t.tiers.standard.basic));
551 put(
552 "annual_standard_small_files",
553 yr(t.tiers.standard.small_files),
554 );
555 put("annual_standard_big_files", yr(t.tiers.standard.big_files));
556 put(
557 "annual_standard_everything",
558 yr(t.tiers.standard.everything),
559 );
560
561 // --- marginal cost per creator/month, broken down by component
562 //
563 // What MNW pays per active creator on top of fixed costs F:
564 //
565 // storage : weighted GB × $/GB/month — Hetzner object storage.
566 // stripe_sub : Stripe processing fee on the creator's tier subscription
567 // (creator→MNW). Weighted across the tier mix. Stripe fees
568 // on fan→creator transactions are $0 to MNW (Connect Std).
569 // chargeback : Expected dispute fee per sub/month (small but real).
570 //
571 // Components are emitted individually so docs can show a breakdown table.
572 // `marginal_avg_{standard,founding}` is the sum per rate class.
573 //
574 // NOT modeled (deliberately): egress (at current scale, within Hetzner's
575 // 20 TB/server free allowance), support time (unmeasured pre-launch — see
576 // A26 in assumptions.md).
577
578 let m = &t.creator_marginal;
579
580 let marginal_storage = (mix.basic_pct * m.storage_basic_gb
581 + mix.small_files_pct * m.storage_small_files_gb
582 + mix.big_files_pct * m.storage_big_files_gb
583 + mix.everything_pct * m.storage_everything_gb)
584 * m.storage_cost_per_gb_per_month;
585 put("marginal_storage", marginal_storage);
586
587 let weighted_stripe_sub = |tiers: &TTierPrices| {
588 mix.basic_pct * stripe_fee(tiers.basic)
589 + mix.small_files_pct * stripe_fee(tiers.small_files)
590 + mix.big_files_pct * stripe_fee(tiers.big_files)
591 + mix.everything_pct * stripe_fee(tiers.everything)
592 };
593 let marginal_stripe_standard = weighted_stripe_sub(&t.tiers.standard);
594 let marginal_stripe_founding = weighted_stripe_sub(&t.tiers.founding);
595 put("marginal_stripe_standard", marginal_stripe_standard);
596 put("marginal_stripe_founding", marginal_stripe_founding);
597
598 // Chargeback expected value: rate × dispute fee. The lost-revenue portion
599 // (disputed charge amount) is treated as a refund of the original sub
600 // payment, not a separate cost — it flows through ARPU naturally if rates
601 // are accurate. Only the $15 dispute fee is incremental.
602 let marginal_chargeback = m.chargeback_rate_tier_subs * t.stripe.dispute_fee;
603 put("marginal_chargeback", marginal_chargeback);
604
605 let marginal_avg_standard = marginal_storage + marginal_stripe_standard + marginal_chargeback;
606 let marginal_avg_founding = marginal_storage + marginal_stripe_founding + marginal_chargeback;
607 put("marginal_avg_standard", marginal_avg_standard);
608 put("marginal_avg_founding", marginal_avg_founding);
609 // Back-compat alias for the previous single-value marginal.
610 put("marginal_avg", marginal_avg_standard);
611
612 // Break-even creator counts (per rate class).
613 let break_even_standard = t.expenses.f_monthly / (arpu_standard - marginal_avg_standard);
614 let break_even_founding = t.expenses.f_monthly / (arpu_founding - marginal_avg_founding);
615 put("break_even_standard", break_even_standard);
616 put("break_even_founding", break_even_founding);
617
618 // Surplus at representative cohort sizes (per rate class, using the matching marginal).
619 let surplus = |n: f64, arpu: f64, marg: f64| n * (arpu - marg) - t.expenses.f_monthly;
620 put(
621 "surplus_100_standard",
622 surplus(100.0, arpu_standard, marginal_avg_standard),
623 );
624 put(
625 "surplus_500_standard",
626 surplus(500.0, arpu_standard, marginal_avg_standard),
627 );
628 put(
629 "surplus_100_founding",
630 surplus(100.0, arpu_founding, marginal_avg_founding),
631 );
632 put(
633 "surplus_500_founding",
634 surplus(500.0, arpu_founding, marginal_avg_founding),
635 );
636
637 // Fill-time in months to reach R_cap + R_opp at representative cohort sizes
638 // under the standard rate.
639 let target = r_cap + t.reserve.r_opp;
640 let fill_time = |n: f64| target / surplus(n, arpu_standard, marginal_avg_standard);
641 put("fill_time_100", fill_time(100.0));
642 put("fill_time_500", fill_time(500.0));
643 }
644
645 // --- tests
646
647 #[cfg(test)]
648 mod tests {
649 use super::*;
650
651 // Vendored copy of the canonical assumptions.toml (the source of truth
652 // lives in the MNW server repo at server/docs/business/assumptions.toml).
653 // Kept in-crate so this crate builds and tests in isolation, without
654 // reaching across repos. Refresh with:
655 // cp ../../server/docs/business/assumptions.toml tests/fixtures/
656 const FIXTURE: &str = include_str!("../tests/fixtures/assumptions.toml");
657
658 fn loaded() -> Assumptions {
659 Assumptions::parse(FIXTURE).expect("fixture parses")
660 }
661
662 #[test]
663 fn fixture_loads_and_validates() {
664 let a = loaded();
665 a.validate().expect("fixture validates");
666 }
667
668 #[test]
669 fn raw_lookup_returns_int_and_float_variants() {
670 let a = loaded();
671 assert_eq!(a.get("expenses.F_monthly"), Some(&LookupValue::Int(580)));
672 assert_eq!(a.get("stripe.percent"), Some(&LookupValue::Float(0.029)));
673 assert_eq!(
674 a.get("cohort.lock_duration"),
675 Some(&LookupValue::String("lifetime".into()))
676 );
677 }
678
679 #[test]
680 fn derived_values_match_worked_examples() {
681 let a = loaded();
682 let get_f = |k: &str| match a.get(k).unwrap() {
683 LookupValue::Float(x) => *x,
684 v => panic!("expected float at {k}, got {v:?}"),
685 };
686 let get_i = |k: &str| match a.get(k).unwrap() {
687 LookupValue::Int(n) => *n as f64,
688 LookupValue::Float(x) => *x,
689 v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"),
690 };
691
692 // R_cap = T_fixed_months · F_monthly + S_legal + S_shock — derived
693 // from the same toml the test loads, so a price/reserve edit doesn't
694 // force this test to be rewritten.
695 let f_monthly = get_i("expenses.F_monthly");
696 let t_fixed = get_i("reserve.T_fixed_months");
697 let s_legal = get_i("reserve.S_legal");
698 let s_shock = get_i("reserve.S_shock");
699 let r_cap = get_f("derived.R_cap");
700 let expected_r_cap = t_fixed * f_monthly + s_legal + s_shock;
701 assert!(
702 (r_cap - expected_r_cap).abs() < 1e-6,
703 "R_cap {r_cap} != {expected_r_cap}"
704 );
705
706 // ARPU_standard = Σ (mix.<tier>_pct × standard.<tier>).
707 let mix_basic = get_f("tier_mix.assumed.basic_pct");
708 let mix_small = get_f("tier_mix.assumed.small_files_pct");
709 let mix_big = get_f("tier_mix.assumed.big_files_pct");
710 let mix_ev = get_f("tier_mix.assumed.everything_pct");
711 let expected_arpu = mix_basic * get_i("tiers.standard.basic")
712 + mix_small * get_i("tiers.standard.small_files")
713 + mix_big * get_i("tiers.standard.big_files")
714 + mix_ev * get_i("tiers.standard.everything");
715 let arpu = get_f("derived.ARPU_standard");
716 assert!(
717 (arpu - expected_arpu).abs() < 1e-9,
718 "ARPU {arpu} != {expected_arpu}"
719 );
720
721 // stripe_fee_basic_std = stripe.percent × price + stripe.fixed.
722 let expected_fee =
723 get_f("stripe.percent") * get_i("tiers.standard.basic") + get_f("stripe.fixed");
724 let fee = get_f("derived.stripe_fee_basic_std");
725 assert!(
726 (fee - expected_fee).abs() < 1e-9,
727 "stripe_fee_basic_std {fee} != {expected_fee}"
728 );
729 }
730
731 #[test]
732 fn substitute_replaces_known_keys() {
733 let a = loaded();
734 let out = a
735 .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.")
736 .unwrap();
737 assert_eq!(out, "Fixed monthly costs are $580.");
738 }
739
740 #[test]
741 fn substitute_replaces_derived_keys() {
742 let a = loaded();
743 let out = a.substitute("R_cap = ${{ derived.R_cap }}").unwrap();
744 assert_eq!(out, "R_cap = $61960");
745 }
746
747 #[test]
748 fn substitute_handles_whitespace_in_markers() {
749 let a = loaded();
750 let out = a
751 .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}")
752 .unwrap();
753 assert_eq!(out, "a=580 b=580");
754 }
755
756 #[test]
757 fn marginal_cost_components_decomposition() {
758 let a = loaded();
759 let get_f = |k: &str| match a.get(k).unwrap() {
760 LookupValue::Float(x) => *x,
761 v => panic!("expected float at {k}, got {v:?}"),
762 };
763
764 let get_i = |k: &str| match a.get(k).unwrap() {
765 LookupValue::Int(n) => *n as f64,
766 LookupValue::Float(x) => *x,
767 v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"),
768 };
769
770 // Structural: marginal_avg_standard = storage + stripe + chargeback.
771 // The individual sub-lines are computed from the toml, so verifying
772 // the sum invariant is the useful assertion — pinning literal values
773 // would just re-encode a Refined-A tier mix.
774 let storage = get_f("derived.marginal_storage");
775 let stripe = get_f("derived.marginal_stripe_standard");
776 let chargeback = get_f("derived.marginal_chargeback");
777 let avg = get_f("derived.marginal_avg_standard");
778 assert!(
779 (avg - (storage + stripe + chargeback)).abs() < 1e-9,
780 "avg = {avg}"
781 );
782
783 // Chargeback formula: rate × dispute_fee. Pinned by the two inputs.
784 let expected_chargeback =
785 get_f("creator_marginal.chargeback_rate_tier_subs") * get_f("stripe.dispute_fee");
786 assert!(
787 (chargeback - expected_chargeback).abs() < 1e-9,
788 "chargeback {chargeback} != rate × dispute {expected_chargeback}"
789 );
790
791 // Founding Stripe fee ≤ standard (lower prices → lower % component).
792 let stripe_f = get_f("derived.marginal_stripe_founding");
793 assert!(
794 stripe_f <= stripe,
795 "founding stripe {stripe_f} should be ≤ standard {stripe}"
796 );
797
798 // Storage weighted by the assumed tier mix (all inputs from toml).
799 let expected_storage = (get_f("tier_mix.assumed.basic_pct")
800 * get_f("creator_marginal.storage_basic_gb")
801 + get_f("tier_mix.assumed.small_files_pct")
802 * get_i("creator_marginal.storage_small_files_gb")
803 + get_f("tier_mix.assumed.big_files_pct")
804 * get_i("creator_marginal.storage_big_files_gb")
805 + get_f("tier_mix.assumed.everything_pct")
806 * get_i("creator_marginal.storage_everything_gb"))
807 * get_f("creator_marginal.storage_cost_per_gb_per_month");
808 assert!(
809 (storage - expected_storage).abs() < 1e-9,
810 "storage {storage} != {expected_storage}"
811 );
812 }
813
814 #[test]
815 fn derived_stripe_fee_on_n_matches_published_table() {
816 let a = loaded();
817 let get_money = |k: &str| {
818 a.substitute(&format!("{{{{ derived.{k} | money }}}}"))
819 .unwrap()
820 };
821
822 // These strings must match the "Stripe fee → You keep" table in
823 // guide/tiers.md line 138-143 and guide/stripe.md exactly, or docs drift.
824 // Sale-price-based (not tier-price-based), so stable under Refined-A.
825 assert_eq!(get_money("stripe_fee_on_1"), "$0.33");
826 assert_eq!(get_money("stripe_fee_on_2"), "$0.36");
827 assert_eq!(get_money("stripe_fee_on_5"), "$0.45");
828 assert_eq!(get_money("stripe_fee_on_10"), "$0.59");
829 assert_eq!(get_money("stripe_fee_on_25"), "$1.03");
830 assert_eq!(get_money("stripe_fee_on_50"), "$1.75");
831
832 assert_eq!(get_money("stripe_keep_on_1"), "$0.67");
833 assert_eq!(get_money("stripe_keep_on_2"), "$1.64");
834 assert_eq!(get_money("stripe_keep_on_5"), "$4.55");
835 assert_eq!(get_money("stripe_keep_on_10"), "$9.41");
836 // Note: source doc previously had $23.98, computed as
837 // round($25 - $1.025) rather than $25 - round($1.025); we standardize
838 // on the latter for consistency with the fee column.
839 assert_eq!(get_money("stripe_keep_on_25"), "$23.97");
840 assert_eq!(get_money("stripe_keep_on_50"), "$48.25");
841 }
842
843 #[test]
844 fn derived_discount_pcts_render_as_whole_percent() {
845 let a = loaded();
846 // percent(0) renders the whole-percent form used in the docs. Both are
847 // stable under Refined-A: annual discount is 10%, founder is 50% of std.
848 assert_eq!(
849 a.substitute("{{ derived.annual_discount_pct | percent(0) }}")
850 .unwrap(),
851 "10%"
852 );
853 assert_eq!(
854 a.substitute("{{ derived.founder_discount_pct | percent(0) }}")
855 .unwrap(),
856 "50%"
857 );
858 }
859
860 // Both sides of the price assertion are whole dollars out of `.round()`,
861 // so exact equality is the assertion, not an epsilon comparison.
862 #[allow(clippy::float_cmp)]
863 #[test]
864 fn derived_annual_prices_match_monthly_times_discount() {
865 // Formula: monthly × 12 × annual_discount.multiplier, rounded to
866 // the nearest whole dollar. All four tiers × two rate classes.
867 let a = loaded();
868 let get_f = |k: &str| match a.get(k).unwrap() {
869 LookupValue::Float(x) => *x,
870 v => panic!("expected float at {k}, got {v:?}"),
871 };
872 let get_i = |k: &str| match a.get(k).unwrap() {
873 LookupValue::Int(n) => *n as f64,
874 LookupValue::Float(x) => *x,
875 v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"),
876 };
877 let mult = get_f("annual_discount.multiplier");
878 for tier in ["basic", "small_files", "big_files", "everything"] {
879 for class in ["founding", "standard"] {
880 let monthly = get_i(&format!("tiers.{class}.{tier}"));
881 let expected = (monthly * 12.0 * mult).round();
882 let actual = get_f(&format!("derived.annual_{class}_{tier}"));
883 assert_eq!(
884 actual, expected,
885 "annual_{class}_{tier}: {actual} != round({monthly} × 12 × {mult}) = {expected}"
886 );
887 }
888 }
889 }
890
891 #[test]
892 fn substitute_applies_ceil_filter_to_derived_value() {
893 let a = loaded();
894 let out = a
895 .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.")
896 .unwrap();
897 // The numeric value drifts with any pricing change, so pin the *shape*
898 // (integer followed by "creators.") rather than a specific N.
899 assert!(
900 out.starts_with("Break-even at ~") && out.ends_with(" creators."),
901 "unexpected shape: {out:?}"
902 );
903 let n_str = out
904 .trim_start_matches("Break-even at ~")
905 .trim_end_matches(" creators.");
906 let n: i64 = n_str.parse().expect("ceil should render as an integer");
907 assert!(n > 0, "break-even should be positive, got {n}");
908 }
909
910 #[test]
911 fn substitute_applies_percent_filter() {
912 let a = loaded();
913 let out = a
914 .substitute("Stripe charges {{ stripe.percent | percent }}.")
915 .unwrap();
916 assert_eq!(out, "Stripe charges 2.9%.");
917 }
918
919 #[test]
920 fn substitute_applies_money_filter() {
921 let a = loaded();
922 let out = a
923 .substitute("Flat fee: {{ stripe.fixed | money }}.")
924 .unwrap();
925 assert_eq!(out, "Flat fee: $0.30.");
926 }
927
928 #[test]
929 fn substitute_chains_filters() {
930 let a = loaded();
931 let out = a
932 .substitute("{{ derived.break_even_standard | round(1) }}")
933 .unwrap();
934 // round(1) produces a single-decimal string. Verify the shape
935 // rather than a specific value (which changes when prices change).
936 let dot = out
937 .find('.')
938 .expect("round(1) should include a decimal point");
939 assert_eq!(
940 out.len() - dot - 1,
941 1,
942 "should have exactly one decimal digit, got {out:?}"
943 );
944 let _: f64 = out.parse().expect("should parse as float");
945 }
946
947 #[test]
948 fn substitute_consumer_can_register_custom_filter() {
949 // Closure-based filter: format thousands as "Nk".
950 let a = loaded().with_filter("kilo", |v: LookupValue, _args: &[FilterArg]| {
951 let n = v
952 .as_f64()
953 .ok_or_else(|| FilterError::type_error("kilo", &v))?;
954 Ok(LookupValue::String(format!("{:.1}k", n / 1000.0)))
955 });
956 let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap();
957 assert_eq!(out, "Cap: 62.0k");
958 }
959
960 #[test]
961 fn substitute_unknown_filter_reports_error() {
962 let a = loaded();
963 let err = a.substitute("{{ expenses.F_monthly | nope }}").unwrap_err();
964 match err {
965 AssumptionsError::Substitution { unresolved } => {
966 assert!(
967 unresolved.iter().any(|m| m.contains("unknown filter")),
968 "{unresolved:?}"
969 );
970 }
971 other => panic!("{other:?}"),
972 }
973 }
974
975 #[test]
976 fn substitute_filter_type_mismatch_reports_error() {
977 let a = loaded();
978 let err = a
979 .substitute("{{ cohort.lock_duration | money }}")
980 .unwrap_err();
981 assert!(matches!(err, AssumptionsError::Substitution { .. }));
982 }
983
984 #[test]
985 fn substitute_skips_inline_code() {
986 let a = loaded();
987 let out = a
988 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
989 .unwrap();
990 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
991 }
992
993 #[test]
994 fn substitute_skips_fenced_code_block() {
995 let a = loaded();
996 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
997 let out = a.substitute(input).unwrap();
998 assert!(out.contains("Value: 580"));
999 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
1000 }
1001
1002 #[test]
1003 fn substitute_reports_unresolved_keys() {
1004 let a = loaded();
1005 let err = a
1006 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
1007 .unwrap_err();
1008 match err {
1009 AssumptionsError::Substitution { unresolved } => {
1010 assert_eq!(
1011 unresolved,
1012 vec!["also.missing".to_string(), "nope.absent".to_string()]
1013 );
1014 }
1015 other => panic!("expected Substitution, got {other:?}"),
1016 }
1017 }
1018
1019 #[test]
1020 fn validation_catches_f_monthly_out_of_range() {
1021 let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50");
1022 let a = Assumptions::parse(&t).unwrap();
1023 let err = a.validate().unwrap_err();
1024 match err {
1025 AssumptionsError::Validation(v) => {
1026 assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}");
1027 }
1028 other => panic!("{other:?}"),
1029 }
1030 }
1031
1032 #[test]
1033 fn validation_catches_tier_mix_sum_off() {
1034 let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50");
1035 let a = Assumptions::parse(&t).unwrap();
1036 let err = a.validate().unwrap_err();
1037 match err {
1038 AssumptionsError::Validation(v) => {
1039 assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}");
1040 }
1041 other => panic!("{other:?}"),
1042 }
1043 }
1044
1045 #[test]
1046 fn validation_catches_founding_above_standard() {
1047 // Rewrite `[tiers.founding]` so basic > standard.basic. Using a
1048 // section-anchored search keeps this test working regardless of the
1049 // specific dollar figure the standard tier is set to.
1050 let re =
1051 regex_lite::Regex::new(r"(?m)^\[tiers\.founding\]\n((?:.*\n)*?)basic = \d+").unwrap();
1052 let t = re
1053 .replace(FIXTURE, |caps: &regex_lite::Captures| {
1054 format!("[tiers.founding]\n{}basic = 99999", &caps[1])
1055 })
1056 .into_owned();
1057 assert!(t != FIXTURE, "regex must have matched");
1058 let a = Assumptions::parse(&t).unwrap();
1059 let err = a.validate().unwrap_err();
1060 match err {
1061 AssumptionsError::Validation(v) => {
1062 assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}");
1063 }
1064 other => panic!("{other:?}"),
1065 }
1066 }
1067
1068 #[test]
1069 fn parse_size_bytes_binary_units() {
1070 // No space, case-insensitive suffix, binary units.
1071 assert_eq!(parse_size_bytes("10MB").unwrap(), 10 * 1024 * 1024);
1072 assert_eq!(parse_size_bytes("50GB").unwrap(), 50 * 1024 * 1024 * 1024);
1073 assert_eq!(parse_size_bytes("500MB").unwrap(), 500 * 1024 * 1024);
1074 assert_eq!(
1075 parse_size_bytes("250GB").unwrap(),
1076 250i64 * 1024 * 1024 * 1024
1077 );
1078 assert_eq!(
1079 parse_size_bytes("500GB").unwrap(),
1080 500i64 * 1024 * 1024 * 1024
1081 );
1082 assert_eq!(
1083 parse_size_bytes("20GB").unwrap(),
1084 20i64 * 1024 * 1024 * 1024
1085 );
1086 assert_eq!(parse_size_bytes("1KB").unwrap(), 1024);
1087 assert_eq!(parse_size_bytes("1B").unwrap(), 1);
1088 // Case-insensitive.
1089 assert_eq!(parse_size_bytes("10mb").unwrap(), 10 * 1024 * 1024);
1090 // Space between number and unit is tolerated (parser trims), even
1091 // though the [tier_limits] convention writes "10MB" with no space.
1092 assert_eq!(parse_size_bytes("10 MB").unwrap(), 10 * 1024 * 1024);
1093 // Rejects malformed input.
1094 assert!(parse_size_bytes("10ZB").is_err(), "unknown unit");
1095 assert!(parse_size_bytes("MB").is_err(), "missing number");
1096 }
1097
1098 #[test]
1099 fn validate_catches_tier_bytes_display_drift() {
1100 // Rewrite `basic_per_file` from its current display value to "999GB"
1101 // while leaving `tier_bytes.basic_per_file` alone — validator should
1102 // catch the mismatch. Using a fresh regex is more robust than
1103 // hardcoding whatever the current display string happens to be.
1104 let re = regex_lite::Regex::new(r#"basic_per_file = "[^"]+""#).unwrap();
1105 let broken = re
1106 .replace(FIXTURE, r#"basic_per_file = "999GB""#)
1107 .into_owned();
1108 assert!(broken != FIXTURE, "regex must have matched something");
1109 let a = Assumptions::parse(&broken).unwrap();
1110 let err = a.validate().unwrap_err();
1111 match err {
1112 AssumptionsError::Validation(rules) => {
1113 assert!(
1114 rules
1115 .iter()
1116 .any(|r| r.contains("tier_limits.basic_per_file")
1117 && r.contains("tier_bytes.basic_per_file")),
1118 "expected drift failure, got: {rules:?}"
1119 );
1120 }
1121 other => panic!("expected Validation, got {other:?}"),
1122 }
1123 }
1124
1125 #[test]
1126 fn validate_accepts_canonical_tier_bytes_pairing() {
1127 let a = loaded();
1128 // Guards that the canonical fixture's tier_bytes match tier_limits.
1129 a.validate()
1130 .expect("canonical fixture: tier_bytes must match tier_limits");
1131 }
1132
1133 #[test]
1134 fn validation_catches_surplus_split_off() {
1135 let t = FIXTURE.replace(
1136 "surplus_split_reserve = 0.20",
1137 "surplus_split_reserve = 0.30",
1138 );
1139 let a = Assumptions::parse(&t).unwrap();
1140 let err = a.validate().unwrap_err();
1141 match err {
1142 AssumptionsError::Validation(v) => {
1143 assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}");
1144 }
1145 other => panic!("{other:?}"),
1146 }
1147 }
1148
1149 #[test]
1150 fn validation_catches_rho_incident_above_annual() {
1151 let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75");
1152 let a = Assumptions::parse(&t).unwrap();
1153 let err = a.validate().unwrap_err();
1154 match err {
1155 AssumptionsError::Validation(v) => {
1156 assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}");
1157 }
1158 other => panic!("{other:?}"),
1159 }
1160 }
1161
1162 #[test]
1163 fn unknown_fields_are_ignored() {
1164 // Unknown sections shouldn't break load; they just won't appear in the
1165 // typed view but should still be in the flat lookup.
1166 let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n");
1167 let a = Assumptions::parse(&extra).unwrap();
1168 assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42)));
1169 }
1170
1171 #[test]
1172 fn load_from_path_round_trip() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let path = dir.path().join("a.toml");
1175 std::fs::write(&path, FIXTURE).unwrap();
1176 let a = Assumptions::load(&path).unwrap();
1177 a.validate().unwrap();
1178 assert!(a.get("derived.R_cap").is_some());
1179 }
1180 }
1181