APR 3361
The number that started the whole containment project — and what it looks like today when the same mistake hits the facade.
Last month a local model read a credit-card statement and returned this:
{ "apr_percent": 3361 }
Grammar-valid. Type-correct. Thirty-three hundred percent APR. It's the single most dangerous class of model output there is — a plausible-looking number — because a tired human reviewing a page of extracted fields will skim right past it. I know, because I did.
It came out of PayDown's statement import; the build-log entry from that day is The 3,361% APR. That one value is why AIFacade exists. This post is the short version, with the receipts, because I recorded a demo against the live instance today and every number below is copied from the responses.
The fixture passes
The founding case lives in the repo as fixtures/02-apr-statement.json: the statement excerpt, a strict schema (apr_percent is a number in [0, 100]), the expected value 33.61, and a tolerance of half a basis point. Run through the facade on a 7B Qwen model:
"validation": { "status": "passed" },
"output": { "apr_percent": 33.61 },
"route": "local", "latency_ms": 2519, "cost_cents": 0
Two and a half seconds, zero cents, correct.
The replay gets caught — and healed
Now the original failure. The OCR pass dropped the decimal, so the statement text literally says APR: 3361, and the model faithfully reports it. The schema's range bound rejects the value. Version one of the facade stopped there: HTTP 422, no output field at all, so nothing downstream could consume it.
But this failure class is known. A percentage that is exactly 100× too large is a dropped decimal point or a basis-points confusion. The fix is arithmetic, not judgment. So today the same replay returns:
"validation": {
"status": "normalized",
"repairs": [
{ "rule": "scale-down-100", "field": "apr_percent", "from": 3361, "to": 33.61 }
]
},
"output": { "apr_percent": 33.61 }
The normalization registry runs under four hard rules:
- A rule fires only on a value that already violates its schema bounds. Valid output is untouchable.
- A repair must land the value in full schema validity, or it doesn't happen.
- If two rules propose different fixes, nothing is normalized. Ambiguity is rejection, never a guess.
- Every applied repair is recorded — rule, field, before, after — in the response and in the audit log.
And one more, enforced by a unit test: every rule must cite the fixture that discovered it.
{ "name": "scale-down-100",
"description": "A percentage read with its decimal point dropped (or given in basis points): value is exactly 100× too large for its bounds. Divides by 100.",
"fixture": "fixtures/02-apr-statement.json" }
A second model checks — code decides
Add routing_hints.validate and the same schema-constrained request runs against the other model family. Deterministic code diffs the two parsed outputs field by field:
"cross_check": { "outcome": "agree", "validator_model": "llama3.1:8b",
"validator_route": "local", "validator_latency_ms": 5435 }
When they disagree you get the field-level diff and a flag — and that's all. No third model votes. Ground truth lives in fixtures, and fixtures only grow.
The ledger, today
Every completion — chat, background tasks, the scribe, the eval harness, the validators themselves — lands in one routing ledger. As of this morning:
| requests | served locally | cloud-equivalent cost avoided | normalized | rejected | cross-checks |
|---|---|---|---|---|---|
| 1,885 | 100% | $8.11 | 2 | 1 | 4 agree · 2 disagree · 1 validator failed |
The two normalizations and the one rejection are in there with their reasons. The validator_failed is honest too: the cloud branch is wired (official Go SDK, structured outputs), but the deployed box doesn't have an API key set yet, so the decision records why it fell back to local instead of pretending.
That's the whole idea. The question was never whether I read what the model wrote. The question is what happens when it's wrong — and the answer has to be structure, not vigilance.
Demo script and runner: docs/demo/ in the AIFacade repo. — A.C.