relocation — evidence pack

spec v1.0.0 · generated 2026-09-22 · worthune.com/docs/models/relocation

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/relocation.

FieldTypeValid domain
currentSalaryfloat30,000 – 300,000
newSalaryfloat30,000 – 300,000
currentMonthlyExpensesfloat1,000 – 15,000
newMonthlyExpensesfloat1,000 – 15,000
movingCostsfloat0 – 50,000
currentSavingsfloat0 – 500,000
annualReturnfloat0.02 – 0.12
yearsHorizonint1 – 30

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/relocation.md, served at /api/v1/models/relocation/spec). This is the authoritative contract: inputs with units, the exact computation, assumptions, exclusions, known issues, and change history.

Model: relocation — v1.0.0 (as-implemented)

Engine: calcRelocation(inputs: RelocationInputs) in src/lib/engine.ts. Compares staying vs. moving on salary, cost of living, one-time moving costs, and invested surplus over a horizon.

Inputs

NameTypeUnitDomain
currentSalarynumberUSD/yr (gross)30,000–300,000
newSalarynumberUSD/yr (gross)30,000–300,000
currentMonthlyExpensesnumberUSD/mo1,000–15,000
newMonthlyExpensesnumberUSD/mo1,000–15,000
movingCostsnumberUSD one-time0–50,000
currentSavingsnumberUSD0–500,000
annualReturnnumberdecimal/yr0.02–0.12
yearsHorizonintegeryears1–30

Computation

  1. annualSalaryDelta = newSalary − currentSalary
  2. annualExpenseDelta = (newMonthlyExpenses − currentMonthlyExpenses) × 12
  3. annualNetDelta = annualSalaryDelta − annualExpenseDelta
  4. monthlyNetDelta = annualNetDelta / 12
  5. breakEvenMonths = ceil(movingCosts / monthlyNetDelta) if monthlyNetDelta > 0, else the sentinel 9999 ("never"). movingCostRecoveryMonths is the same value.
  6. Path savings (gross, per month, floored at 0): stayMonthlySavings = max(0, currentSalary/12 − currentMonthlyExpenses); moveMonthlySavings = max(0, newSalary/12 − newMonthlyExpenses).
  7. Wealth paths, annual steps, index y = 0..yearsHorizon. Starting balances: stay currentSavings; move currentSavings − movingCosts (may go negative and stays negative until savings outweigh it). yearlyData[y] = {year: y, stay, move} records the balance before that year's growth; then each balance updates as bal = bal × (1 + annualReturn) + monthlySavings × 12.
  8. wealthAtHorizonStay/Move = yearlyData[yearsHorizon] values; netWealthGain = move − stay at horizon.

Output keys

annualSalaryDelta, annualExpenseDelta, annualNetDelta, movingCostRecoveryMonths, breakEvenMonths, wealthAtHorizonStay, wealthAtHorizonMove, netWealthGain, yearlyData (fields {year, stay, move}). monthlyNetDelta is an intermediate, not an output.

Assumptions & exclusions (part of the contract)

  • Salaries are used gross; taxes are the user's job to bake into inputs. Break-even uses the net *delta* while the wealth paths use each path's own gross surplus — the two outputs answer different questions.
  • No salary growth, no expense inflation, annual compounding of monthly contributions (contributions earn no intra-year return).
  • A negative move balance still compounds at annualReturn (symmetric growth on negative balances — effectively borrowing at the investment rate). Flagged below.

Known model issues (v1.0.0)

  • Negative-balance compounding (above): overstates the move path's drag when movingCosts > currentSavings. Candidate v1.1 fix: floor at 0 or use a distinct borrowing rate.
  • Break-even sentinel is the number 9999, not Infinity — callers must treat it as "never".

Verification

Vectors: oracle/vectors/relocation.json (seeded fuzz + boundary cases). Tolerance: relative 1e-9 / absolute 1e-6 per numeric field; yearlyData element-wise.

Verification

250 deterministic cases per model: the all-minimum and all-maximum domain corners, 2 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/relocation and re-runnable against your own integration.

Hand-written cases and what each one pins

RationaleInputs (unspecified fields take the all-minimum corner)
No-break-even case (pay cut into higher-cost city){"currentSalary":200000,"newSalary":100000,"currentMonthlyExpenses":3000,"newMonthlyExpenses":8000}
Moving costs exceed savings → negative starting move balance{"currentSavings":5000,"movingCosts":50000,"yearsHorizon":10}

To grade your own implementation against the engine at these tolerances: POST /api/v1/grade/relocation.

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/relocation
{
  "currentSalary": 165000,
  "newSalary": 165000,
  "currentMonthlyExpenses": 8000,
  "newMonthlyExpenses": 8000,
  "movingCosts": 25000,
  "currentSavings": 250000,
  "annualReturn": 0.07,
  "yearsHorizon": 16
}

Response (chart arrays compacted for print)

{
  "annualSalaryDelta": 0,
  "annualExpenseDelta": 0,
  "annualNetDelta": 0,
  "movingCostRecoveryMonths": 9999,
  "breakEvenMonths": 9999,
  "wealthAtHorizonStay": 2662316.6321558263,
  "wealthAtHorizonMove": 2588512.538441691,
  "netWealthGain": -73804.09371413523,
  "yearlyData": [
    {
      "year": 0,
      "stay": 250000,
      "move": 225000
    },
    {
      "year": 1,
      "stay": 336500,
      "move": 309750
    },
    "… 15 more"
  ]
}

Decision record

sha256: 7247d12f1e287249ee84b6aa1488ebce4d2f70a96cc89a1d73015414a54ef8e8

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