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'
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
@@ -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 ───────────────────────────────────────────────")