The thing I decided early, and have never seriously revisited, is that this app is allowed to be unhelpful and is not allowed to be confidently wrong about a dollar figure. That sounds like a platitude until you make it a check that can fail a build. Then it stops being a value and starts being a constraint that costs you points on every scorecard you publish. It is a document app for personal finance, running entirely on device, over a person's own tax forms and statements and policies. The questions it gets asked are things like how much interest did I earn, what is my deductible, what did I pay in property tax. A wrong answer to any of those is not an inconvenience, it is a number somebody might carry into a tax return or a phone call with an insurer, and the person asking has, by construction, no easy way to check it, because if the document were easy to read they would not have asked.
So the bar is not accuracy. Accuracy is a thing I would like more of and negotiate over constantly. The bar is that when the engine does not know, it says so. The failure mode of the whole system should be silence rather than fabrication.
What it actually looks like as a check
The pleasant discovery is that this is straightforwardly measurable (about forty lines of harness code, no model in the loop). The harness first decides which questions are money questions, by looking for a dollar-shaped token in any of the machine-checkable needles or in the human-written expected answer:
/// A question whose answer key pins a MONEY figure — any must-contain needle OR the human-authored
/// `expected` text carrying a dollar-shaped token. These are the questions where a wrong answer can
/// be silently expensive — the zero-wrong-money bar applies to them.
static func isMoneyQuestion(_ q: Question) -> Bool {
let needles = (q.mustContainAll ?? []) + (q.mustContainAny ?? []) + [q.expected]
return needles.contains { $0.range(of: #"\$?\d[\d,]*\.\d{2}"#, options: .regularExpression) != nil }
}That expected term is there because of an audit finding rather than foresight. A money question authored with a figure only in its prose expectation, and not also encoded in a must-contain needle, was silently escaping the safety bar entirely. That is a very quiet way for a guarantee to stop being a guarantee. We found it in an audit, not in production.
The violation check itself is the interesting one, because of what it does not count:
/// The zero-wrong-money check over a report's runs: for every MONEY question, a model run
/// violates iff it was graded WRONG, did NOT decline honestly, and its answer still contains
/// a dollar-shaped figure — i.e. it confidently stated money that failed the answer key.
/// (A wrong answer with no figure, or an honest "couldn't find it", costs correctness only.)
static func moneyViolations(runs: [QuestionRun]) -> [String] {
var violations: [String] = []
for qr in runs where isMoneyQuestion(qr.question) {
for r in qr.runs {
let g = grade(qr.question, answer: r.answer, citedTitles: r.citedTitles)
guard g.grade == .wrong, !looksLikeNotFound(r.answer),
r.answer.range(of: #"\$\s?\d|[\d,]+\.\d{2}"#, options: .regularExpression) != nil
else { continue }
violations.append("\(qr.question.id) · \(r.model)")
}
}
return violations
}Three conditions have to hold together for something to count as a violation: the answer was graded wrong, it did not read as an honest decline, and it nonetheless contains a dollar-shaped figure. The middle condition is looksLikeNotFound used as a negated guard, and it is the load-bearing piece of the whole design. It is what separates "I could not find that in your documents" from "your deductible is one thousand dollars" when neither one matches the key. The first costs a correctness point and nothing else. The second is the failure the entire product is organized to avoid.
That negation also has a structural property I did not appreciate until I had to reason about a change to it. Since the decline heuristic appears only as !looksLikeNotFound, widening it – teaching the grader to recognize more phrasings as honest declines – can only ever remove money violations, never add one. The check is monotone in the direction of the change, so the safety metric cannot silently regress when someone improves the honesty heuristic. That is a property worth having in a metric you intend to trust for a long time.
The record, including the parts that fail
The record I published in July said that five corpora passed the money bar and that banking was the one exception. When I went to re-measure all of it on the current build against the current library, that record did not reproduce. I would rather lay out what came back than tell the cleaner version, partly because the cleaner version is the one I had already written down.
| corpus (pre-fix snapshot, August) | money questions | zero-wrong-money |
|---|---|---|
| banking | 13 | PASS |
| synthetic-irs | 18 | PASS |
| openrag (external benchmark) | 4 | PASS |
| tax | 13 | FAIL (two) |
| insurance | 12 | FAIL (one) |
| utilities | 8 | FAIL (one) |
That table is the state of things at the moment of discovery and I am leaving it standing as history, because the version of the record I want a reader to trust is the one further down, measured after the fixes and against a harder grader.
Banking, the corpus that carried the failure for two months, now passes, and the three that had passed all along now do not. That is close to an exact inversion of what I expected to find, and it is the reason this section is longer than it used to be.
The banking fix is the easy half. The violation there was a question about the new balance on an American Express statement with a January 7th closing date, and the engine retrieved the right statement, ranked it first by a wide margin, and then read $399.40 off a near-identical sibling from the following month sitting in the same eight-document window. Boosting the right document had never been the problem, because the boost had already worked. What was missing was removing the wrong-dated siblings from the context entirely, so that a four-billion-parameter model reading six nearly identical AmEx statements is not asked to pick. Once the filter shipped, the answer came back $487.20, correctly cited, and banking went green for the first time since I built the corpus.
The other three are harder, and the first thing I had to accept is that the July numbers were measured against a much smaller library. The banking collection has grown from roughly four hundred documents to nearly fifteen hundred, the insurance corpus now assembles from about sixteen hundred files where it once drew on a few hundred, and every answer key and every negative question in those corpora was authored against the smaller version. So the honest first move was not to explain the four new violations. It was to open every source PDF behind them and find out whether I was looking at four engine failures or four stale keys.
All four keys are correct. I checked each one against the original document rather than against the text the engine extracted from it (which turns out to be the distinction the whole exercise rests on), and not one of the four answers I had written down was wrong. That was not the result I wanted, because a wrong key is a bookkeeping problem and a wrong answer is a product problem.
Two of the four are the same defect wearing different clothes, and it is a defect in reading rather than in retrieval or reasoning. The on-device text recognizer, handed a two-column billing table, emits all of the labels and then all of the values, so every token survives and the pairing a human reader depends on is destroyed. On an ADT security bill from February 2012 the extracted text runs PREVIOUS BALANCE, PAYMENT RECEIVED, four line items, ENDING BALANCE, and then 241.99, 241.99 CR, 7.00, 26.99, 4.00, 5.00, 42.99. The last label sits flush against the first number. The model told me the amount due was $241.99 and quoted "ENDING BALANCE 241.99" as its evidence – a phrase that appears nowhere in the bill and nowhere in the extraction. The real amount due is $42.99, and $241.99 is the previous balance, which the same bill records as already paid.
A Brighthouse universal life statement fails the same way with a different shape. The flattening strands the "Cash Value:" label ten lines from any number at all, and the model, finding no figure attached to the words it was asked about, fell through to the only line in the document that does pair them – a protection-continuation projection reading BASED ON CASH VALUE, 03/16/2077, 09/16/2041, 0.00, 0.00 – and reported the cash value as zero on a policy actually worth $45,783.79. Both of those are the worst error a reader could make from those two documents (a paid balance reported as money owed, a live policy reported as worthless), and both look perfectly well grounded, with the right document retrieved and correctly cited, which is what makes them dangerous.
I had previously written the ADT one off as a harness artifact. The theory was that the document was missing its structured sidecar in the snapshot, so the deterministic "amount due" anchor was simply absent. I went back and checked that claim against the store the failing run actually used, because the point of this exercise was to stop trusting things I had not personally verified, and the sidecar is there. It contains exactly one money field, and that field reads USD 42.99. The correct answer was sitting in structured storage, unambiguous, with nothing to choose it against, and the engine answered $241.99 regardless – which is a worse finding than the one I retracted, and I would rather print it than keep the tidier explanation I had already published.
The tax pair is different again, and it is the one I find genuinely uncomfortable. Both violations come out of the deterministic form-field extractor rather than the model reading passages, and that is the tier I have been describing all along as the reason wrong money has not happened where it is engaged. Asked for Box 2 federal withholding on the 2025 W-2, it answered $315,259.24 and cited a document whose filename begins with 04132025 and whose contents are the 2024 W-2 – an accurate figure, quoted correctly, from the wrong tax year, and one the new date filter cannot catch, because that filename parses to April 2025, the date the envelope was mailed.
The second one is worse, and it is the reason this post exists in its current form. Asked for Box 1 wages on the same form, with the right document retrieved and sitting in the window, it answered $1,317,128.02 and labelled that figure "model-extracted" inside a block of copy that tells the user it is reading an exact extracted value rather than a generated one. The 2025 form says $1,552,945.61. The 2024 form it was actually reading says $1,148,528.02. I searched the full extracted text of every document in the library for $1,317,128.02 and it is not there, in any form, on any page. That is a fabricated dollar figure presented under a label that promises determinism, and it is the single worst thing I have found in a year of measuring this system.
There is one more finding from the re-measure that is not a money failure but is worth stating, because it changes how much any of these numbers are worth. Three of the insurance negative questions – the ones that check whether the engine will decline rather than invent – were authored when the corpus genuinely did not contain the documents they ask about, and the corpus now does. There is a California Earthquake Authority policy for the Palos Verdes house in the library today, with a $1,927.00 premium and a $979,291 combined dwelling limit. When the engine answered that question with those exact figures, my grader marked it wrong for failing to decline. It was right and my measurement was stale. When I re-keyed those two questions to the document that now answers them and re-ran the corpus, the engine got both, and insurance went from 53% to 65% without a single line of engine code changing (the money bar stayed exactly where it was, failing on the same one question, over a denominator that grew from ten to twelve).
So I re-verified all eight negatives across the three corpora against the current library. I converted the two that had become answerable into ordinary extraction questions keyed to the document that now answers them, corrected a third whose stated rationale was wrong even though its verdict still holds, and left the five that are still true alone. One of those five does still fail honestly: asked about a Southern California Edison bill for a Portland address that Edison has never served, the engine answered $201.74 off a NW Natural gas bill. That one is a real honesty miss on a question whose premise I re-confirmed, and not an artifact of anything.
The fair summary is that the money bar is not clean today, that it fails in three corpora out of six, and that none of the three failures is an answer key I got wrong. They split into a model misreading a table the recognizer flattened and a deterministic extractor reaching for the wrong year and then inventing a number. I have filed all three as engine work and fixed none of them here, because the corpus is the instrument, and you do not adjust the instrument in the same motion as the machine it is measuring.
Why this is the right contract
The argument for the bar is not really an argument about models. It is an argument about who is on the other end. This runs on a phone, over documents I did not publish and would not hand to a service. The person asking is asking precisely because the answer is not obvious to them. There is no colleague to sanity-check the figure, and no second system to reconcile against. In that setting a confident wrong number is worse than no number by a wide margin, and it is worse in a way that compounds, because a system that guesses well most of the time teaches you to stop checking it – which is the only way its rare bad guess ever does real damage.
There is a cost and I want to name it rather than pretend the choice was free. The bar makes the scorecard look worse than a looser system would, every honest decline lands as a miss in the correctness column, and there are questions where a competent guess would have been right and the engine said nothing instead. I have looked at those and decided each time that I do not want them, which is a preference and not a proof. What tips it for me is that the declines have been the most informative output the system produces – "the March 2017 bill isn't in the sources, I see September 2017" is a bug report with a diagnosis attached – and that after months of measuring this thing across every corpus I could build or borrow, the number I would most hate to see move is not the accuracy. It is the one in the money column, and it has moved, in three corpora, in ways I did not predict and cannot yet fix. What the bar bought me was not a clean scorecard. It was finding out – from a check I wrote a year ago and have never been allowed to soften – that my most trusted tier quoted the wrong tax year and then invented six figures of wages, which is a thing I would still not know if I had shipped a system that scores five points higher by occasionally inventing somebody's deductible.
What happened after
Both tax violations are closed now, and the fix that closes them is narrower than the one I had braced myself to write. The fabrication turned out not to be invention in the interesting sense at all. The model had misread Box 1 of the 2024 W-2, whose real value is $1,148,528.02, into $1,317,128.02. Every guard standing between that misread and the rendered row was checking the shape of the reply – under three hundred characters, no more lines than amounts plus two, a total that collapses to the sum of its own components – and never the number itself. So the new guard does the one thing none of the old ones did. A model-extracted amount must now appear literally in the cited document's own recognized text (normalized for thousands commas, and for the recognizer's habit of rendering them as periods), and a single amount failing that test voids the entire reply. The document then degrades to an honest couldn't-read caveat rather than a row. The check runs per document, against the text actually cited, so a figure living somewhere in the other fifteen hundred files cannot launder a bad read on this one. The wrong-year pick got the companion fix. The selector now reads the year the form is for, taken from a bounded standalone year in the filename stem or, failing that, from a tax-year declaration in the body, rather than the year the envelope happened to be mailed. When every candidate declares a year and none of them is the year asked about, it declines instead of picking the nearest thing to hand. Re-run against the same snapshot, with nothing else moved, tax went from 15 correct with two money violations to 17 out of 17 with none, both W-2 questions quoting the real 2025 form. That is about as cleanly as a fix ever gets to own its own jump.
The OCR pair went the other way entirely, and the honest account is that we built three candidate fixes, measured all three, and shipped none of them. The label-and-value re-pairing pass did repair ADT, the ending balance finally reading 42.99 against its own label. Then we ran it across the whole corpus. It fired on better than a quarter of the library and manufactured a wrong pairing on eleven of the fifty-four documents standing behind money questions – including an Ending Balance $0.00 on an Ally account actually holding $182,536.15. That is the ADT catastrophe exactly, except with our signature on it, asserted by our own code rather than stumbled into by the recognizer. The decline guard was cheaper and worse: dry-run over every money question, it would have refused twenty-four correct answers out of forty, because OCR routinely puts a healthy figure on its own line even on documents that are perfectly readable. Line-preserving chunking was the near miss of the three. It genuinely fixed the Brighthouse statement from $0.00 to $45,783.79 and netted four more correct answers overall, and it also produced a $450,658 answer on a tax return whose line 37 reads $44,322, so it came off main the same day it went on. The real fix – rebuilding table rows from the bounding boxes Vision hands us and we currently throw away – is filed and sequenced, and I would rather wait for it than take that trade three times running.
So here is the record as it stands, on a fresh snapshot and under a rewritten grader that is stricter than the one that produced the July numbers (the bar itself turned out to have two holes of its own, which is a story for the post next door):
| corpus (2026-09-01, grader v3) | correct/partial/wrong | correctness | money questions | zero-wrong-money |
|---|---|---|---|---|
| tax | 17/0/0 | 100.00% | 15 | PASS |
| banking | 17/0/1 | 94.44% | 13 | PASS |
| synthetic-irs | 21/0/2 | 91.30% | 18 | PASS |
| openrag (external benchmark) | 35/7/2 | 79.55% | 4 | PASS |
| utilities | 13/3/3 | 68.42% | 8 | FAIL (one) |
| insurance | 11/0/6 | 64.71% | 14 | FAIL (one) |
Two violations remain, they are the same defect in two documents, and the denominators they are measured over got larger rather than smaller when the grader got honest – tax from thirteen money questions to fifteen, insurance from twelve to fourteen. That is the direction I want that number to move. What I take from the whole sequence is less about the two fixes that landed than about the three that did not. The thing I would have done a year ago is ship the re-pairing pass on the strength of the two documents it visibly repaired, and never run it over the other fifty-two. The only reason I did not is that the bar I wrote to convict the engine turns out to apply, without amendment or appeal, to my own repairs – which is the property that makes it worth keeping long after it has stopped being flattering.