Skip to main content

max / makenotwork

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