Skip to main content

max / makenotwork

43.0 KB · 1180 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` is the
36 // 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 // The keep column is price minus the rounded fee, $25 - round($1.025),
837 // not round($25 - $1.025). Consistent with the fee column.
838 assert_eq!(get_money("stripe_keep_on_25"), "$23.97");
839 assert_eq!(get_money("stripe_keep_on_50"), "$48.25");
840 }
841
842 #[test]
843 fn derived_discount_pcts_render_as_whole_percent() {
844 let a = loaded();
845 // percent(0) renders the whole-percent form used in the docs. Both are
846 // stable under Refined-A: annual discount is 10%, founder is 50% of std.
847 assert_eq!(
848 a.substitute("{{ derived.annual_discount_pct | percent(0) }}")
849 .unwrap(),
850 "10%"
851 );
852 assert_eq!(
853 a.substitute("{{ derived.founder_discount_pct | percent(0) }}")
854 .unwrap(),
855 "50%"
856 );
857 }
858
859 // Both sides of the price assertion are whole dollars out of `.round()`,
860 // so exact equality is the assertion, not an epsilon comparison.
861 #[allow(clippy::float_cmp)]
862 #[test]
863 fn derived_annual_prices_match_monthly_times_discount() {
864 // Formula: monthly × 12 × annual_discount.multiplier, rounded to
865 // the nearest whole dollar. All four tiers × two rate classes.
866 let a = loaded();
867 let get_f = |k: &str| match a.get(k).unwrap() {
868 LookupValue::Float(x) => *x,
869 v => panic!("expected float at {k}, got {v:?}"),
870 };
871 let get_i = |k: &str| match a.get(k).unwrap() {
872 LookupValue::Int(n) => *n as f64,
873 LookupValue::Float(x) => *x,
874 v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"),
875 };
876 let mult = get_f("annual_discount.multiplier");
877 for tier in ["basic", "small_files", "big_files", "everything"] {
878 for class in ["founding", "standard"] {
879 let monthly = get_i(&format!("tiers.{class}.{tier}"));
880 let expected = (monthly * 12.0 * mult).round();
881 let actual = get_f(&format!("derived.annual_{class}_{tier}"));
882 assert_eq!(
883 actual, expected,
884 "annual_{class}_{tier}: {actual} != round({monthly} × 12 × {mult}) = {expected}"
885 );
886 }
887 }
888 }
889
890 #[test]
891 fn substitute_applies_ceil_filter_to_derived_value() {
892 let a = loaded();
893 let out = a
894 .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.")
895 .unwrap();
896 // The numeric value drifts with any pricing change, so pin the *shape*
897 // (integer followed by "creators.") rather than a specific N.
898 assert!(
899 out.starts_with("Break-even at ~") && out.ends_with(" creators."),
900 "unexpected shape: {out:?}"
901 );
902 let n_str = out
903 .trim_start_matches("Break-even at ~")
904 .trim_end_matches(" creators.");
905 let n: i64 = n_str.parse().expect("ceil should render as an integer");
906 assert!(n > 0, "break-even should be positive, got {n}");
907 }
908
909 #[test]
910 fn substitute_applies_percent_filter() {
911 let a = loaded();
912 let out = a
913 .substitute("Stripe charges {{ stripe.percent | percent }}.")
914 .unwrap();
915 assert_eq!(out, "Stripe charges 2.9%.");
916 }
917
918 #[test]
919 fn substitute_applies_money_filter() {
920 let a = loaded();
921 let out = a
922 .substitute("Flat fee: {{ stripe.fixed | money }}.")
923 .unwrap();
924 assert_eq!(out, "Flat fee: $0.30.");
925 }
926
927 #[test]
928 fn substitute_chains_filters() {
929 let a = loaded();
930 let out = a
931 .substitute("{{ derived.break_even_standard | round(1) }}")
932 .unwrap();
933 // round(1) produces a single-decimal string. Verify the shape
934 // rather than a specific value (which changes when prices change).
935 let dot = out
936 .find('.')
937 .expect("round(1) should include a decimal point");
938 assert_eq!(
939 out.len() - dot - 1,
940 1,
941 "should have exactly one decimal digit, got {out:?}"
942 );
943 let _: f64 = out.parse().expect("should parse as float");
944 }
945
946 #[test]
947 fn substitute_consumer_can_register_custom_filter() {
948 // Closure-based filter: format thousands as "Nk".
949 let a = loaded().with_filter("kilo", |v: LookupValue, _args: &[FilterArg]| {
950 let n = v
951 .as_f64()
952 .ok_or_else(|| FilterError::type_error("kilo", &v))?;
953 Ok(LookupValue::String(format!("{:.1}k", n / 1000.0)))
954 });
955 let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap();
956 assert_eq!(out, "Cap: 62.0k");
957 }
958
959 #[test]
960 fn substitute_unknown_filter_reports_error() {
961 let a = loaded();
962 let err = a.substitute("{{ expenses.F_monthly | nope }}").unwrap_err();
963 match err {
964 AssumptionsError::Substitution { unresolved } => {
965 assert!(
966 unresolved.iter().any(|m| m.contains("unknown filter")),
967 "{unresolved:?}"
968 );
969 }
970 other => panic!("{other:?}"),
971 }
972 }
973
974 #[test]
975 fn substitute_filter_type_mismatch_reports_error() {
976 let a = loaded();
977 let err = a
978 .substitute("{{ cohort.lock_duration | money }}")
979 .unwrap_err();
980 assert!(matches!(err, AssumptionsError::Substitution { .. }));
981 }
982
983 #[test]
984 fn substitute_skips_inline_code() {
985 let a = loaded();
986 let out = a
987 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
988 .unwrap();
989 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
990 }
991
992 #[test]
993 fn substitute_skips_fenced_code_block() {
994 let a = loaded();
995 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
996 let out = a.substitute(input).unwrap();
997 assert!(out.contains("Value: 580"));
998 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
999 }
1000
1001 #[test]
1002 fn substitute_reports_unresolved_keys() {
1003 let a = loaded();
1004 let err = a
1005 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
1006 .unwrap_err();
1007 match err {
1008 AssumptionsError::Substitution { unresolved } => {
1009 assert_eq!(
1010 unresolved,
1011 vec!["also.missing".to_string(), "nope.absent".to_string()]
1012 );
1013 }
1014 other => panic!("expected Substitution, got {other:?}"),
1015 }
1016 }
1017
1018 #[test]
1019 fn validation_catches_f_monthly_out_of_range() {
1020 let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50");
1021 let a = Assumptions::parse(&t).unwrap();
1022 let err = a.validate().unwrap_err();
1023 match err {
1024 AssumptionsError::Validation(v) => {
1025 assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}");
1026 }
1027 other => panic!("{other:?}"),
1028 }
1029 }
1030
1031 #[test]
1032 fn validation_catches_tier_mix_sum_off() {
1033 let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50");
1034 let a = Assumptions::parse(&t).unwrap();
1035 let err = a.validate().unwrap_err();
1036 match err {
1037 AssumptionsError::Validation(v) => {
1038 assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}");
1039 }
1040 other => panic!("{other:?}"),
1041 }
1042 }
1043
1044 #[test]
1045 fn validation_catches_founding_above_standard() {
1046 // Rewrite `[tiers.founding]` so basic > standard.basic. Using a
1047 // section-anchored search keeps this test working regardless of the
1048 // specific dollar figure the standard tier is set to.
1049 let re =
1050 regex_lite::Regex::new(r"(?m)^\[tiers\.founding\]\n((?:.*\n)*?)basic = \d+").unwrap();
1051 let t = re
1052 .replace(FIXTURE, |caps: &regex_lite::Captures| {
1053 format!("[tiers.founding]\n{}basic = 99999", &caps[1])
1054 })
1055 .into_owned();
1056 assert!(t != FIXTURE, "regex must have matched");
1057 let a = Assumptions::parse(&t).unwrap();
1058 let err = a.validate().unwrap_err();
1059 match err {
1060 AssumptionsError::Validation(v) => {
1061 assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}");
1062 }
1063 other => panic!("{other:?}"),
1064 }
1065 }
1066
1067 #[test]
1068 fn parse_size_bytes_binary_units() {
1069 // No space, case-insensitive suffix, binary units.
1070 assert_eq!(parse_size_bytes("10MB").unwrap(), 10 * 1024 * 1024);
1071 assert_eq!(parse_size_bytes("50GB").unwrap(), 50 * 1024 * 1024 * 1024);
1072 assert_eq!(parse_size_bytes("500MB").unwrap(), 500 * 1024 * 1024);
1073 assert_eq!(
1074 parse_size_bytes("250GB").unwrap(),
1075 250i64 * 1024 * 1024 * 1024
1076 );
1077 assert_eq!(
1078 parse_size_bytes("500GB").unwrap(),
1079 500i64 * 1024 * 1024 * 1024
1080 );
1081 assert_eq!(
1082 parse_size_bytes("20GB").unwrap(),
1083 20i64 * 1024 * 1024 * 1024
1084 );
1085 assert_eq!(parse_size_bytes("1KB").unwrap(), 1024);
1086 assert_eq!(parse_size_bytes("1B").unwrap(), 1);
1087 // Case-insensitive.
1088 assert_eq!(parse_size_bytes("10mb").unwrap(), 10 * 1024 * 1024);
1089 // Space between number and unit is tolerated (parser trims), even
1090 // though the [tier_limits] convention writes "10MB" with no space.
1091 assert_eq!(parse_size_bytes("10 MB").unwrap(), 10 * 1024 * 1024);
1092 // Rejects malformed input.
1093 assert!(parse_size_bytes("10ZB").is_err(), "unknown unit");
1094 assert!(parse_size_bytes("MB").is_err(), "missing number");
1095 }
1096
1097 #[test]
1098 fn validate_catches_tier_bytes_display_drift() {
1099 // Rewrite `basic_per_file` from its current display value to "999GB"
1100 // while leaving `tier_bytes.basic_per_file` alone — validator should
1101 // catch the mismatch. Using a fresh regex is more robust than
1102 // hardcoding whatever the current display string happens to be.
1103 let re = regex_lite::Regex::new(r#"basic_per_file = "[^"]+""#).unwrap();
1104 let broken = re
1105 .replace(FIXTURE, r#"basic_per_file = "999GB""#)
1106 .into_owned();
1107 assert!(broken != FIXTURE, "regex must have matched something");
1108 let a = Assumptions::parse(&broken).unwrap();
1109 let err = a.validate().unwrap_err();
1110 match err {
1111 AssumptionsError::Validation(rules) => {
1112 assert!(
1113 rules
1114 .iter()
1115 .any(|r| r.contains("tier_limits.basic_per_file")
1116 && r.contains("tier_bytes.basic_per_file")),
1117 "expected drift failure, got: {rules:?}"
1118 );
1119 }
1120 other => panic!("expected Validation, got {other:?}"),
1121 }
1122 }
1123
1124 #[test]
1125 fn validate_accepts_canonical_tier_bytes_pairing() {
1126 let a = loaded();
1127 // Guards that the canonical fixture's tier_bytes match tier_limits.
1128 a.validate()
1129 .expect("canonical fixture: tier_bytes must match tier_limits");
1130 }
1131
1132 #[test]
1133 fn validation_catches_surplus_split_off() {
1134 let t = FIXTURE.replace(
1135 "surplus_split_reserve = 0.20",
1136 "surplus_split_reserve = 0.30",
1137 );
1138 let a = Assumptions::parse(&t).unwrap();
1139 let err = a.validate().unwrap_err();
1140 match err {
1141 AssumptionsError::Validation(v) => {
1142 assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}");
1143 }
1144 other => panic!("{other:?}"),
1145 }
1146 }
1147
1148 #[test]
1149 fn validation_catches_rho_incident_above_annual() {
1150 let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75");
1151 let a = Assumptions::parse(&t).unwrap();
1152 let err = a.validate().unwrap_err();
1153 match err {
1154 AssumptionsError::Validation(v) => {
1155 assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}");
1156 }
1157 other => panic!("{other:?}"),
1158 }
1159 }
1160
1161 #[test]
1162 fn unknown_fields_are_ignored() {
1163 // Unknown sections shouldn't break load; they just won't appear in the
1164 // typed view but should still be in the flat lookup.
1165 let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n");
1166 let a = Assumptions::parse(&extra).unwrap();
1167 assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42)));
1168 }
1169
1170 #[test]
1171 fn load_from_path_round_trip() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let path = dir.path().join("a.toml");
1174 std::fs::write(&path, FIXTURE).unwrap();
1175 let a = Assumptions::load(&path).unwrap();
1176 a.validate().unwrap();
1177 assert!(a.get("derived.R_cap").is_some());
1178 }
1179 }
1180