Check what a model extracted
The rows came back from a model, not from this library. The check that can still say they are wrong costs one object, and it never looks at how they were obtained.
A model reads layouts nothing deterministic can. It also returns rows that look exactly like right ones, because looking right is the thing it is best at, and the literature says plainly why that stays true.
So the useful question is not which reader to pick. It is: once the rows are in your hands, what can still tell you they are wrong?
Nothing in this library’s pipeline asks where a reading came from. readDocument takes a document and an object with a read method, and nobody checks that read did any reading. Hand it what the model returned and every guarantee around it still applies.
One object, and read does not read
import { documentFromText, readDocument, findNumbers } from 'truecopy';
const document = documentFromText(sourceText, 'statement.txt');
const rows = await yourModel(document.text); // whatever you already do
// Every figure the document actually contains, and the total it declares.
const written = new Set(findNumbers(document.text, 2).map((found) => found.value));
const totalLine = document.text.split('\n').find((line) => line.startsWith('Total'));
const declared = findNumbers(totalLine ?? '', 2).at(-1)?.value ?? null;
const result = readDocument(document, {
// `read` does not read. It hands back what the model returned.
read: () => ({ records: rows, header: { declared } }),
selfCheck: (_document, reading) =>
reading.header.declared === null
? { nothing: 'this statement declares no total to check against' }
: {
declared: [reading.header.declared],
read:
Math.round(
reading.records.reduce((sum, row) => sum + row.amount, 0) * 100
) / 100,
unit: 'EUR'
},
// What a person has to confirm: the rows carrying a figure the document does not contain.
rowsToReview: (_document, reading) =>
reading.records
.filter((row) => !written.has(row.amount) && !written.has(-row.amount))
.map((row) => ({
raw: `${row.date} ${row.label} ${row.amount}`,
fields: { ...row },
droppedBecause: 'this value is not written anywhere in the document'
}))
});
On a six-line statement where the model read -118.75 as -18.75, run against truecopy 1.0.0:
verdict : needs-review
discrepancy : {
amount: 99.99999999999977,
unit: 'EUR',
declared: 1988.95,
read: 2088.95
}
to review : [ '2026-01-11 DIRECT DEBIT ENERGY -18.75' ]
Two independent things caught the same mistake, and neither of them read the document.
The two checks, and why they are different
The document contradicts itself. The rows add up to 2088.95, the document says 1988.95. This is the only check that can call a reading wrong rather than odd, and it is arithmetic: no model, no threshold, no judgement. readDocument will not return read for a reading that contradicts its document, whoever produced it. That rule is the contract, and it was written for a hand-rolled reader long before it was pointed at a model.
A value is not in the document. -18.75 appears nowhere in the source text. This one catches what the first misses: a value invented outright, a digit dropped, a figure carried over from the previous page. It is worth having on its own, because a model asked for JSON will fill a field rather than leave it empty, and findNumbers compares figures as quantities, so 1 234,50 and 1,234.50 are the same number and neither is a string to match.
Note which rows come back: the ones a person should look at, not a boolean. That is the fourth law, and it is the difference between a check that blocks a pipeline and one that routes a document to a screen.
Round before you compare
That 99.99999999999977 is real output, not a typo. discrepancy is a subtraction between two floats, and it is compared against exactly zero.
So round the sum to the precision the document is written in, inside selfCheck, as the snippet does. Skip it and a correct reading comes back as needs-review because a cent-sized residue is not zero. It is the first thing that goes wrong here and it looks like a bug in the check rather than in the arithmetic.
What this does not prove
It does not compare the model against the truth. Nothing can, without a second reading of the same document by something that does not share the first one’s blind spots.
It compares the model against what the document says about itself, which has two consequences worth stating out loud:
- A document that declares nothing cannot be checked this way. Say so with
{ nothing: 'why' }and you learn that no arithmetic check exists here, which is worth more than a green tick that meant nothing. What is left is the second check, and classify for whether it is even the document you think. - Two errors that cancel out pass. A reading that transposes two amounts still sums correctly. The self-check is a strong filter, not a proof, and the honest output stays three-valued for that reason.
An empty list of problems is not a promise that the reading is right. It never was, for a model or for anything else.
Where to go next
- How do you know the reading is right? - the six checks, of which this page is two.
- contract - the shape
readDocumentmakes compulsory, and the defaults it fills in. - kit - the same rules as assertions in your own test suite, so the check survives the next refactor.
- truecopy vs asking a model to extract it - when to use which, and what the research says about abstention.