<?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: Cosmin Iaru</title>
    <description>The latest articles on DEV Community by Cosmin Iaru (@cosmin_iaru).</description>
    <link>https://dev.to/cosmin_iaru</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%2F4210902%2F475df1b2-47fa-490a-ad6a-9c600f2574e1.jpg</url>
      <title>DEV Community: Cosmin Iaru</title>
      <link>https://dev.to/cosmin_iaru</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cosmin_iaru"/>
    <language>en</language>
    <item>
      <title>Why Single-Pass LLMs Fail at Receipt OCR (And How We Built a Deterministic Consensus Engine)</title>
      <dc:creator>Cosmin Iaru</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:40:25 +0000</pubDate>
      <link>https://dev.to/cosmin_iaru/why-single-pass-llms-fail-at-receipt-ocr-and-how-we-built-a-deterministic-consensus-engine-211m</link>
      <guid>https://dev.to/cosmin_iaru/why-single-pass-llms-fail-at-receipt-ocr-and-how-we-built-a-deterministic-consensus-engine-211m</guid>
      <description>&lt;p&gt;A few weeks ago, while sharing an early version of &lt;a href="https://finpulse.day" rel="noopener noreferrer"&gt;FINpulse&lt;/a&gt;—a personal and family finance PWA I’ve been building—someone on Reddit left a comment that stuck with me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I run an enterprise system processing tens of thousands of receipts a month. AI extraction looks great when you test it on 50 receipts. When you hit thousands, you realize LLMs estimate—they don't interpret. If a receipt is faded and it looks logical for a line item to be 100 RON, the model will just write 100 RON and report 100% confidence."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He was completely right.&lt;/p&gt;

&lt;p&gt;When you use a vision LLM (whether it’s Gemini Flash, GPT-4o, or Claude) to extract structured JSON from a crumpled thermal grocery receipt, the model is optimizing for &lt;strong&gt;plausibility&lt;/strong&gt;, not accounting accuracy. &lt;/p&gt;

&lt;p&gt;If a thermal printer skipped a few pixels on a &lt;code&gt;7&lt;/code&gt; so it looks a bit like a &lt;code&gt;1&lt;/code&gt;, a standard OCR engine might return a low character confidence score. A vision LLM, however, will happily "fix" the rest of the line item—or worse, invent a price that makes the receipt look tidy—while returning &lt;code&gt;"confidence": 0.99&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a toy project, a 2-dollar hallucination doesn't matter. In a personal finance and 50/50 couple bill-splitting app, a single invented number ruins the user's trust immediately.&lt;/p&gt;

&lt;p&gt;Here is how we redesigned the extraction pipeline in &lt;a href="https://finpulse.day" rel="noopener noreferrer"&gt;FINpulse&lt;/a&gt; to stop trusting single-pass LLM outputs and force deterministic verification before anything touches the database.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Never Ask an LLM for a "Confidence Score"
&lt;/h3&gt;

&lt;p&gt;Our first mistake was asking the model to rate its own confidence (&lt;code&gt;0.0&lt;/code&gt; to &lt;code&gt;1.0&lt;/code&gt;) in the JSON schema. &lt;/p&gt;

&lt;p&gt;LLMs are notoriously terrible at calibrating their own uncertainty on visual tasks. Unless the image is completely black, the model will almost always output &lt;code&gt;0.95&lt;/code&gt; or &lt;code&gt;1.0&lt;/code&gt;, even when it just guessed three blurry prices.&lt;/p&gt;

&lt;p&gt;Instead of asking for subjective confidence, we changed the prompt schema to ask for &lt;strong&gt;observable visual telemetry&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;isBlurry&lt;/code&gt;: boolean&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;isCutOff&lt;/code&gt;: boolean (is the top merchant header or bottom total missing?)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;textClarity&lt;/code&gt;: &lt;code&gt;"high" | "medium" | "low"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a receipt is physically cut off at the bottom, the model is much more honest about answering &lt;code&gt;isCutOff: true&lt;/code&gt; than lowering an arbitrary confidence float.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Deterministic Math Gate
&lt;/h3&gt;

&lt;p&gt;A receipt is not freeform prose; it’s a strict mathematical table. Every receipt must obey two basic invariants:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Line-Item Invariant:&lt;/strong&gt; For every product row, &lt;code&gt;quantity × unitPrice&lt;/code&gt; must equal &lt;code&gt;lineTotal&lt;/code&gt; (within a &lt;code&gt;±0.02&lt;/code&gt; rounding tolerance for weighted items like fruits or vegetables).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipt Sum Invariant:&lt;/strong&gt; The sum of all extracted &lt;code&gt;lineTotal&lt;/code&gt; values, minus any explicit &lt;code&gt;discount&lt;/code&gt; lines, must equal the printed &lt;code&gt;totalAmount&lt;/code&gt; at the bottom of the receipt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So we built a pure TypeScript deterministic validator (&lt;code&gt;lib/ai/receipt-validator.ts&lt;/code&gt;) that runs immediately after the primary vision model returns its JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sumOfItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;netCalculatedTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sumOfItems&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalDiscounts&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headerTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extracted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalDiff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;netCalculatedTotal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;headerTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMathValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalDiff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also check line-level multiplication and date sanity (e.g., rejecting receipts where a smudge caused &lt;code&gt;2026&lt;/code&gt; to be read as &lt;code&gt;2020&lt;/code&gt; or a future date).&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;isMathValid&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, all line multiplications check out, and &lt;code&gt;textClarity&lt;/code&gt; is high, we don't need to waste latency or tokens—the extraction is mathematically proven to be consistent.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Dual-Model Consensus (Only When the Math Fails)
&lt;/h3&gt;

&lt;p&gt;What happens when &lt;code&gt;totalDiff &amp;gt; 0.05&lt;/code&gt;? &lt;/p&gt;

&lt;p&gt;Maybe the receipt has 15 grocery items, and the sum of the items is &lt;code&gt;142.50&lt;/code&gt;, but the printed total at the bottom is &lt;code&gt;152.50&lt;/code&gt;. One of the items (or the total itself) was misread by 10 units.&lt;/p&gt;

&lt;p&gt;Calling the same model a second time rarely helps; it usually makes the exact same visual assumption. Instead, our pipeline triggers a &lt;strong&gt;Consensus Fallback&lt;/strong&gt; using a second, independent vision architecture (crossing Google Gemini with OpenAI's &lt;code&gt;gpt-4o-mini&lt;/code&gt; vision).&lt;/p&gt;

&lt;p&gt;Once we have outputs from both Model A and Model B:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We run the &lt;strong&gt;Deterministic Math Gate&lt;/strong&gt; on Model B's output. If Model B's line items add up cleanly to the printed total (&lt;code&gt;totalDiff &amp;lt;= 0.02&lt;/code&gt;), Model B wins automatically.&lt;/li&gt;
&lt;li&gt;If both models struggle (for instance, a receipt was torn or folded right across a price), our comparator diffs the two item lists line by line and pinpoints the exact row where the two models disagreed.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  4. Honest UX: Flagging Discrepancies Instead of Hiding Them
&lt;/h3&gt;

&lt;p&gt;The biggest takeaway from that Reddit thread was simple: &lt;strong&gt;users don't expect a scanner to read an unreadable receipt, but they hate when software silently lies to them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the deterministic math gate still finds a gap after consensus, we don't silently insert a fake "adjustment" row or pretend everything is fine. &lt;/p&gt;

&lt;p&gt;Instead, &lt;a href="https://finpulse.day" rel="noopener noreferrer"&gt;FINpulse&lt;/a&gt; opens a &lt;strong&gt;Reconciliation View&lt;/strong&gt; right next to the receipt photo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lines that passed mathematical validation are marked green.&lt;/li&gt;
&lt;li&gt;The specific line item (or total) where the math didn't add up is highlighted in amber, showing both the sum of items and the printed receipt total.&lt;/li&gt;
&lt;li&gt;The user can tap the highlighted number, type the correction in two seconds, and save.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once verified, the receipt items can automatically cross off products from the user's active shopping list or be split 50/50 inside a shared family workspace.&lt;/p&gt;




&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Combining LLMs with deterministic rules completely changed how reliable our document pipeline feels in production. LLMs are fantastic at layout-agnostic parsing—figuring out that &lt;code&gt;"LAPTE 3.5% 2 BUC X 6.50"&lt;/code&gt; is a dairy product with &lt;code&gt;quantity: 2&lt;/code&gt; and &lt;code&gt;unitPrice: 6.50&lt;/code&gt; across hundreds of different store formats. But arithmetic and verification belong in deterministic code, not in neural weights.&lt;/p&gt;

&lt;p&gt;If you're curious to test how the hybrid receipt scanner and PDF/CSV bank statement importer work in practice, you can try the free tier of &lt;strong&gt;&lt;a href="https://finpulse.day" rel="noopener noreferrer"&gt;FINpulse&lt;/a&gt;&lt;/strong&gt; (no bank login credentials required) or check out the architecture details on our &lt;a href="https://finpulse.day/help" rel="noopener noreferrer"&gt;overview page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How are you handling validation and hallucinations when extracting structured financial data with LLMs? I'd love to hear what patterns worked for your stack in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
