side-business — evidence pack

spec v1.0.1 · generated 2026-09-20 · worthune.com/docs/models/side-business

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.

FieldTypeValid domain
sideIncomeMonthlyfloat200 – 20,000
selfEmploymentTaxRatefloat0.1 – 0.2
incomeTaxRatefloat0.1 – 0.37
businessExpensesMonthlyfloat0 – 5,000
currentSavingsfloat0 – 1,000,000
currentMonthlySavingsfloat200 – 10,000
annualReturnfloat0.02 – 0.12
yearsHorizonint5 – 40
currentAgeint18 – 70

Cross-field constraints

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

NameTypeUnitDomain
sideIncomeMonthlynumberUSD/mo gross200–20,000
selfEmploymentTaxRatenumberdecimal0.10–0.20
incomeTaxRatenumberdecimal0.10–0.37
businessExpensesMonthlynumberUSD/mo0–5,000
currentSavingsnumberUSD0–1,000,000
currentMonthlySavingsnumberUSD/mo200–10,000
annualReturnnumberdecimal/yr0.02–0.12 (no UI slider; default 0.07)
yearsHorizonintegeryears5–40
currentAgeintegeryears18–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.

  1. grossSideIncome = sideIncomeMonthly − businessExpensesMonthly (USD/mo; despite the name this is net of expenses — see issues). May be negative.
  2. taxRate = selfEmploymentTaxRate + incomeTaxRate (plain sum of decimals, up to 0.57 in-domain).
  3. netSideIncome = grossSideIncome × (1 − taxRate) (USD/mo; negative when expenses exceed income).
  4. additionalMonthlySavings = max(0, netSideIncome) — 100% of net side income is assumed saved (hardwired).
  5. Closed-form horizon wealth (monthly-annuity convention): wealthAtHorizonWithout = currentSavings × (1 + r)^n + currentMonthlySavings × ((1 + r_m)^(12n) − 1) / r_m; wealthAtHorizonWith is the same with currentMonthlySavings + additionalMonthlySavings as the deposit. additionalWealth = wealthAtHorizonWith − wealthAtHorizonWithout.
  6. FIRE acceleration: set targetWealth = wealthAtHorizonWithout. Then iterate bal = 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 × 12 added 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).
  7. yearlyData, index y = 0..yearsHorizon: both balances start at currentSavings; each iteration records first ({year: currentAge + y, without: withoutBal, with: withBal}) then updates bal = bal × (1 + r) + deposit × 12 (deposits as in step 6; without uses currentMonthlySavings alone). No rounding anywhere in this model; no wall-clock dependence (year is 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 + income sum 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)

  • grossSideIncome is 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, yearsWithSide is biased high and fireAccelerationYears clamps to 0 for small side incomes even though wealth strictly increased. The max(0, …) hides the inconsistency.
  • Loop cap: yearsWithSide stops 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 netSideIncome is 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 currentSalary and retirementAge (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

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

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