every model spec’d & versioned · Concordance-tested changelog →

Engineering · 2026-08-23

We Shipped the Same Units Bug Twice

Two models overstated a headline number by roughly 12×. Two independent implementations agreed on both wrong answers, and the test harness stayed green. What that says about what verification actually proves — and the gate we added once we understood the class.

In August 2026 we audited our own calculators and found that the divorce model overstated its headline retirement gap by roughly 12×. We fixed it, shipped a version bump, and wrote it into the public changelog. The next day, auditing the next batch of models, we found the same bug again in long-term care.

Both were live. Both had passed a verification harness that we consider unusually strict — two independent implementations, written in different languages by different authors, agreeing on 250 deterministic cases per model at one part in a billion. Both times the harness was green. It was green because both implementations were wrong in exactly the same way, and that is the part worth writing down.

The two bugs

The divorce model projects two futures — with the divorce and without it — and reports the gap between them. The no-divorce baseline took an annual savings amount and fed it into a monthly annuity factor. A year's worth of saving was deposited every month. projectedRetirementWithoutDivorce came out roughly twelve times too large on its contributions term, and retirementGap, the number the interface leads with, inherited the error.

The long-term-care model reports a break-even age: how old you would be when the premiums you have paid equal the care costs you have avoided. It divided total premiums by a monthly cost, which produces a number of months, and then added that number to an age as though it were years. The offset was twelve times larger than it should have been.

One is a rate mismatch, one is an interval mismatch, and both are the same mistake underneath: a quantity measured per month met a quantity measured per year, and nothing in between them objected. Twelve is not a coincidence in either case. It is the exchange rate between the two units nobody converted.

ModelFixed inWhat went wrongWhat it distorted
divorcev1.1.0, 2026-08-13Annual savings amount fed into a monthly annuity factorprojectedRetirementWithoutDivorce, and retirementGap through it
long-term-carev1.1.0, 2026-08-14Premiums ÷ monthly cost gives months, added to an age as yearsbreakEvenAge — the offset 12× too large

Why two implementations agreed on a wrong answer

Our verification method is Concordance testing: every model is implemented twice, once in the TypeScript engine that serves production and once in a Python oracle written from the published specification alone, by an author barred from reading the engine's code. The two must agree on 250 cases per model before anything ships. When they disagree, the release stops.

That method has a boundary, and these two bugs sat exactly on it. Our specifications were written as-implemented — reverse-engineered from the production TypeScript, documenting the model including its quirks, with a Known model issues section for the ones we had spotted. So the divorce specification said, in effect, deposit the annual amount monthly. The Python author read that, implemented it faithfully, and produced the same wrong number. Two independent witnesses agreed, because they had been told the same wrong thing.

Concordance testing proves the engine matches its specification. It cannot prove the specification matches reality. Those are different claims, and only one of them is mechanical.

This is not a flaw we patched out of the method; it is the shape of the method. Any differential-testing scheme compares two artifacts against each other, and a shared upstream error is invisible to it by construction. The same is true of property-based testing against properties you chose, and of golden-file testing against goldens you generated. Every automated check answers the question you encoded. None of them answers a question you did not think to ask.

What did find them

A person reading each specification against its arithmetic, model by model, and asking what every quantity was measured in. That audit is written up in the Phase 0 accuracy report, which lists both findings alongside the others it produced — a negative break-even when net operating income was zero, a NaN at a 0% mortgage rate, five inputs that were accepted by the API and then ignored. Manual review found things the harness structurally could not.

The fix, and what it does not do

Both models were repaired the way every behavior change ships here: specification first, then code, then a fresh harness run, then a changelog entry naming the error in plain language. You can read both entries; they say what they say. We publish our own bugs because a changelog that only contains features is not evidence of anything.

The structural change was smaller and duller than the fixes. Every model specification now declares the unit of every input in its own column of the input table, so USD/mo sits next to USD/yr in the reader's eye at the exact moment the mistake gets made. Five specifications additionally carry a unit note in prose where the conventions are unusually easy to confuse — long-term care, for instance, takes a daily insurance benefit and a monthly premium and an annual care cost, all in the same model.

| Name                       | Type    | Unit                     | Domain          |
|----------------------------|---------|--------------------------|-----------------|
| careStartAge               | integer | years                    | 65–95           |
| annualCareCost             | number  | USD/yr (today's dollars) | 30,000–200,000  |
| ltcInsurancePremiumMonthly | number  | USD/mo                   | 0–1,000         |
| careInflationRate          | number  | decimal/yr               | 0.02–0.08       |

A convention nobody enforces is a convention that decays, so this one is a test. It reads all 55 specifications, requires a Unit column, requires every numeric input to declare something in it, and rejects an em dash where a unit belongs. It does not demand a unit for a boolean or an enum, because for those a dash is the honest entry and requiring a word would be churn with no safety in it.

Being precise about what the gate does

It does not verify that a declared unit is correct, and it does not check that the arithmetic respects it. Neither of those is mechanizable here. It would not have caught either bug in this post. All it guarantees is that the information a reviewer needs is present and cannot quietly go missing on the fifty-sixth model. That is a small claim, and it is the true one.

What we would tell another team

Know which question your verification answers. Ours answers whether the engine matches its specification, and it answers that very well — well enough that we publish the cases and invite you to run them. It says nothing about whether the specification is right. Teams get into trouble when a strong guarantee about one question is quietly enjoyed as a guarantee about a different one.

Write the specification before the code where you can. Ours were reverse-engineered from a working product, which is often the honest starting position, and it means the first version of a specification inherits whatever the code was already doing wrong. Every as-implemented specification should be read once more, on purpose, by someone asking what each quantity means rather than what it does.

Treat the second occurrence differently from the first. The divorce bug was an incident. The long-term-care bug, one day later, made it a class — and a class deserves a structural answer rather than a second fix. The question after any bug worth writing up is not only what was wrong here, but what would have made this visible, and where else the same blindness is operating.

And publish it. A vendor's claim to be careful is worth roughly nothing; a vendor's list of the mistakes it made, what they cost, and what changed afterwards is worth reading. We would rather you evaluate us on the second thing, because it is the one we can hand over.

Sources

  1. Phase 0 accuracy report — the audit that found both bugs, its method, and both findings queues.
  2. divorce model specification, changelog v1.1.0 (2026-08-13) — the annual-into-monthly-annuity fix.
  3. long-term-care model specification, changelog v1.1.0 (2026-08-14) — the months-added-as-years fix, plus the unit note and the input table shown above.
  4. The Concordance harness that was green through both bugs: .github/workflows/oracle.yml, running the exported vectors against the independent Python oracle on every change.
  5. The units gate described above: src/lib/model-api/spec-units.test.ts, run in CI over every specification in the catalog.
  6. The public changelog, where both entries live alongside every other behavior change.