How do you port a financial model and know you didn't break it?
A retirement planner that is wrong does not crash. It returns a number, the number looks reasonable, and somebody makes a decision with it.
When I ported a Python retirement model I had been using for years into a native Swift engine, the hard part was not the translation; it was proving the translation was faithful, and keeping it faithful across two years of feature work. This post is about the engine rather than a household.
Three pieces of correctness infrastructure carried the port: an exact-match oracle, a decoupling discipline, and a frozen seed schedule. None is novel individually; together they let the engine add couples modeling, stochastic mortality, a multi-asset market model, healthcare, ACA subsidies, and annuity pricing while still reproducing the original exactly.
How do you validate a port when the randomness will not match?
You find the case where the randomness is switched off.
A naive validation runs both versions and compares distributions, which catches gross errors and misses everything subtle: two different pseudorandom generators produce different draws, leaving you to argue whether a 0.4% difference in the median is a bug or sampling noise. NumPy uses PCG64, Swift's generator does not, and bit-exact reproduction of the draws was never on the table.
The oracle is a scenario with volatility set to zero on every account. Growth becomes deterministic, every trial produces an identical path, and the generator drops out of the computation entirely, leaving the machinery worth verifying: the withdrawal waterfall, the tax stack, annuitization, debt amortization, required minimum distributions, and the year over year accounting.
The test rebuilds that scenario's inputs from the original fixture, runs the Swift engine pinned to the fixture's start year, and asserts the entire projection matches the reference CSV over a 76 year horizon. Tolerance is two cents absolute, or one part per million on large balances, whichever is larger; column order is asserted too.
How do you add features without breaking the oracle?
Make every new feature inert by default, and make "inert" mean byte-identical rather than approximately equal.
The temptation is to weave a new feature into the projection loop and update the golden fixtures to match. Do that once and the oracle becomes a regression test against your own most recent output, a much weaker thing.
Instead, every engine feature since the original port is gated behind an optional input that defaults to nil or zero, and the contract is that with the feature off, the arithmetic collapses to exactly the prior code path, not approximately. Some concrete cases:
- Couples. A nil
couplesinput produces a projection byte-identical to the pre-couples engine. - Stochastic mortality. Nil uses the deterministic plan-to age and never draws from the mortality generator, so it cannot perturb the growth stream.
- Social Security taxation. With no Social Security tagged streams, the provisional income worksheet contributes nothing and every tax line collapses to the prior arithmetic.
- The multi-asset market model. Nil falls back to the legacy per account return and volatility, the path the oracle exercises.
- ACA subsidies, annuities, life events. Empty lists compile to no operations.
The payoff: the oracle has asserted the same numbers from the same fixture for the life of the project, so any commit that breaks the original mechanics fails immediately, even inside a feature that looks unrelated.
The cost: the engine carries permanent code paths only the oracle exercises, because the app layer moved on to the market model and never passes nil in production, a low grade tax on every signature and refactor. Some of the guarantees require care a looser tolerance would allow skipping: for fixed inflation the original pow() formulation must be kept rather than "simplified" to index ratios, because floating point disagrees about whether those are the same.
Decoupling also only helps if a stale cached result is detectable. Results are stored with a fingerprint of the inputs that produced them and shown only while the fingerprint matches; constants outside the user's data, such as an annuity pricing rate, fold in too, otherwise a change to a shipped assumption would leave saved projections wrong and still marked valid.
Why does parallelizing a Monte Carlo break reproducibility?
Because the obvious implementation makes each trial's draws depend on execution order, and execution order depends on how many cores happen to be free.
Draw from one shared generator across concurrent trials and results vary between devices and between runs. A user who reruns and sees 71% instead of 72% has no way to tell whether they changed something.
The fix removes execution order from the computation. Each trial derives its own seed for each of the three independent generator streams (growth shocks, mortality draws, market returns) as a pure function of a base seed and the trial index; the mixer is a SplitMix64 finalizer over base + (trial + 1) * γ, the job SplitMix64 was designed for.
Trials run in fixed chunks and merge in index order, so the merge is not order sensitive either. The tests pin the schedule itself, not just the outputs.
Three separate streams are the other half: mortality draws must not perturb the growth sequence, or mortality off would stop being byte-identical the moment a single random number was consumed. Stream independence is what makes the decoupling contract enforceable.
What none of this gets you
An engine can be internally perfect and still be wrong about the world. Nothing above should be read as implying otherwise.
Reproducing a reference implementation to the cent proves the arithmetic is faithful. It says nothing about whether the capital market assumptions are reasonable, whether a period mortality table with a flat 1% improvement scale is a defensible stand in for a full actuarial scale (a documented simplification), or whether next year's tax law will resemble this year's. Those are judgment calls, and the useful response is to write them down: the value, the source, the verification date, and what would make it wrong. The project carries a source registry for every sourced constant, plus methodology memos for the market assumptions, mortality basis, healthcare model, and annuity pricing.
The thing I would tell past me
Build the oracle before the second feature, not after the tenth.
The discipline is cheap as a habit and expensive to retrofit, because retrofitting means reconstructing the original behavior from a codebase that has already drifted. If correctness is not self announcing, and financial, actuarial, and scientific code all qualify, the first thing worth building is the test that tells you the port is still a port.
The engine described here ships in the app.
It runs entirely on the phone; financial data never leaves the device.
Get Seraph: Retirement PlannerReferences
- NumPy, PCG64 bit generator, the generator used by the reference Python implementation.
- Sebastiano Vigna, SplitMix64 reference implementation. See also Steele, Lea and Flood, "Fast splittable pseudorandom number generators" (OOPSLA 2014), the paper the mixer comes from.
- Social Security Administration, period life table, the mortality basis referenced above.
- Implementation details described here are from the Seraph: Retirement Planner engine as of v1.45.1 (July 2026).