A freedom calculator answers three questions — how long the money lasts, what number ends the job, how far away that is — and they are one recurrence asked from two sides. Build it exactly, measure it honestly, and the finding is not that the arithmetic is wrong. It is that the answer is mostly a statement about assumptions, and the ones that move it most are the ones the genre does not show you.
Weekend Builds Vol 4 · #05 · the finale — Vol 4 is now complete at 5 of 5.
👉 Live, runs in your browser: https://dev48v.infy.uk/agentlab/vol4-05-freedom-calculator.html
👉 PUBLIC, MIT, dependencies = []: https://github.com/dev48v/freedom-calculator
python -m freedom_calculator # runway, the FI number, time to FI
python -m freedom_calculator --sensitivity # which input the answer rests on
python -m freedom_calculator --assumptions # the model, and what it costs
python -m freedom_calculator --float # why money is not a float here
Declared inputs, derived signs, measured spans
You declare five numbers. The scenario on the page: 42,000 balance, 3,200 a month out, 5,400 a month in, 5% real return, 4% withdrawal rate. From those: an FI number of 960,000 (25× annual spend) and 231 months to reach it.
Every direction below is derived on paper from the recurrence, then measured by bumping the input and watching the answer. The two are kept apart so they can be checked against each other, and the suite asserts they agree for every input at six bump sizes — plus a non-vacuity test, because a calculator that ignored its inputs entirely would pass a sign check perfectly.
| ±10% on… | months, down / base / up | span |
|---|---|---|
| what you spend a month | 198 / 231 / 269 | 71 |
| what you earn a month | 271 / 231 / 202 | 69 |
| withdrawal rate (usually hidden) | 248 / 231 / 217 | 31 |
| real return you assume | 241 / 231 / 222 | 19 |
| balance you already have | 233 / 231 / 230 | 3 |
The row to sit with is the last one. The genre is arranged around a number worth about a tenth of what the hidden constant is worth.
There is also a row that must be exactly zero: runway does not depend on the withdrawal rate at all. It is a drawdown question. The derived sign is 0 and the measured direction must be 0 too — not small, not usually, exactly, at every bump size on every scenario. That identity is what stops the two halves of the calculator leaking into each other.
The argument people actually have, priced
The 4% rule is a rule about a number under argument — 3% for the cautious, 5% for the cheerful:
| withdrawal rate | FI number | months |
|---|---|---|
| 3% | 1,280,000 | 278 |
| 4% | 960,000 | 231 |
| 5% | 768,000 | 198 |
512,000 of spread — 53.3% of the headline number — and 80 months of your life, from one constant most calculators will not let you type.
Forgetting to subtract inflation is worth 43 months: a nominal 8% instead of a real 5% brings the date from 231 months forward to 188, on which date you actually hold ~717,562 against a target of 960,000.
Money is an exact rational because the output is an integer
Not fastidiousness. The output is a month count that turns on a comparison, and a comparison decided by the last bit of a binary approximation is a month count decided by the last bit of a binary approximation.
Over 120,000 balances that are exactly n months of spend, floor(balance / spend) in doubles answers n−1 in 12,736 of them — 10.61%. Exact rationals: 0. Whole months, not cents.
The zero-net-burn case is worse. Summed as floats, the same basket of line items lands on a net burn of 2.27e-13 instead of zero, and a calculator that divides reports 43,980,465,111,040,000 months with a straight face. This package reaches the unbounded answer with a comparison and no division at all:
// runway and time-to-FI are the SAME recurrence, asked from two sides
// b[m+1] = b[m] * (1 + r) + flow flow = income - spend
// the zero-net-burn identity, decided BEFORE any division ever happens
if (cmp(add(mul(balance, r), flow), ZERO) >= 0) return { status: "unbounded" };
A test walks the syntax tree of runway() and asserts there is no division operator in it, with docstrings stripped first so it cannot pass on the paragraph explaining that the division is not there.
Same returns, reversed, a 17× different answer
The model has no sequence-of-returns risk in it. That is a limitation, and here is what it costs — with no RNG anywhere. One declared list of 30 yearly real returns, mean exactly the same 5% the smooth model uses, applied forward, then reversed. Start at the FI number, draw 4%:
| order | ending balance |
|---|---|
| forward (crash at the front) | ~127,381 |
| reversed | ~2,163,974 |
| the smooth model's answer | ~1,625,807 |
17.0× from order alone. Same numbers, same mean, same multiset, same product — and the control proves the product is identical: withdraw nothing and the two orders end on the identical cent, 4,176,967.52, because multiplication commutes. The entire gap is order, and the smooth model's answer sits between the two giving no hint which one you got.
The sequence was built to put the crash at the front. Stated, not hidden — the size of the crash is not the point. The point is that reversing a list changes the answer at all, which the smooth model says is impossible.
What this is not
Deterministic arithmetic on declared inputs. No RNG, no Monte Carlo, no historical data. Constant real returns, constant burn in today's money, no taxes, no lumpy years, no property, no state pension. The monthly rate is the annual rate over twelve rather than its twelfth root — the usual convention, and slightly generous. Anything past a 100-year horizon is reported as beyond the horizon rather than as a number, because a model that answers "3,847 months" is not being more precise, it is being less honest.
The JavaScript on the page is exact rationals over BigInt, checked against the Python's reference.json numerator for denominator, and against a third implementation in the verifier that simulates month by month instead of using the closed form.
50 pytest · 239 verifier asserts · 44 in-page self-checks · 0 failures. MIT, Python standard library only.