emergency-fund — evidence pack
A per-model evidence bundle for vendor-risk and compliance review, rendered from the same artifacts the engine runs: the published method, the input contract, the versioned spec, the verification cases, the sourced constants, the change history, and a live run with its audit fingerprint. To keep a copy, print this page to PDF. Reviewing this without an engineering background: each section states what is claimed, where the claim comes from, and how to check it — nothing in this pack asks to be taken on faith.
Method — Concordance testing
con·cor·dance — agreement between independent witnesses.
Every Worthune model is implemented twice: once in the engine that serves you, and a second time — separately, in a different language, from the published spec alone, with no access to the engine's code. The two must agree on 250 cases per model, to one part in a billion, before any release. When they disagree, the release stops. The harness re-runs on every change.
Our mark is a picture of it: two squares drawn independently, solid only where they overlap. The answer is the shape they share, and nothing outside it.
What it does mean
- The engine implements its documented model exactly.
- The spec is precise enough for independent reproduction — that's what the second implementation proves.
- Regressions are caught: the harness re-runs on every change, and a mismatch blocks the release.
What it does not mean
- That a model's financial judgment is right for your situation — models simplify, and each spec names what it leaves out.
- That we've never shipped a mistake. We have; the fixes are in the public changelog. The method exists because we decided never to rely on trust again — ours included.
- Outputs are planning illustrations, not financial advice.
Input contract
All fields are required. Out-of-domain values are rejected with per-field
errors — never clamped. Unknown fields are rejected. The machine-readable
contract is public at /api/v1/models/emergency-fund.
| Field | Type | Valid domain |
|---|---|---|
monthlyExpenses | float | 500 – 20,000 |
targetMonths | int | 1 – 12 |
currentSavings | float | 0 – 100,000 |
monthlySavings | float | 50 – 5,000 |
savingsAccountRate | float | 0.01 – 0.08 |
Sentinel values
monthsToGoal=240→ goal not reached within 240 months (yearsToGoal = 20 is the never-sentinel in years)
Responses annotate these in a sentinels array with a triggered flag per run.
Full specification
Reproduced verbatim from the versioned spec (docs/model-specs/emergency-fund.md, served at /api/v1/models/emergency-fund/spec). This is
the authoritative contract: inputs with units, the exact computation,
assumptions, exclusions, known issues, and change history.
Model: emergency-fund — v1.0.0 (as-implemented)
Engine: calcEmergencyFund(inputs: EmergencyFundInputs) in src/lib/engine.ts. Months to reach a target of N months of expenses in a high-yield savings account, with a month-by-month balance projection.
Inputs
| Name | Type | Unit | Domain |
|---|---|---|---|
| monthlyExpenses | number | USD/mo | 500–20,000 |
| targetMonths | integer | months of coverage | 1–12 |
| currentSavings | number | USD | 0–100,000 |
| monthlySavings | number | USD/mo | 50–5,000 |
| savingsAccountRate | number | decimal/yr (0.048 = 4.8% APY) | 0.01–0.08 |
Computation
Let r_m = savingsAccountRate / 12 (nominal monthly convention), MAX = 240.
targetAmount = monthlyExpenses × targetMonths.- Simulation, months m = 0..240 inclusive (241 iterations), balance starting at
currentSavings,monthsToGoalinitialized to the sentinel 240. Each iteration, in this exact order:- record
projectedBalance[m] = {month: m, balance, target: targetAmount}(targetrepeated in every element); - record
coverageByMonth[m] = {month: m, monthsCovered: monthlyExpenses > 0 ? balance / monthlyExpenses : 0}; - if
balance ≥ targetAmountandmonthsToGoal === 240, setmonthsToGoal = m(first-hit latch; the balance recorded is the pre-update balance, so month 0 testscurrentSavingsitself); - update
balance = balance × (1 + r_m) + monthlySavings.
- record
finalBalance = projectedBalance[monthsToGoal].balance. (The code writes?? targetAmountas a fallback, but the array always has indices 0–240 andmonthsToGoal ≤ 240, so the fallback is dead.)interestEarned = max(0, finalBalance − currentSavings − monthlySavings × monthsToGoal).yearsToGoal = monthsToGoal / 12(float, no rounding).- The returned
projectedBalanceandcoverageByMonthare the firstmin(monthsToGoal + 1, 61)elements of the recorded arrays — i.e. truncated at month 60 even when the goal is reached later (or never).
No Math.round anywhere; no wall-clock dependence.
Output keys
targetAmount, monthsToGoal, yearsToGoal, interestEarned, projectedBalance (fields {month, balance, target}), coverageByMonth (fields {month, monthsCovered}). finalBalance is an intermediate, not an output.
Assumptions & exclusions (part of the contract)
- Deposits land end-of-month, after interest; the goal check sees the start-of-month balance, so a deposit that crosses the target counts in the following month.
- No inflation on the expense target; the target is fixed at t = 0.
- If
currentSavings ≥ targetAmount,monthsToGoal = 0andinterestEarned = 0.
Known model issues (v1.0.0)
- Sentinel collision:
monthsToGoal = 240means *either* "goal first reached at exactly month 240" *or* "never reached within 240 months" — the two are indistinguishable. The UI treats≥ 240as "Never". CorrespondinglyyearsToGoal = 20is the never-sentinel in years. - When the goal is never reached,
interestEarnedis still computed — over the full 240-month window, not "to goal" as the name implies. - Chart arrays are hard-truncated at 61 elements (months 0–60): for slow savers the plotted series ends well short of the goal/sentinel.
targetis duplicated into everyprojectedBalanceelement.- The
monthlyExpenses > 0guard on coverage returns 0 (not Infinity/NaN) at zero expenses; notetargetAmountis then also 0, so the goal is met at month 0. Out of domain, but defined behavior.
Verification
Vectors: oracle/vectors/emergency-fund.json (seeded fuzz + boundary cases). Tolerance: rel 1e-9 / abs 1e-6 per numeric field; projectedBalance and coverageByMonth element-wise.
Suggested curated edges:
- Already funded:
{monthlyExpenses: 500, targetMonths: 1, currentSavings: 100000, monthlySavings: 50, savingsAccountRate: 0.01}→monthsToGoal = 0, single-element arrays,interestEarned = 0. - Never-sentinel:
{monthlyExpenses: 20000, targetMonths: 12, currentSavings: 0, monthlySavings: 50, savingsAccountRate: 0.01}→monthsToGoal = 240, arrays truncated at 61 elements. - Truncation straddle: inputs tuned so the goal lands between months 61 and 239 (e.g.
{monthlyExpenses: 4500, targetMonths: 6, currentSavings: 0, monthlySavings: 300, savingsAccountRate: 0.04}) — exercisesmonthsToGoal > 60with 61-element arrays.
Verification
250 deterministic cases per model: the all-minimum and
all-maximum domain corners, 3 hand-written edge cases
(below), and seeded pseudo-random cases filling the rest. The independent
reimplementation must match the engine on every case to a relative tolerance
of 1e-9 (absolute 0.000001) before any
release. The cases are not a sample of our testing — they are the testing
itself, downloadable at /api/v1/evals/emergency-fund and re-runnable
against your own integration.
Hand-written cases and what each one pins
| Rationale | Inputs (unspecified fields take the all-minimum corner) |
|---|---|
| Already funded → monthsToGoal = 0, single-element arrays | {"monthlyExpenses":500,"targetMonths":1,"currentSavings":100000,"monthlySavings":50,"savingsAccountRate":0.01} |
| Never-sentinel 240, arrays truncated at 61 elements | {"monthlyExpenses":20000,"targetMonths":12,"currentSavings":0,"monthlySavings":50,"savingsAccountRate":0.01} |
| Goal lands between months 61 and 239 (truncation straddle) | {"monthlyExpenses":4500,"targetMonths":6,"currentSavings":0,"monthlySavings":300,"savingsAccountRate":0.04} |
To grade your own implementation against the engine at these
tolerances: POST /api/v1/grade/emergency-fund.
Constants consumed
This model consumes no constants from the facts registry — its outputs are fully determined by its inputs and the computation in the spec above.
Changelog
Current version: v1.0.0. Entries come straight from the spec's own changelog section; the full-catalog view is at worthune.com/models/changelog.
No changes since launch — this model is still on the version it shipped with. Behavior changes only through a spec version bump with a public changelog entry.
Sample run & decision record
This request was executed against the production engine when this document
was generated — the example cannot drift from the API's real behavior. Every
response carries a decision record: a SHA-256 fingerprint over
{model, specVersion, inputs, outputs} that lets you prove, months
later, where a number came from.
Request
POST /api/v1/models/emergency-fund
{
"monthlyExpenses": 10000,
"targetMonths": 7,
"currentSavings": 50000,
"monthlySavings": 2500,
"savingsAccountRate": 0.05
}Response (chart arrays compacted for print)
{
"targetAmount": 70000,
"monthsToGoal": 8,
"yearsToGoal": 0.6666666666666666,
"interestEarned": 1985.2857506217988,
"projectedBalance": [
{
"month": 0,
"balance": 50000,
"target": 70000
},
{
"month": 1,
"balance": 52708.333333333336,
"target": 70000
},
"… 7 more"
],
"coverageByMonth": [
{
"month": 0,
"monthsCovered": 5
},
{
"month": 1,
"monthsCovered": 5.270833333333334
},
"… 7 more"
]
}Decision record
sha256: 193846e35e9bde82213608b21a69c33051bbfb6198274d6a263e717b0c5acba9
Store this record with any advice or agent output built on these numbers. To verify later: build {model, specVersion, inputs, outputs} from the stored response, serialize as JSON with object keys sorted recursively (no whitespace), and SHA-256 it — a match proves the numbers came from this spec version with these inputs, unaltered. (The hash covers the full response outputs, not the compacted preview above — recompute from a live call.)