relocation — 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/relocation.
| Field | Type | Valid domain |
|---|---|---|
currentSalary | float | 30,000 – 300,000 |
newSalary | float | 30,000 – 300,000 |
currentMonthlyExpenses | float | 1,000 – 15,000 |
newMonthlyExpenses | float | 1,000 – 15,000 |
movingCosts | float | 0 – 50,000 |
currentSavings | float | 0 – 500,000 |
annualReturn | float | 0.02 – 0.12 |
yearsHorizon | int | 1 – 30 |
Sentinel values
breakEvenMonths=9999→ never breaks even (monthlyNetDelta ≤ 0)
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
| Name | Type | Unit | Domain |
|---|---|---|---|
| currentSalary | number | USD/yr (gross) | 30,000–300,000 |
| newSalary | number | USD/yr (gross) | 30,000–300,000 |
| currentMonthlyExpenses | number | USD/mo | 1,000–15,000 |
| newMonthlyExpenses | number | USD/mo | 1,000–15,000 |
| movingCosts | number | USD one-time | 0–50,000 |
| currentSavings | number | USD | 0–500,000 |
| annualReturn | number | decimal/yr | 0.02–0.12 |
| yearsHorizon | integer | years | 1–30 |
Computation
annualSalaryDelta = newSalary − currentSalaryannualExpenseDelta = (newMonthlyExpenses − currentMonthlyExpenses) × 12annualNetDelta = annualSalaryDelta − annualExpenseDeltamonthlyNetDelta = annualNetDelta / 12breakEvenMonths = ceil(movingCosts / monthlyNetDelta)ifmonthlyNetDelta > 0, else the sentinel 9999 ("never").movingCostRecoveryMonthsis the same value.- Path savings (gross, per month, floored at 0):
stayMonthlySavings = max(0, currentSalary/12 − currentMonthlyExpenses);moveMonthlySavings = max(0, newSalary/12 − newMonthlyExpenses). - Wealth paths, annual steps, index y = 0..yearsHorizon. Starting balances: stay
currentSavings; movecurrentSavings − 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 asbal = bal × (1 + annualReturn) + monthlySavings × 12. wealthAtHorizonStay/Move = yearlyData[yearsHorizon]values;netWealthGain = move − stayat 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
| Rationale | Inputs (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.)