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)
This commit is contained in:
Magnus Hedemark
2026-08-04 11:30:05 -04:00
committed by GitHub
parent 0ff373467c
commit f9db3dbe4b
3 changed files with 146 additions and 50 deletions
+68
View File
@@ -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
+26 -16
View File
@@ -13,7 +13,7 @@ metadata:
spec-version: '1.0' spec-version: '1.0'
tags: startup-finance, runway-analysis, ycombinator, paul-graham, fundraising, financial-modeling, tags: startup-finance, runway-analysis, ycombinator, paul-graham, fundraising, financial-modeling,
default-alive, burn-rate, startup-metrics 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 skills: yc-weekly-growth-compass
requires-toolsets: terminal 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 | | "How long until we break even?" | Trajectory check |
| "Default alive/dead analysis" | Explicit framework request | | "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 ## How to Use
### Quick Answer (No Script) ### Quick Answer (No Script)
@@ -64,7 +72,7 @@ python scripts/default-alive.py \
--monthly-growth 8 --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 #### Required inputs
@@ -87,12 +95,15 @@ Output shows: runway (months), burn multiple, months to breakeven extrapolated,
| Field | Meaning | | Field | Meaning |
|-------|---------| |-------|---------|
| `runway_months` | Months until cash runs out (at current burn) | | `projected_cashout_month` | Month cash runs out under the model; `null` if never within the 10-year projection |
| `burn_multiple` | Net burn ÷ net new ARR | | `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) | | `months_to_breakeven` | Months until revenue ≥ expenses (extrapolated) |
| `default_verdict` | `ALIVE`, `DEAD`, or `MARGINAL` | | `default_verdict` | `ALIVE`, `DEAD`, or `MARGINAL` |
| `revenue_at_breakeven` | Projected revenue when/if breakeven reached | | `revenue_at_breakeven` | Projected revenue when/if breakeven reached |
| `gap_to_breakeven` | Monthly shortfall remaining | | `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.) | | `levers` | What can change the outcome (increase price, cut costs, etc.) |
## Methodology ## Methodology
@@ -147,48 +158,47 @@ When the verdict is DEAD or MARGINAL, evaluate these levers (in rough order of i
## Examples ## Examples
### YC Typical Profile (Default Alive) ### YC Typical Profile (Default Dead under the model)
```bash ```bash
python scripts/default-alive.py \ python3 scripts/default-alive.py \
--monthly-revenue 30000 \ --monthly-revenue 30000 \
--monthly-burn 75000 \ --monthly-burn 75000 \
--cash-on-hand 500000 \ --cash-on-hand 500000 \
--monthly-growth 10 --monthly-growth 10
``` ```
- Runway: ~11 months - Projected cash-out: month 14
- Burn multiple: 1.5x - Burn multiple: 1.25x (net burn / net new ARR)
- Breakeven: ~14 months (3 months short on runway → MARGINAL) - Breakeven would require 22 months, after cash runs out
- Verdict: **MARGINAL** — needs faster growth, cost cuts, or funding - Verdict: **DEAD** — needs faster growth, cost cuts, or funding
### Pre-Revenue Startup (Default Dead) ### Pre-Revenue Startup (Default Dead)
```bash ```bash
python scripts/default-alive.py \ python3 scripts/default-alive.py \
--monthly-revenue 0 \ --monthly-revenue 0 \
--monthly-burn 80000 \ --monthly-burn 80000 \
--cash-on-hand 400000 \ --cash-on-hand 400000 \
--monthly-growth 0 --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) - Burn multiple: undefined (no revenue)
- Verdict: **DEAD** — fundraising is existential - Verdict: **DEAD** — fundraising is existential
### Capital-Efficient SaaS (Default Alive) ### Capital-Efficient SaaS (Default Alive)
```bash ```bash
python scripts/default-alive.py \ python3 scripts/default-alive.py \
--monthly-revenue 150000 \ --monthly-revenue 150000 \
--monthly-burn 180000 \ --monthly-burn 180000 \
--cash-on-hand 2000000 \ --cash-on-hand 2000000 \
--monthly-growth 7 --monthly-growth 7
``` ```
- Runway: ~66 months (effectively infinite) - Projected cash-out: none within the 10-year projection
- Burn multiple: 0.2x - Burn multiple: 0.24x (net burn / net new ARR)
- Breakeven: ~3 months
- Verdict: **ALIVE** - Verdict: **ALIVE**
## References ## References
@@ -16,9 +16,6 @@ See SKILL.md for full documentation and methodology.
import argparse import argparse
import json import json
import math
import sys
from typing import Optional
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Core calculation # Core calculation
@@ -62,8 +59,8 @@ def project_trajectory(
current_growth = growth_rate current_growth = growth_rate
trajectory = [] trajectory = []
breakeven_month: Optional[int] = None breakeven_month: int | None = None
cashout_month: Optional[int] = None cashout_month: int | None = None
for month in range(1, MAX_PROJECTION_MONTHS + 1): for month in range(1, MAX_PROJECTION_MONTHS + 1):
# Revenue grows (or decays) at current growth rate # Revenue grows (or decays) at current growth rate
@@ -117,29 +114,27 @@ def project_trajectory(
# Diagnostics # 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 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) # Secondary diagnostic: net burn / MRR. This is NOT Graham's burn multiple.
last_month_revenue = monthly_revenue 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 current_arr = monthly_revenue * 12
if monthly_growth_pct > 0 and monthly_revenue > 0: if monthly_growth_pct > 0 and monthly_revenue > 0:
next_arr = (monthly_revenue * (1 + monthly_growth_pct / 100)) * 12 next_arr = (monthly_revenue * (1 + monthly_growth_pct / 100)) * 12
net_new_arr = next_arr - current_arr 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: else:
net_new_arr = 0 net_new_arr = 0
arr_burn_multiple = None burn_multiple = None
# Verdict # Verdict
if breakeven_month is not None and cashout_month is not None: if breakeven_month is not None and cashout_month is not None:
if breakeven_month < cashout_month: if breakeven_month < cashout_month:
post_breakeven_runway = cashout_month - breakeven_month post_breakeven_runway = cashout_month - breakeven_month
if post_breakeven_runway >= SAFETY_BUFFER_MONTHS: verdict = "ALIVE" if post_breakeven_runway >= SAFETY_BUFFER_MONTHS else "MARGINAL"
verdict = "ALIVE"
else:
verdict = "MARGINAL"
else: else:
verdict = "DEAD" verdict = "DEAD"
elif breakeven_month is not None and cashout_month is None: elif breakeven_month is not None and cashout_month is None:
@@ -194,20 +189,27 @@ def project_trajectory(
"diagnostics": { "diagnostics": {
"net_monthly_burn": round(net_burn, 2), "net_monthly_burn": round(net_burn, 2),
"burn_multiple": burn_multiple, "burn_multiple": burn_multiple,
"arr_burn_multiple": arr_burn_multiple, "burn_to_revenue_ratio": burn_to_revenue_ratio,
"current_arr": round(current_arr, 2), "current_arr": round(current_arr, 2),
"net_new_arr": round(net_new_arr, 2), "net_new_arr": round(net_new_arr, 2),
"runway_months": runway_months, "projected_cashout_month": runway_months,
"months_to_breakeven": breakeven_month, "months_to_breakeven": breakeven_month,
"cashout_month": cashout_month, "cashout_month": cashout_month,
"gap_to_breakeven": round(gap_to_breakeven, 2), "gap_to_breakeven": round(gap_to_breakeven, 2),
"months_of_gap_remaining": months_of_gap, "months_of_gap_remaining": months_of_gap,
"months_projected": len(trajectory), "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, "verdict": verdict,
"explanation": _generate_explanation( "explanation": _generate_explanation(
verdict, runway_months, breakeven_month, cashout_month, 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, cash_on_hand,
), ),
"levers": levers, "levers": levers,
@@ -219,11 +221,11 @@ def project_trajectory(
def _generate_explanation( def _generate_explanation(
verdict: str, verdict: str,
runway_months: int, runway_months: int | None,
breakeven_month: Optional[int], breakeven_month: int | None,
cashout_month: Optional[int], cashout_month: int | None,
burn_multiple: Optional[float], burn_multiple: float | None,
arr_burn_multiple: Optional[float], burn_to_revenue_ratio: float | None,
monthly_revenue: float, monthly_revenue: float,
monthly_burn: float, monthly_burn: float,
cash_on_hand: float, cash_on_hand: float,
@@ -232,27 +234,31 @@ def _generate_explanation(
lines = [] lines = []
if verdict == "ALIVE": 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: if breakeven_month:
lines.append(f" Breakeven projected at month {breakeven_month}.") lines.append(f" Breakeven projected at month {breakeven_month}.")
elif verdict == "DEAD": 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: if cashout_month:
lines.append(f" Cash runs out at month {cashout_month}.") lines.append(f" Cash runs out at month {cashout_month}.")
if breakeven_month: if breakeven_month:
lines.append(f" Breakeven would require {breakeven_month} months — too late.") lines.append(f" Breakeven would require {breakeven_month} months — too late.")
else: 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: if breakeven_month and cashout_month:
lines.append(f" Breakeven at month {breakeven_month}, cash-out at month {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(f" Only {cashout_month - breakeven_month} months of post-breakeven buffer (need {SAFETY_BUFFER_MONTHS}+).")
lines.append("") lines.append("")
if burn_multiple is not None: 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'})") qualifier = "efficient" if burn_multiple < 1 else "healthy" if burn_multiple < 2 else "warning" if burn_multiple < 3 else "critical"
if arr_burn_multiple is not None: lines.append(f" Burn Multiple: {burn_multiple}x ({qualifier}, net burn / net new ARR)")
lines.append(f" ARR Burn Multiple: {arr_burn_multiple}x (net burn / net new ARR)") if burn_to_revenue_ratio is not None:
lines.append(f" Runway: {runway_months} months") 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" Monthly gap: ${monthly_burn - monthly_revenue:,.0f}")
lines.append(f" Cash: ${cash_on_hand:,.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("── Diagnostics ─────────────────────────────────────────")
lines.append(f" Net monthly burn: ${diag['net_monthly_burn']:>8,.0f}") lines.append(f" Net monthly burn: ${diag['net_monthly_burn']:>8,.0f}")
if diag["burn_multiple"] is not None: if diag["burn_multiple"] is not None:
lines.append(f" Burn Multiple: {diag['burn_multiple']:>8.2f}x") lines.append(f" Burn Multiple: {diag['burn_multiple']:>8.2f}x (net burn ÷ net new ARR)")
if diag["arr_burn_multiple"] is not None: if diag["burn_to_revenue_ratio"] 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 to Revenue: {diag['burn_to_revenue_ratio']:>8.2f}x (net burn ÷ MRR)")
lines.append(f" Current ARR: ${diag['current_arr']:>8,.0f}") lines.append(f" Current ARR: ${diag['current_arr']:>8,.0f}")
if diag["net_new_arr"]: if diag["net_new_arr"]:
lines.append(f" Net new ARR/month: ${diag['net_new_arr']:>8,.0f}") 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}") lines.append(f" Months to breakeven: {diag['months_to_breakeven'] or 'never':>8}")
if diag["months_of_gap_remaining"]: 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(f" Cash gap coverage: {diag['months_of_gap_remaining']:>8.1f} months at current spend")
lines.append("") 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 # Levers
if result["levers"]: if result["levers"]:
lines.append("── Levers ───────────────────────────────────────────────") lines.append("── Levers ───────────────────────────────────────────────")