DEV Community

Cover image for Asking a model how confident it is does not detect its own reading errors
Andrei Batinas
Andrei Batinas

Posted on

Asking a model how confident it is does not detect its own reading errors

I wrote this post for the purposes of entering the All Things Agentic
Hackathon, about the agent I built for it. Here is the part that surprised me.


Every month the administrator of a Romanian homeowners' association posts a
payment list on the notice board. Twenty-eight apartments, fifteen columns,
eight different allocation keys. By law you have ten days to contest how your
share was calculated. Almost nobody does, because checking it means knowing both
the statute and the arithmetic.

I built an agent that does the checking. The interesting part was not the
prompts.

The instruction that does not work

The extractor has one job: copy printed values out of a PDF into a validated
structure. It is explicitly forbidden to compute anything. And it is told, in
capital letters, that a value it cannot read clearly goes into
low_confidence_fields rather than being guessed.

To test whether that instruction held, I made the generator produce degraded
versions of the sample documents: rasterised to 150 dpi, skewed by up to 1.5
degrees, Gaussian noise, JPEG at quality 75, repackaged as a PDF with no text
layer. pdftotext returns three bytes. The model has to actually read pixels.

The result:

document fields errors fidelity low_confidence_fields
clean scan 513 2 99.610% 0
errors scan 457 1 99.781% 0
penalties scan 513 0 100.000% 0

Three errors. Zero flagged.

The worst one: apartment 7's hot water charge, 41,80 lei, read as 34,20.
That is not a mangled digit. It is the value from apartment 5's row, picked up
because the page was skewed. The model did not perceive the cell as illegible.
It perceived it as legible, and was wrong, with full confidence.

This matters more than a fidelity number suggests. The row's printed total was
still transcribed correctly, so the breakdown no longer summed to it. Downstream,
that looks exactly like an association misallocating money. An audit built on
that produces a formal accusation from a scanning artefact.

A model's introspection about its own certainty is not a detector. No amount
of prompt engineering fixes this, because the model has no independent check on
its own reading.

What works instead

A financial document contains its own redundancy. Rows sum to totals. Columns
sum to declared amounts. That property has nothing to do with heating or
undivided ownership shares; it holds because somebody laid the document out
expecting the numbers to add up.

So before any audit rule runs, a deterministic check called R0 verifies the
transcription against itself:

  • per row: Σ charges + arrears + penalties == the printed total due
  • per column: Σ across apartments == the category's declared amount

On the clean scan, both fired:

[R0.row]    Apartamentul 7: suma defalcării (891.33) nu dă totalul de plată
            tipărit (898.93); diferență -7.60 lei.
[R0.column] Coloana „Apă caldă menajeră": suma pe apartamente (3218.60) nu dă
            suma declarată în tabelul de cheltuieli (3226.20); diferență -7.60 lei.
Enter fullscreen mode Exit fullscreen mode

Same difference, both times. When a row and a column fail by the same amount,
their intersection localises a single cell. One targeted re-read is spent on it:
that page rendered at 300 dpi, with the response narrowed to one cell.

The second reading returned 41,80. The correct value.

And then the system threw it away.

The conservative rule

Two readings that disagree mean you do not know which one is right. Adopting the
second because it happens to balance is just preferring the answer you like. So
apartment 7 is marked unauditable, the cell goes into
low_confidence_fields, and the rule that depends on it reports partial
coverage instead of a finding.

The result on that document: zero false audit findings. Not "we caught the
error", which would still leave the question of what else we missed. Zero
accusations generated from a scanning artefact.

Field-level precision matters here. The uncertain value is one charge cell, so
the rules that never touch it still run: the undivided shares still sum to 100%,
the penalties are still checked against the legal cap, the apartment count is
still verified. One bad cell does not void an audit.

The comparison worth making

The obvious objection is that a good model in a chat window would do this
anyway. So I measured it: same model, same PDFs, one call, the prompt a person
would actually type. "Check this payment list, find the errors, and draft a
formal request to the association."
Five runs per document.

It identifies well. 2.8 of 3 and 3.4 of 4 planted findings on average, and four
of five runs quoted all three abusive penalty amounts correctly.

It quantifies badly. 0.2 of 4. It reports the penalty printed in the
document and almost never the excess over the legal cap, which requires knowing
the cap, applying it to the arrears, and subtracting. The first number the owner
can read themselves. The second is the one they need to file anything.

And on a document with zero planted errors, every run reported problems
anyway. From one of them, verbatim:

#### 1. Eroare de calcul la consumul de apă (Ap. 3)
*   Index vechi:      347,2 mc
*   Index nou:        353,0 mc
*   Consum real:      353,0 - 347,2 = 5,8 mc
*   Consum înregistrat în tabel: 5,6 mc (eroare de 0,2 mc în minus...)
Enter fullscreen mode Exit fullscreen mode

The document says 347,4, not 347,2. The model misread one digit, did
perfectly correct arithmetic on its own wrong input, and concluded that the
association had understated a meter reading. Everything downstream of that is
built on a digit that was never there.

That is the same failure as apartment 7. The difference is that one system has a
check that does not depend on the model being honest with itself, and the other
does not.

The boundary

This is why the audit rules are ordinary Python and not an agent.

The pipeline is a Google ADK SequentialAgent with six sub-agents. Only three
touch a model: triage reads the first page to decide whether the document is
worth the expensive pipeline at all, extraction transcribes, and drafting writes
the letter. The integrity check and the seven audit rules are subtractions and
comparisons against a tolerance. A model executing them would introduce a class
of error that otherwise does not exist, in exchange for no capability.

A test parses reconciler.py and asserts that no model SDK appears among its
imports, so the boundary is enforced structurally rather than by convention.

The same reasoning shapes the letter. It is generated by a model and then
verified against the findings: every amount in it must come from a computed
finding, and every paragraph must point at one. A letter that fails the check is
never sent.

That validator caught something too, though not what I expected. It rejected
three drafts in a row over two amounts. The model had invented nothing: the
figures came from the reconciler's own explanation text, which my allowlist did
not scan. The validator was right to reject and wrong about provenance. A strict
check that fails closed shows you where your own definition is incomplete,
instead of letting the output through.

What generalises

Consilium audits Romanian homeowners' association payment lists because that is
the problem I have. But the shape is not specific to it: a financial document
issued by a party with an interest in erring in its own favour, sent to someone
with a legal deadline to object and no practical way to check. Utility bills.
Medical bills. Insurance settlements. Supplier invoices to small businesses.

The schema changes. The rules change. The article numbers change.

R0 does not, because it encodes no domain knowledge at all. It only requires
that the numbers were supposed to add up.


Code, architecture diagram and reproducible setup:
https://github.com/dubios1923/consilium

Live inspection page:
https://consilium-dashboard-aq2ftfgfkq-ew.a.run.app

Top comments (1)

Collapse
 
anp2network profile image
ANP2 Network

The 300 dpi reread is doing less than the write-up implies on the degraded PDFs. In tools/gen_samples.py, SCAN_DPI = 150, _degrade_page rasterizes with pdftoppm -r 150, rotates, adds noise, saves JPEG quality 75, then rebuilds the PDF by drawing that JPEG at width_px * 72.0 / SCAN_DPI. So each degraded page is a single lossy 150 dpi raster. Then config.yaml sets integrity.reread_dpi: 300, and resolve_integrity calls render_pages(...), which shells out to pdftoppm -r 300 -png on the same PDF.

The second pass is upsampling the exact pixels where rereading matters. The skew is unchanged, the row geometry is unchanged, the bitmap is bigger. What actually differs is the prompt: CELL_REREAD_PROMPT names one apartment and one category, while the first pass transcribes the whole table. That is arguably a better result than the one you claimed, since attention narrowing is cheap and carries to any document. It is also a different mechanism, and it does nothing about geometry. A skewed row boundary is baked into the raster and survives resampling, and the apartment 7 failure was a row boundary slip.

The branch that produces an accusation is also the branch that did not run in your example. In resolve_integrity, disagreement after reread_cell(...) appends to flags and marks the apartment and category unauditable. Agreement within integrity.reread_match_tolerance: 0.01 goes to confirmed_inconsistencies, which _integrity_findings converts into a high severity internal_arithmetic_mismatch whose message asserts the reading was confirmed by a second pass. So "zero false audit findings" on that document is a measurement of the abstention path. The accusation path fires when the two reads agree, and same-pixel agreement is what a systematic geometry error produces. Blur and JPEG ringing are the errors a second look might shake off. A skew that put one row's value into another row's cell is the error a second look reproduces.

There is a concrete route into that branch in integrity.py. localize requires each row delta to match exactly one column delta, but it never requires the matched column to match exactly one row. And arrears, penalties and total_due appear only in check_rows, since check_columns iterates expense_lines and only charge cells land in a column check. Give apartment 9 a misread arrears with delta d, let some unrelated category column already be off by a delta within 0.01 of d from a genuine charge misread elsewhere, and localize will name a charge cell in apartment 9 that is correct. It rereads the same. confirmed gets extended, and the accusation is on a row whose actual error was in a field no column ever checks. I would make localize require mutual uniqueness and fall through to the whole-row reread path otherwise. The cheap control for the first point: rerun the apartment 7 targeted prompt at 150 dpi. If it still returns 41,80, the dpi knob is inert and deskewing before the second render is the experiment worth running.