side-business — 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/side-business.
| Field | Type | Valid domain |
|---|---|---|
sideIncomeMonthly | float | 200 – 20,000 |
selfEmploymentTaxRate | float | 0.1 – 0.2 |
incomeTaxRate | float | 0.1 – 0.37 |
businessExpensesMonthly | float | 0 – 5,000 |
currentSavings | float | 0 – 1,000,000 |
currentMonthlySavings | float | 200 – 10,000 |
annualReturn | float | 0.02 – 0.12 |
yearsHorizon | int | 5 – 40 |
currentAge | int | 18 – 70 |
Cross-field constraints
- annualReturn must stay > 0 (NaN at 0)
Full specification
Reproduced verbatim from the versioned spec (docs/model-specs/side-business.md, served at /api/v1/models/side-business/spec). This is
the authoritative contract: inputs with units, the exact computation,
assumptions, exclusions, known issues, and change history.
Model: side-business — v1.0.1 (as-implemented)
Engine: calcSideBusiness(inputs: SideBusinessInputs) in src/lib/engine.ts. After-tax value of side/freelance income, the extra wealth it builds over a horizon, and a rough FIRE-acceleration estimate.
> Unit warning: all rates are annual decimals (0.153 = 15.3% SE > tax). Income and expense inputs are monthly USD.
Inputs
| Name | Type | Unit | Domain |
|---|---|---|---|
| sideIncomeMonthly | number | USD/mo gross | 200–20,000 |
| selfEmploymentTaxRate | number | decimal | 0.10–0.20 |
| incomeTaxRate | number | decimal | 0.10–0.37 |
| businessExpensesMonthly | number | USD/mo | 0–5,000 |
| currentSavings | number | USD | 0–1,000,000 |
| currentMonthlySavings | number | USD/mo | 200–10,000 |
| annualReturn | number | decimal/yr | 0.02–0.12 (no UI slider; default 0.07) |
| yearsHorizon | integer | years | 5–40 |
| currentAge | integer | years | 18–70 (no UI slider; default 35) |
> Removed in v1.0.1 (breaking): currentSalary and retirementAge > no longer exist. Both were dead inputs (currentSalary was never > even destructured; retirementAge was destructured but never used), > and neither had a UI slider. Callers must stop sending them — the API > rejects unknown fields.
Keep annualReturn > 0 in vectors: annualReturn = 0 makes monthlyReturn = 0 and the annuity factor divides 0/0 → NaN.
Computation
Let r = annualReturn, r_m = r/12, n = yearsHorizon.
grossSideIncome = sideIncomeMonthly − businessExpensesMonthly(USD/mo; despite the name this is net of expenses — see issues). May be negative.taxRate = selfEmploymentTaxRate + incomeTaxRate(plain sum of decimals, up to 0.57 in-domain).netSideIncome = grossSideIncome × (1 − taxRate)(USD/mo; negative when expenses exceed income).additionalMonthlySavings = max(0, netSideIncome)— 100% of net side income is assumed saved (hardwired).- Closed-form horizon wealth (monthly-annuity convention):
wealthAtHorizonWithout = currentSavings × (1 + r)^n + currentMonthlySavings × ((1 + r_m)^(12n) − 1) / r_m;wealthAtHorizonWithis the same withcurrentMonthlySavings + additionalMonthlySavingsas the deposit.additionalWealth = wealthAtHorizonWith − wealthAtHorizonWithout. - FIRE acceleration: set
targetWealth = wealthAtHorizonWithout. Then iteratebal = currentSavings,yearsWithSide = 0;while (bal < targetWealth && yearsWithSide < 100) { bal = bal × (1 + r) + (currentMonthlySavings + additionalMonthlySavings) × 12; yearsWithSide++ }. Note this loop uses annual compounding of the yearly deposit (monthly × 12added after growth, no intra-year return) while the target came from the monthly-annuity closed form — deliberately mismatched conventions; see issues.fireAccelerationYears = max(0, yearsHorizon − yearsWithSide). yearlyData, index y = 0..yearsHorizon: both balances start atcurrentSavings; each iteration records first ({year: currentAge + y, without: withoutBal, with: withBal}) then updatesbal = bal × (1 + r) + deposit × 12(deposits as in step 6;withoutusescurrentMonthlySavingsalone). No rounding anywhere in this model; no wall-clock dependence (yearis age-based).
Output keys
grossSideIncome, netSideIncome, additionalMonthlySavings, fireAccelerationYears, wealthAtHorizonWithout, wealthAtHorizonWith, additionalWealth, yearlyData (fields {year, without, with} — note with is a reserved-looking but legal JSON/JS key). taxRate, monthlyReturn, and targetWealth are intermediates, not outputs.
Assumptions & exclusions (part of the contract)
- SE tax is applied to the full net-of-expenses amount: no 92.35% SE-base factor, no employer-half deduction, no QBI — the flat
SE + incomesum is the contract. - The entire after-tax side income is invested (0% lifestyle inflation).
- Headline wealth outputs use monthly annuity compounding; the chart and the FIRE loop use annual deposit compounding. The chart's final point is therefore below
wealthAtHorizonWith(out)for the same inputs.
Known model issues (v1.0.1)
grossSideIncomeis misnamed — it is income *minus expenses* (i.e. pre-tax net), not gross.- Mixed compounding conventions (step 6): because the annual-deposit loop grows slower than the monthly-annuity target,
yearsWithSideis biased high andfireAccelerationYearsclamps to 0 for small side incomes even though wealth strictly increased. Themax(0, …)hides the inconsistency. - Loop cap:
yearsWithSidestops at 100; with the clamp the output is then 0 (there is no explicit "never" sentinel). annualReturn = 0→ NaN in the annuity factors (out-of-domain).- Negative
netSideIncomeis reported as-is but contributes nothing to savings (floored at step 4) — the model never charges the loss.
Changelog
- 1.0.1 (2026-08-13, product-approved): BREAKING — removed dead inputs
currentSalaryandretirementAge(accepted, never used; no UI sliders). No numeric output changes. - 1.0.0: initial as-implemented spec.
Verification
Vectors: oracle/vectors/side-business.json (seeded fuzz + boundary cases). Tolerance: relative 1e-9 / absolute 1e-6 per numeric field; yearlyData element-wise.
Suggested curated cases:
// expenses exceed income: negative gross/net, additionalMonthlySavings = 0,
// fireAccelerationYears = 0 (loop still runs against the closed-form target)
{ "sideIncomeMonthly": 200, "selfEmploymentTaxRate": 0.153,
"incomeTaxRate": 0.22, "businessExpensesMonthly": 5000, "currentSavings": 50000,
"currentMonthlySavings": 1500, "annualReturn": 0.07, "yearsHorizon": 20,
"currentAge": 35 }
// max combined tax (0.57) at max side income, long horizon — compounding-convention gap largest
{ "sideIncomeMonthly": 20000, "selfEmploymentTaxRate": 0.20,
"incomeTaxRate": 0.37, "businessExpensesMonthly": 0, "currentSavings": 0,
"currentMonthlySavings": 200, "annualReturn": 0.12, "yearsHorizon": 40,
"currentAge": 18 }
// exact break-even: income = expenses → grossSideIncome and netSideIncome
// exactly 0, additionalMonthlySavings 0, both paths identical
{ "sideIncomeMonthly": 1000, "selfEmploymentTaxRate": 0.153,
"incomeTaxRate": 0.22, "businessExpensesMonthly": 1000, "currentSavings": 50000,
"currentMonthlySavings": 1500, "annualReturn": 0.07, "yearsHorizon": 20,
"currentAge": 35 }Verification
250 deterministic cases per model: the all-minimum and
all-maximum domain corners, 4 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/side-business and re-runnable
against your own integration.
Hand-written cases and what each one pins
| Rationale | Inputs (unspecified fields take the all-minimum corner) |
|---|---|
| Expenses exceed income: negative gross/net, additionalMonthlySavings = 0 | {"sideIncomeMonthly":200,"selfEmploymentTaxRate":0.153,"incomeTaxRate":0.22,"businessExpensesMonthly":5000,"currentSavings":50000,"currentMonthlySavings":1500,"annualReturn":0.07,"yearsHorizon":20,"currentAge":35} |
| Max combined tax (0.57) at max side income, long horizon | {"sideIncomeMonthly":20000,"selfEmploymentTaxRate":0.2,"incomeTaxRate":0.37,"businessExpensesMonthly":0,"currentSavings":0,"currentMonthlySavings":200,"annualReturn":0.12,"yearsHorizon":40,"currentAge":18} |
| High-base short-horizon case (large savings, min return) | {"sideIncomeMonthly":2000,"selfEmploymentTaxRate":0.1,"incomeTaxRate":0.1,"businessExpensesMonthly":300,"currentSavings":1000000,"currentMonthlySavings":10000,"annualReturn":0.02,"yearsHorizon":5,"currentAge":70} |
| Exact break-even: income = expenses → gross/net side income exactly 0 | {"sideIncomeMonthly":1000,"selfEmploymentTaxRate":0.153,"incomeTaxRate":0.22,"businessExpensesMonthly":1000,"currentSavings":50000,"currentMonthlySavings":1500,"annualReturn":0.07,"yearsHorizon":20,"currentAge":35} |
To grade your own implementation against the engine at these
tolerances: POST /api/v1/grade/side-business.
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.1. Entries come straight from the spec's own changelog section; the full-catalog view is at worthune.com/models/changelog.
- 1.0.1 (2026-08-13, product-approved): BREAKING — removed dead
- 1.0.0: initial as-implemented spec.
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/side-business
{
"sideIncomeMonthly": 10000,
"selfEmploymentTaxRate": 0.15,
"incomeTaxRate": 0.24,
"businessExpensesMonthly": 2500,
"currentSavings": 500000,
"currentMonthlySavings": 5100,
"annualReturn": 0.07,
"yearsHorizon": 23,
"currentAge": 44
}Response (chart arrays compacted for print)
{
"grossSideIncome": 7500,
"netSideIncome": 4575,
"additionalMonthlySavings": 4575,
"fireAccelerationYears": 4,
"wealthAtHorizonWithout": 5849453.866410045,
"wealthAtHorizonWith": 8970490.999207105,
"additionalWealth": 3121037.1327970605,
"yearlyData": [
{
"year": 44,
"without": 500000,
"with": 500000
},
{
"year": 45,
"without": 596200,
"with": 651100
},
"… 22 more"
]
}Decision record
sha256: 68e35f00bf65fd3329aefe38f922822e2d67c179f6a45e0bb91c8b9c8ffb7779
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.)