emergency-fund — evidence pack

spec v1.0.0 · generated 2026-09-20 · worthune.com/docs/models/emergency-fund

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.

FieldTypeValid domain
monthlyExpensesfloat500 – 20,000
targetMonthsint1 – 12
currentSavingsfloat0 – 100,000
monthlySavingsfloat50 – 5,000
savingsAccountRatefloat0.01 – 0.08

Sentinel values

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

NameTypeUnitDomain
monthlyExpensesnumberUSD/mo500–20,000
targetMonthsintegermonths of coverage1–12
currentSavingsnumberUSD0–100,000
monthlySavingsnumberUSD/mo50–5,000
savingsAccountRatenumberdecimal/yr (0.048 = 4.8% APY)0.01–0.08

Computation

Let r_m = savingsAccountRate / 12 (nominal monthly convention), MAX = 240.

  1. targetAmount = monthlyExpenses × targetMonths.
  2. Simulation, months m = 0..240 inclusive (241 iterations), balance starting at currentSavings, monthsToGoal initialized to the sentinel 240. Each iteration, in this exact order:
    1. record projectedBalance[m] = {month: m, balance, target: targetAmount} (target repeated in every element);
    2. record coverageByMonth[m] = {month: m, monthsCovered: monthlyExpenses > 0 ? balance / monthlyExpenses : 0};
    3. if balance ≥ targetAmount and monthsToGoal === 240, set monthsToGoal = m (first-hit latch; the balance recorded is the pre-update balance, so month 0 tests currentSavings itself);
    4. update balance = balance × (1 + r_m) + monthlySavings.
  3. finalBalance = projectedBalance[monthsToGoal].balance. (The code writes ?? targetAmount as a fallback, but the array always has indices 0–240 and monthsToGoal ≤ 240, so the fallback is dead.)
  4. interestEarned = max(0, finalBalance − currentSavings − monthlySavings × monthsToGoal).
  5. yearsToGoal = monthsToGoal / 12 (float, no rounding).
  6. The returned projectedBalance and coverageByMonth are the first min(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 = 0 and interestEarned = 0.

Known model issues (v1.0.0)

  • Sentinel collision: monthsToGoal = 240 means *either* "goal first reached at exactly month 240" *or* "never reached within 240 months" — the two are indistinguishable. The UI treats ≥ 240 as "Never". Correspondingly yearsToGoal = 20 is the never-sentinel in years.
  • When the goal is never reached, interestEarned is 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.
  • target is duplicated into every projectedBalance element.
  • The monthlyExpenses > 0 guard on coverage returns 0 (not Infinity/NaN) at zero expenses; note targetAmount is 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}) — exercises monthsToGoal > 60 with 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

RationaleInputs (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.)