From f9db3dbe4b94930175ab4d621e6242078a60e7d4 Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Tue, 4 Aug 2026 11:30:05 -0400 Subject: [PATCH] fix(calculator): honest burn-multiple and runway labels, surface model assumptions (#274) * fix(calculator): honest burn-multiple and runway labels, surface model assumptions - Burn Multiple now reports Graham's metric (net burn / net new ARR); the net burn / MRR ratio is reported separately as Burn to Revenue. The qualifier (efficient/healthy/warning/critical) is derived from the real burn multiple, so DEAD verdicts no longer print 'efficient'. - ALIVE verdicts no longer print a misleading 'Runway: 120 months' (projection cap); output now shows 'Projected cash-out' with 'none within the 10-year projection' when the company never runs out. - Model assumptions (fixed/variable burn split, variable burn ratio, growth decay, projection cap, safety buffer) are now surfaced in the human report and in JSON model_assumptions. - SKILL.md: fix dead paulgraham.com/default.html source URL to aord.html; update output-field docs and examples to real model output. - Add regression tests (tests/integration/test_default_alive.py). Fixes #272 Fixes #273 * docs(calculator): add When Not to Use boundary (validator requirement) --- tests/integration/test_default_alive.py | 68 +++++++++++++++ yc-default-alive-calculator/SKILL.md | 42 +++++---- .../scripts/default-alive.py | 86 +++++++++++-------- 3 files changed, 146 insertions(+), 50 deletions(-) create mode 100644 tests/integration/test_default_alive.py diff --git a/tests/integration/test_default_alive.py b/tests/integration/test_default_alive.py new file mode 100644 index 0000000..0f9adf2 --- /dev/null +++ b/tests/integration/test_default_alive.py @@ -0,0 +1,68 @@ +"""Regression tests for yc-default-alive-calculator output labeling. + +Covers issues #272 (Burn Multiple mislabel) and #273 (runway display and +unsurfaced model assumptions): the canonical Burn Multiple is Graham's +net burn / net new ARR, the MRR ratio has an honest name, ALIVE verdicts +no longer print a misleading projection-cap runway, and model assumptions +are surfaced in the output. +""" + +import importlib.util +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[2] / "yc-default-alive-calculator" / "scripts" / "default-alive.py" + +_spec = importlib.util.spec_from_file_location("default_alive", SCRIPT) +assert _spec is not None and _spec.loader is not None +MOD = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(MOD) + + +def calc(revenue, burn, cash, growth): + return MOD.project_trajectory( + monthly_revenue=revenue, + monthly_burn=burn, + cash_on_hand=cash, + monthly_growth_pct=growth, + growth_decay_pct=0.5, + ) + + +def test_lean_saas_alive_diagnostics(): + d = calc(12000, 18000, 220000, 7) + diag = d["diagnostics"] + assert d["verdict"] == "ALIVE" + assert diag["burn_multiple"] == 0.6 # Graham: net burn / net new ARR + assert diag["burn_to_revenue_ratio"] == 0.5 # net burn / MRR + assert diag["projected_cashout_month"] is None + assert "none within the 10-year projection" in d["explanation"] + assert "Runway:" not in d["explanation"] + + +def test_dead_verdict_labels_are_honest(): + d = calc(12000, 26000, 220000, 3) + diag = d["diagnostics"] + assert d["verdict"] == "DEAD" + assert diag["burn_multiple"] == 3.24 + assert diag["burn_to_revenue_ratio"] == 1.17 + assert diag["projected_cashout_month"] == 18 + assert "critical" in d["explanation"] + assert "efficient" not in d["explanation"] + assert "net burn / net new ARR" in d["explanation"] + + +def test_model_assumptions_surfaced(): + d = calc(12000, 26000, 220000, 3) + a = d["model_assumptions"] + assert a["fixed_burn_pct"] == 70.0 + assert a["variable_burn_ratio"] == 0.65 + assert a["growth_decay_pct"] == 0.5 + assert a["projection_cap_months"] == 120 + assert a["safety_buffer_months"] == 3 + + +def test_pre_revenue_has_no_burn_multiple(): + d = calc(0, 80000, 400000, 0) + assert d["verdict"] == "DEAD" + assert d["diagnostics"]["burn_multiple"] is None + assert d["diagnostics"]["burn_to_revenue_ratio"] is None diff --git a/yc-default-alive-calculator/SKILL.md b/yc-default-alive-calculator/SKILL.md index d6c7d62..62dda4c 100644 --- a/yc-default-alive-calculator/SKILL.md +++ b/yc-default-alive-calculator/SKILL.md @@ -13,7 +13,7 @@ metadata: spec-version: '1.0' tags: startup-finance, runway-analysis, ycombinator, paul-graham, fundraising, financial-modeling, default-alive, burn-rate, startup-metrics - sources: https://paulgraham.com/default.html, https://paulgraham.com/die.html, https://www.ycombinator.com/about + sources: https://paulgraham.com/aord.html, https://paulgraham.com/die.html, https://www.ycombinator.com/about skills: yc-weekly-growth-compass requires-toolsets: terminal --- @@ -35,6 +35,14 @@ A startup is "default alive" if its current revenue trajectory will reach profit | "How long until we break even?" | Trajectory check | | "Default alive/dead analysis" | Explicit framework request | +## When Not to Use + +- **Detailed financial planning.** This is a diagnostic, not a budget. It projects one growth curve with fixed assumptions; it does not model hiring plans, contract timing, seasonality, or capital expenditures. Use a proper financial model for those. +- **Fundraising valuation.** The verdict tells you whether raising money is existential; it does not compute a valuation, cap table, or round size. +- **Cost structures the default assumptions don't describe.** The model splits burn into fixed and variable components (default 70/30). Hardware, R&D, or manufacturing companies with very different cost structures will get a misleading projection. +- **Unknown or unstable growth.** The model assumes a steady monthly growth rate with a small decay. A company in launch mode or product-market-fit search does not produce a meaningful projection. +- **As the only input to a board decision.** Treat the verdict as a conversation starter with your CFO or accountant, not as financial advice. + ## How to Use ### Quick Answer (No Script) @@ -64,7 +72,7 @@ python scripts/default-alive.py \ --monthly-growth 8 ``` -Output shows: runway (months), burn multiple, months to breakeven extrapolated, default alive/dead verdict, and the key levers available. +Output shows: the verdict, burn multiple (net burn ÷ net new ARR), burn-to-revenue ratio (net burn ÷ MRR), projected cash-out month, months to breakeven, cash gap coverage at current spend, the model's assumptions, and the key levers available. #### Required inputs @@ -87,12 +95,15 @@ Output shows: runway (months), burn multiple, months to breakeven extrapolated, | Field | Meaning | |-------|---------| -| `runway_months` | Months until cash runs out (at current burn) | -| `burn_multiple` | Net burn ÷ net new ARR | +| `projected_cashout_month` | Month cash runs out under the model; `null` if never within the 10-year projection | +| `burn_multiple` | Graham's burn multiple: net burn ÷ net new ARR | +| `burn_to_revenue_ratio` | Net burn ÷ MRR (secondary diagnostic, not Graham's metric) | | `months_to_breakeven` | Months until revenue ≥ expenses (extrapolated) | | `default_verdict` | `ALIVE`, `DEAD`, or `MARGINAL` | | `revenue_at_breakeven` | Projected revenue when/if breakeven reached | | `gap_to_breakeven` | Monthly shortfall remaining | +| `months_of_gap_remaining` | Static runway at current spend: cash ÷ monthly gap | +| `model_assumptions` | `fixed_burn_pct`, `variable_burn_ratio`, `growth_decay_pct`, `projection_cap_months`, `safety_buffer_months` | | `levers` | What can change the outcome (increase price, cut costs, etc.) | ## Methodology @@ -147,48 +158,47 @@ When the verdict is DEAD or MARGINAL, evaluate these levers (in rough order of i ## Examples -### YC Typical Profile (Default Alive) +### YC Typical Profile (Default Dead under the model) ```bash -python scripts/default-alive.py \ +python3 scripts/default-alive.py \ --monthly-revenue 30000 \ --monthly-burn 75000 \ --cash-on-hand 500000 \ --monthly-growth 10 ``` -- Runway: ~11 months -- Burn multiple: 1.5x -- Breakeven: ~14 months (3 months short on runway → MARGINAL) -- Verdict: **MARGINAL** — needs faster growth, cost cuts, or funding +- Projected cash-out: month 14 +- Burn multiple: 1.25x (net burn / net new ARR) +- Breakeven would require 22 months, after cash runs out +- Verdict: **DEAD** — needs faster growth, cost cuts, or funding ### Pre-Revenue Startup (Default Dead) ```bash -python scripts/default-alive.py \ +python3 scripts/default-alive.py \ --monthly-revenue 0 \ --monthly-burn 80000 \ --cash-on-hand 400000 \ --monthly-growth 0 ``` -- Runway: 5 months +- Projected cash-out: month 8 (the model burns only the fixed 70% of burn at zero revenue) - Burn multiple: undefined (no revenue) - Verdict: **DEAD** — fundraising is existential ### Capital-Efficient SaaS (Default Alive) ```bash -python scripts/default-alive.py \ +python3 scripts/default-alive.py \ --monthly-revenue 150000 \ --monthly-burn 180000 \ --cash-on-hand 2000000 \ --monthly-growth 7 ``` -- Runway: ~66 months (effectively infinite) -- Burn multiple: 0.2x -- Breakeven: ~3 months +- Projected cash-out: none within the 10-year projection +- Burn multiple: 0.24x (net burn / net new ARR) - Verdict: **ALIVE** ## References diff --git a/yc-default-alive-calculator/scripts/default-alive.py b/yc-default-alive-calculator/scripts/default-alive.py index fcfd3ff..b4e9d7d 100755 --- a/yc-default-alive-calculator/scripts/default-alive.py +++ b/yc-default-alive-calculator/scripts/default-alive.py @@ -16,9 +16,6 @@ See SKILL.md for full documentation and methodology. import argparse import json -import math -import sys -from typing import Optional # --------------------------------------------------------------------------- # Core calculation @@ -62,8 +59,8 @@ def project_trajectory( current_growth = growth_rate trajectory = [] - breakeven_month: Optional[int] = None - cashout_month: Optional[int] = None + breakeven_month: int | None = None + cashout_month: int | None = None for month in range(1, MAX_PROJECTION_MONTHS + 1): # Revenue grows (or decays) at current growth rate @@ -117,29 +114,27 @@ def project_trajectory( # Diagnostics # ------------------------------------------------------------------ - runway_months = cashout_month if cashout_month else MAX_PROJECTION_MONTHS + runway_months = cashout_month if cashout_month else None # None = never runs out within projection net_burn = monthly_burn - monthly_revenue - burn_multiple = round(net_burn / max(monthly_revenue, 1), 2) if monthly_revenue > 0 else None - # Net new ARR (monthly growth in ARR terms) - last_month_revenue = monthly_revenue + # Secondary diagnostic: net burn / MRR. This is NOT Graham's burn multiple. + burn_to_revenue_ratio = round(net_burn / max(monthly_revenue, 1), 2) if monthly_revenue > 0 else None + + # Graham's burn multiple: net burn / net new ARR (annualized new recurring revenue added this month) current_arr = monthly_revenue * 12 if monthly_growth_pct > 0 and monthly_revenue > 0: next_arr = (monthly_revenue * (1 + monthly_growth_pct / 100)) * 12 net_new_arr = next_arr - current_arr - arr_burn_multiple = round(net_burn / max(net_new_arr, 1), 2) if net_new_arr > 0 else None + burn_multiple = round(net_burn / max(net_new_arr, 1), 2) if net_new_arr > 0 else None else: net_new_arr = 0 - arr_burn_multiple = None + burn_multiple = None # Verdict if breakeven_month is not None and cashout_month is not None: if breakeven_month < cashout_month: post_breakeven_runway = cashout_month - breakeven_month - if post_breakeven_runway >= SAFETY_BUFFER_MONTHS: - verdict = "ALIVE" - else: - verdict = "MARGINAL" + verdict = "ALIVE" if post_breakeven_runway >= SAFETY_BUFFER_MONTHS else "MARGINAL" else: verdict = "DEAD" elif breakeven_month is not None and cashout_month is None: @@ -194,20 +189,27 @@ def project_trajectory( "diagnostics": { "net_monthly_burn": round(net_burn, 2), "burn_multiple": burn_multiple, - "arr_burn_multiple": arr_burn_multiple, + "burn_to_revenue_ratio": burn_to_revenue_ratio, "current_arr": round(current_arr, 2), "net_new_arr": round(net_new_arr, 2), - "runway_months": runway_months, + "projected_cashout_month": runway_months, "months_to_breakeven": breakeven_month, "cashout_month": cashout_month, "gap_to_breakeven": round(gap_to_breakeven, 2), "months_of_gap_remaining": months_of_gap, "months_projected": len(trajectory), }, + "model_assumptions": { + "fixed_burn_pct": fixed_burn_pct, + "variable_burn_ratio": round(variable_burn_ratio, 4), + "growth_decay_pct": growth_decay_pct, + "projection_cap_months": MAX_PROJECTION_MONTHS, + "safety_buffer_months": SAFETY_BUFFER_MONTHS, + }, "verdict": verdict, "explanation": _generate_explanation( verdict, runway_months, breakeven_month, cashout_month, - burn_multiple, arr_burn_multiple, monthly_revenue, monthly_burn, + burn_multiple, burn_to_revenue_ratio, monthly_revenue, monthly_burn, cash_on_hand, ), "levers": levers, @@ -219,11 +221,11 @@ def project_trajectory( def _generate_explanation( verdict: str, - runway_months: int, - breakeven_month: Optional[int], - cashout_month: Optional[int], - burn_multiple: Optional[float], - arr_burn_multiple: Optional[float], + runway_months: int | None, + breakeven_month: int | None, + cashout_month: int | None, + burn_multiple: float | None, + burn_to_revenue_ratio: float | None, monthly_revenue: float, monthly_burn: float, cash_on_hand: float, @@ -232,27 +234,31 @@ def _generate_explanation( lines = [] if verdict == "ALIVE": - lines.append(f"✅ DEFAULT ALIVE — You will reach profitability before running out of cash.") + lines.append("✅ DEFAULT ALIVE — You will reach profitability before running out of cash.") if breakeven_month: lines.append(f" Breakeven projected at month {breakeven_month}.") elif verdict == "DEAD": - lines.append(f"❌ DEFAULT DEAD — You will run out of cash before reaching profitability.") + lines.append("❌ DEFAULT DEAD — You will run out of cash before reaching profitability.") if cashout_month: lines.append(f" Cash runs out at month {cashout_month}.") if breakeven_month: lines.append(f" Breakeven would require {breakeven_month} months — too late.") else: - lines.append(f"⚠️ MARGINAL — Breakeven is possible but dangerously close to cash-out.") + lines.append("⚠️ MARGINAL — Breakeven is possible but dangerously close to cash-out.") if breakeven_month and cashout_month: lines.append(f" Breakeven at month {breakeven_month}, cash-out at month {cashout_month}.") lines.append(f" Only {cashout_month - breakeven_month} months of post-breakeven buffer (need {SAFETY_BUFFER_MONTHS}+).") lines.append("") if burn_multiple is not None: - lines.append(f" Burn Multiple: {burn_multiple}x ({'efficient' if burn_multiple < 2 else 'inefficient' if burn_multiple < 3 else 'critical'})") - if arr_burn_multiple is not None: - lines.append(f" ARR Burn Multiple: {arr_burn_multiple}x (net burn / net new ARR)") - lines.append(f" Runway: {runway_months} months") + qualifier = "efficient" if burn_multiple < 1 else "healthy" if burn_multiple < 2 else "warning" if burn_multiple < 3 else "critical" + lines.append(f" Burn Multiple: {burn_multiple}x ({qualifier}, net burn / net new ARR)") + if burn_to_revenue_ratio is not None: + lines.append(f" Burn to Revenue: {burn_to_revenue_ratio}x (net burn / MRR)") + if runway_months is None: + lines.append(" Projected cash-out: none within the 10-year projection") + else: + lines.append(f" Projected cash-out (model): month {runway_months}") lines.append(f" Monthly gap: ${monthly_burn - monthly_revenue:,.0f}") lines.append(f" Cash: ${cash_on_hand:,.0f}") @@ -295,18 +301,30 @@ def format_output(result: dict, verbose: bool) -> str: lines.append("── Diagnostics ─────────────────────────────────────────") lines.append(f" Net monthly burn: ${diag['net_monthly_burn']:>8,.0f}") if diag["burn_multiple"] is not None: - lines.append(f" Burn Multiple: {diag['burn_multiple']:>8.2f}x") - if diag["arr_burn_multiple"] is not None: - lines.append(f" ARR Burn Multiple: {diag['arr_burn_multiple']:>8.2f}x (net burn ÷ net new ARR)") + lines.append(f" Burn Multiple: {diag['burn_multiple']:>8.2f}x (net burn ÷ net new ARR)") + if diag["burn_to_revenue_ratio"] is not None: + lines.append(f" Burn to Revenue: {diag['burn_to_revenue_ratio']:>8.2f}x (net burn ÷ MRR)") lines.append(f" Current ARR: ${diag['current_arr']:>8,.0f}") if diag["net_new_arr"]: lines.append(f" Net new ARR/month: ${diag['net_new_arr']:>8,.0f}") - lines.append(f" Runway: {diag['runway_months']:>8} months") + cashout = diag["projected_cashout_month"] + cashout_label = "none within 10y projection" if cashout is None else f"month {cashout}" + lines.append(f" Projected cash-out: {cashout_label:>8}") lines.append(f" Months to breakeven: {diag['months_to_breakeven'] or 'never':>8}") if diag["months_of_gap_remaining"]: lines.append(f" Cash gap coverage: {diag['months_of_gap_remaining']:>8.1f} months at current spend") lines.append("") + # Model assumptions + if "model_assumptions" in result: + a = result["model_assumptions"] + lines.append("── Model Assumptions ──────────────────────────────") + lines.append(f" Fixed burn: {a['fixed_burn_pct']:.0f}% of burn fixed; {100 - a['fixed_burn_pct']:.0f}% scales with revenue") + lines.append(f" Variable burn ratio: {a['variable_burn_ratio']:.2f} per $1 of revenue") + lines.append(f" Growth decay: {a['growth_decay_pct']:.1f}% per month") + lines.append(f" Projection cap: {a['projection_cap_months']} months | Safety buffer: {a['safety_buffer_months']} months") + lines.append("") + # Levers if result["levers"]: lines.append("── Levers ───────────────────────────────────────────────")