<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrei Batinas</title>
    <description>The latest articles on DEV Community by Andrei Batinas (@dubios).</description>
    <link>https://dev.to/dubios</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F928891%2F195ff1fc-6614-4589-8397-8f3741f1dbd4.png</url>
      <title>DEV Community: Andrei Batinas</title>
      <link>https://dev.to/dubios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dubios"/>
    <language>en</language>
    <item>
      <title>Asking a model how confident it is does not detect its own reading errors</title>
      <dc:creator>Andrei Batinas</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:08:12 +0000</pubDate>
      <link>https://dev.to/dubios/asking-a-model-how-confident-it-is-does-not-detect-its-own-reading-errors-5937</link>
      <guid>https://dev.to/dubios/asking-a-model-how-confident-it-is-does-not-detect-its-own-reading-errors-5937</guid>
      <description>&lt;p&gt;&lt;em&gt;I wrote this post for the purposes of entering the All Things Agentic&lt;br&gt;
Hackathon, about the agent I built for it. Here is the part that surprised me.&lt;/em&gt;&lt;/p&gt;



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

&lt;p&gt;I built an agent that does the checking. The interesting part was not the&lt;br&gt;
prompts.&lt;/p&gt;
&lt;h2&gt;
  
  
  The instruction that does not work
&lt;/h2&gt;

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

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

&lt;p&gt;The result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;document&lt;/th&gt;
&lt;th&gt;fields&lt;/th&gt;
&lt;th&gt;errors&lt;/th&gt;
&lt;th&gt;fidelity&lt;/th&gt;
&lt;th&gt;&lt;code&gt;low_confidence_fields&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clean scan&lt;/td&gt;
&lt;td&gt;513&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;99.610%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;errors scan&lt;/td&gt;
&lt;td&gt;457&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;99.781%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;penalties scan&lt;/td&gt;
&lt;td&gt;513&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100.000%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three errors. Zero flagged.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;A model's introspection about its own certainty is not a detector.&lt;/strong&gt; No amount&lt;br&gt;
of prompt engineering fixes this, because the model has no independent check on&lt;br&gt;
its own reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  What works instead
&lt;/h2&gt;

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

&lt;p&gt;So before any audit rule runs, a deterministic check called R0 verifies the&lt;br&gt;
transcription against itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;per row: Σ charges + arrears + penalties == the printed total due&lt;/li&gt;
&lt;li&gt;per column: Σ across apartments == the category's declared amount&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the clean scan, both fired:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The second reading returned &lt;code&gt;41,80&lt;/code&gt;. The correct value.&lt;/p&gt;

&lt;p&gt;And then the system threw it away.&lt;/p&gt;
&lt;h2&gt;
  
  
  The conservative rule
&lt;/h2&gt;

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

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

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

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

&lt;p&gt;It identifies well. 2.8 of 3 and 3.4 of 4 planted findings on average, and four&lt;br&gt;
of five runs quoted all three abusive penalty amounts correctly.&lt;/p&gt;

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

&lt;p&gt;And on a document with &lt;strong&gt;zero&lt;/strong&gt; planted errors, every run reported problems&lt;br&gt;
anyway. From one of them, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;#### 1. Eroare de calcul la consumul de apă (Ap. 3)&lt;/span&gt;
&lt;span class="p"&gt;*&lt;/span&gt;   Index vechi:      347,2 mc
&lt;span class="p"&gt;*&lt;/span&gt;   Index nou:        353,0 mc
&lt;span class="p"&gt;*&lt;/span&gt;   Consum real:      353,0 - 347,2 = 5,8 mc
&lt;span class="p"&gt;*&lt;/span&gt;   Consum înregistrat în tabel: 5,6 mc (eroare de 0,2 mc în minus...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  The boundary
&lt;/h2&gt;

&lt;p&gt;This is why the audit rules are ordinary Python and not an agent.&lt;/p&gt;

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

&lt;p&gt;A test parses &lt;code&gt;reconciler.py&lt;/code&gt; and asserts that no model SDK appears among its&lt;br&gt;
imports, so the boundary is enforced structurally rather than by convention.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

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

&lt;p&gt;The schema changes. The rules change. The article numbers change.&lt;/p&gt;

&lt;p&gt;R0 does not, because it encodes no domain knowledge at all. It only requires&lt;br&gt;
that the numbers were supposed to add up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code, architecture diagram and reproducible setup:&lt;br&gt;
&lt;a href="https://github.com/dubios1923/consilium" rel="noopener noreferrer"&gt;https://github.com/dubios1923/consilium&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Live inspection page:&lt;br&gt;
&lt;a href="https://consilium-dashboard-aq2ftfgfkq-ew.a.run.app" rel="noopener noreferrer"&gt;https://consilium-dashboard-aq2ftfgfkq-ew.a.run.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
  </channel>
</rss>
