← weaver

Rating as Data: Building a Generic Insurance Rating Engine

We made the rating manual the data and the engine a generic interpreter — one resolver, exact decimal, and every premium showing its work.

The problem nobody enjoys

Insurance rating logic almost always ends up buried in code. A carrier files a rating manual — pages of factor tables, limit/deductible grids, risk modifiers, employee-count tiers, state fees — and somewhere downstream a developer translates all of it into if statements and lookup functions. Then the next state shows up. Then the next line of business. Then the carrier refiles. Each one is more special-case code, and every line of that code is a place the rated premium can quietly drift away from the filed manual.

That’s the part that should worry you. In rating, “the number is a little off” isn’t a cosmetic bug — it’s a mispriced policy.

We wanted to stop writing that code. So we drew one hard line and built around it.

The hard line: shapes are data, arithmetic is code

Here’s the whole idea in one sentence: the rating manual is data; the engine is one generic interpreter with a small fixed vocabulary of math.

The manual’s shapes — its grids, its factor lists, its risk tables, its tiers — are imported as self-describing data. The data says what to look up and in what order. The engine only knows how to add, multiply, divide, and round. It has no idea what line of business it’s rating, what a deductible is, or which state it’s in. It just walks data.

That line between data and code is versioned — we call it the engine’s ABI — and it’s fail-closed. Every rating bundle declares the engine surface it needs. An engine that doesn’t satisfy that surface refuses to rate rather than guessing. In financial software, “refuse” is a feature.

The heart: one N-axis resolver

Once shapes are data, something nice happens — every lookup is the same lookup.

A one-dimensional factor list, a two-dimensional limit-by-deductible grid, a string-keyed risk table: they’re all just axes you resolve in a pinned order. For each axis you have:

  • a match mode — exact, ceiling (smallest band ≥ input), floor (largest band ≤ input), range (between bounds), or interpolate (linear between two rows); and
  • an off-the-edge rule — what to do when the input falls outside every band: reject, clamp low, or clamp high.

That second one is load-bearing. A lookup that can silently fall through to “no value” is a silent mis-rate waiting to happen, so the resolver is total by construction — every axis must say what happens at the edge. There is no implicit “undefined.”

One resolver, every shape. Add a new kind of table to a manual and you’re adding data, not a new code path.

Money is not a float

All the arithmetic runs in exact rational decimal with banker’s rounding at explicitly declared scales — never floating point. This isn’t pedantry. Floating point will cheerfully tell you 0.1 + 0.2 == 0.30000000000000004, and scaled up across a premium calculation that becomes a number which fails to reconcile by a cent between two systems. Exact decimal reproduces the same number, to the penny, every time, on any machine.

When the goal is “match the filed manual exactly,” exactly has to mean exactly.

The formula, and a ledger that shows its work

On top of the lookups sits a formula: a sequence of those fixed operations over the resolved values. Two things make it trustworthy:

  1. Branching is governed. Things like an employee-count tier don’t drop into arbitrary code — they use a single conditional selector operation. The formula stays data, so it stays auditable end to end.
  2. Every step emits a ledger line. The output isn’t just a premium; it’s a premium plus the full chain of how it got there, each factor traced back to its source in the manual. “Why this rate?” has an answer you can read.

How we knew it was right

We built it test-first, then put two gates in front of it:

  • Golden-gating — a bundle must reproduce a known-correct premium exactly before it’s allowed to lock. No green, no lock.
  • Content-hashing — on lock, the bundle gets a canonical fingerprint that’s identical across machines and languages. An approved rate is immutable and independently verifiable; it cannot silently drift.

And the import itself was diffed cell-for-cell against the source manual — every table, every matrix, nothing simplified, nothing assumed.

The honest part

Our first cut hit the right number the wrong way. Two values were effectively hardcoded to make one reference quote come out correct — the kind of shortcut that demos beautifully and lies completely. It would have produced the right answer for exactly one input and quietly wrong answers for everything else.

We caught it, named it out loud, and replaced it with the correct general method: the risk modifier is now computed from the actual risk answers, and the large-employer tier is computed via a governed formula step that stays continuous across the boundary. Same final number on the reference quote — but now for the right reason, and correct for every other quote too.

I’m including this on purpose. The interesting engineering isn’t that we hit the number; it’s that we noticed we’d hit it dishonestly and fixed the method, not the output.

The payoff

Feed the engine a manual as data and you get a correct, faithful, auditable rate — with no code change. New state, new carrier, new line of business: that’s an import, not a release. And because each state is a self-contained bundle, adding one can never destabilize a state already in production.

Rating logic, finally, that you can read, prove, and trust — because it’s data all the way down.