<?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: Stephen Ohakanu</title>
    <description>The latest articles on DEV Community by Stephen Ohakanu (@sohakanu).</description>
    <link>https://dev.to/sohakanu</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%2F4126601%2F9b5e9289-d903-457f-ba1c-8b1fbc54236f.png</url>
      <title>DEV Community: Stephen Ohakanu</title>
      <link>https://dev.to/sohakanu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sohakanu"/>
    <language>en</language>
    <item>
      <title>One Packet to Tune, Two to Check: Tuning Scribe Without Fooling Myself</title>
      <dc:creator>Stephen Ohakanu</dc:creator>
      <pubDate>Sun, 18 Oct 2026 19:21:31 +0000</pubDate>
      <link>https://dev.to/sohakanu/one-packet-to-tune-two-to-check-tuning-scribe-without-fooling-myself-51m5</link>
      <guid>https://dev.to/sohakanu/one-packet-to-tune-two-to-check-tuning-scribe-without-fooling-myself-51m5</guid>
      <description>&lt;p&gt;Scribe is a pipeline I'm building that reads handwritten answers off scanned paper forms with local models. Answers that look doubtful go to a person for review; the rest are accepted.&lt;/p&gt;

&lt;p&gt;Part 2 ended with reading accuracy dropping sharply on handwriting the pipeline had never seen, while the review gate held. It also said part 3 would be the private held-out evaluation. That evaluation needs packets the pipeline has never seen, and it's still coming. This post answers the question that comes first: on a long form I &lt;em&gt;could&lt;/em&gt; tune for, how far could tuning go, and how would I know the gains were real?&lt;/p&gt;

&lt;p&gt;I moved Scribe to a 14-page questionnaire with 177 answers, on pages full of tick grids, yes/no underlines, timed tasks and tester-written scores. A packet here is one filled copy of those 14 pages. I tuned on one packet and kept two back as check packets.&lt;/p&gt;

&lt;p&gt;There was no fine-tuning: no model weights changed. Tuning here means everything around the models: crops, prompts, the schema, review rules and serving settings. And most of what I learned was about checking rather than tuning. The first apparent gain was a scoring convention. The two worst bugs stayed silent until I looked at what the model actually saw. One change looked neutral on the tuning packet and hurt the check packets.&lt;/p&gt;

&lt;p&gt;On all 531 answers across the three packets, here is the current setup (the last row of the table at the end) against where I started:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;96% read exactly right&lt;/strong&gt;, up from 93% (94.4% to 96.3% on the two check packets alone).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;99.5% of the answers accepted without review were correct&lt;/strong&gt;, up from 97.5%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two wrong answers slipped past review&lt;/strong&gt;, down from 11, but 9 of those 11 were on the tuning packet (2 to 1 on the check packets alone).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About 22% of answers go to review&lt;/strong&gt;, up from 18%: a second model that re-checks answers adds review work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A packet took about 37 minutes.&lt;/strong&gt; Adding that second model had pushed it to about 70, and the speed work won the time back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answer by answer against the baseline, 16 answers went from wrong to right and none from right to wrong (exact McNemar p = 0.00003). On the check packets alone it was 7 and none (p = 0.016). Treat both as rough: one fix can flip several answers on the same page, and the first figure includes the packet I tuned on.&lt;/p&gt;

&lt;p&gt;None of this counts toward part 1's targets: I tuned on one of these packets, and the check packets shaped some decisions along the way. The rest of this post is how I got there, what I threw away, and where the method leaked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Ftuning_gains_flow_v2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Ftuning_gains_flow_v2.png" alt="How each change was tested and what it gained: baseline 93.2% exact with 11 escaped at about 52 minutes; tuned 95.3% with 4 escaped; with a second model 94.9% with 3 escaped at about 70 minutes, a dip that came from moved crops, not from the second model's flags; before caching 96.0% with 1 escaped at about 47 minutes; current, with prefix caching, 96.2% with 2 escaped at about 37 minutes, where caching also moved crops so the extra escape's cause is unresolved. Dropped along the way: cropping the tester-scored page, re-checking blank answers, 8 answers at once, removing the reading skills, and speculative decoding." width="800" height="1486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The pages always come in the same order: questionnaires, a page that a tester scores, and several pages of timed tasks and number fields. I built one schema for the packet. It now has 177 answer fields (201 before I dropped some), 89 checks between related answers, and a rule that a page's position in a complete packet decides which page it is.&lt;/p&gt;

&lt;p&gt;I had three filled copies. They have never been published, so the models tested here cannot have seen these filled pages in training (blank forms of this kind may well be public).&lt;/p&gt;

&lt;p&gt;The answer key was drafted automatically (two independent AI reads of each page, then an adjudicating pass), and two people, one of them me, then reviewed every value. The first review changed 2 of the 603 values in that first key, the two reviewers agreed on 99.2% (598 of 603), and I settled the five disagreements. People checking a pre-filled draft can miss what the draft missed, so the key is very good rather than perfect. It shrank to 531 answers when I dropped some fields, below.&lt;/p&gt;

&lt;p&gt;The rules, which I settled after a first experiment had already been scored on all three packets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one packet for tuning (the hardest at baseline: 86% exact, against 94% on another)&lt;/li&gt;
&lt;li&gt;two check packets, kept out of the tuning loop and run to confirm a change&lt;/li&gt;
&lt;li&gt;every comparison is paired: each answer is scored in both runs, only the answers that changed count, and an exact McNemar test on the fixed and broken counts gives a rough p-value (reported, not used as a cut-off)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A fourth rule came later, from a result described below: a change stays only if it improves the tuning packet &lt;em&gt;and&lt;/em&gt; holds on the check packets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the scorer before trusting a gain
&lt;/h2&gt;

&lt;p&gt;My first experiment compared prompts with and without short "reading skills": form-agnostic notes on how pen marks work (underlines, crossings-out, handwritten digits, empty boxes). On the first packet scored, the skills version looked 1.5 points better.&lt;/p&gt;

&lt;p&gt;It wasn't. The model writes &lt;code&gt;[]&lt;/code&gt; for "nothing ticked", and my answer key writes a blank. Both mean the same thing, but the scorer counted them as different, and that one convention produced the whole gain. Scored fairly, the skills version was half a point &lt;em&gt;behind&lt;/em&gt; on that packet, and across all three packets the two versions were within two answers of each other.&lt;/p&gt;

&lt;p&gt;I check both scorings now and decide on the fair one; every number in this post uses it. Part 2's lesson again, from the other direction: before you trust a gain, check that your scorer isn't the thing that changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at what the model actually saw
&lt;/h2&gt;

&lt;p&gt;Accuracy numbers told me &lt;em&gt;which&lt;/em&gt; answers were wrong, not &lt;em&gt;why&lt;/em&gt;. So I built a debugging view that shows, for every wrong answer, the image the model read next to where the answer actually is on the page.&lt;/p&gt;

&lt;p&gt;It found two bugs that no metric would have explained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One page was never cropped at all.&lt;/strong&gt; The step that finds each answer's box asks for every box on a page in one reply. On the tester-scored page that reply hit its length limit, the cut-off JSON failed to parse, and all 22 fields meant to be cropped silently fell back to the whole page. Fix: ask in batches of ten, and keep every complete box from a cut-off reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A grid was read one column off.&lt;/strong&gt; Each row of a tick grid is ticked under one of three column headings. Cropping a single row removed the headings, so the model miscounted columns and gave wrong answers with full confidence. Fix: read those rows from the whole page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A third pattern, invented "yes" answers on blank rows of a timed-task page, turned out to be the crop catching a circle from the row above. Same fix.&lt;/p&gt;

&lt;p&gt;On the tuning packet alone, three rounds of changes took exact accuracy from 85.6% to 89.1% and accepted-answer accuracy from 90.6% to 98.6%. Not all of that was better reading: the middle round also sent every tick on the tester-scored page straight to review, which takes the worst answers out of the accepted pile, and added an underline rule I'd noticed on these forms (&lt;em&gt;the word where the underline starts is the one chosen&lt;/em&gt;) to the skills and to 26 yes/no hints.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change that looked fine and wasn't
&lt;/h2&gt;

&lt;p&gt;Once the box-finding worked, the tester-scored page was finally cropped. On the tuning packet that changed nothing (64% before, 64% after). I almost kept it.&lt;/p&gt;

&lt;p&gt;On the two check packets it &lt;strong&gt;dropped that page from 94% to 86%&lt;/strong&gt;. A crop shows only part of a row of ticks, and the old bug had been hiding the problem by never cropping at all.&lt;/p&gt;

&lt;p&gt;I reverted it, and that's when "holds on the check packets" became a hard rule rather than a final formality. It also means the check packets have now shaped a decision; more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read what you actually use
&lt;/h2&gt;

&lt;p&gt;The tester-scored page has a tick per item and a handwritten score per section. The ticks were the problem: 15 of 66 wrong at the start, and once I sent every tick to review, 22 extra review items per packet (about 11% of all its answers). The written scores had one error across all three packets, and they are the values that get used downstream.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fscored_assessment_ticks_vs_scores.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fscored_assessment_ticks_vs_scores.png" alt="A synthetic tester-scored page with invented items. Orange boxes mark the per-item ticks, including one over the box border, one crossed out and one faint; green boxes mark the handwritten section scores and the total. A legend below explains both." width="800" height="1180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A synthetic page with invented items, drawn for this post. Ticks can land over the border, be crossed out or be faint; the written section score is the tester's own count.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the schema now reads only the written scores. It drops the tick fields, plus two fields that depended on them. That changes the task rather than improving the reading: the page's worst fields leave the scoring, review load drops by 22 items per packet, and each packet needs 24 fewer model calls. To keep comparisons honest, the summary at the top and every comparison from here on score both runs on the same reduced set of 531 answers; the figures before this point used the original 603.&lt;/p&gt;

&lt;p&gt;With all the fixes so far, and no second model yet, exact accuracy across the three packets went from 93.2% to 95.3% (12 answers fixed, 1 broken), and wrong answers escaping review fell from 11 to 4. On the check packets alone the gain was smaller and not significant: 94.4% to 95.5% (5 fixed, 1 broken).&lt;/p&gt;

&lt;h2&gt;
  
  
  The second model, revisited
&lt;/h2&gt;

&lt;p&gt;Part 1's best idea was a second, different model re-reading each non-blank answer the main model would accept. Adding it back on this form let one fewer wrong answer through (4 to 3), but the review load jumped from 17% to 27%.&lt;/p&gt;

&lt;p&gt;Exact accuracy also dipped, from 95.3% to 94.9%. The second model only flags answers; it never changes one, so the dip came from somewhere else. This first version re-read each answer from the other source (the whole page for a cropped answer, a crop for a whole-page one), so the pipeline now had to find a box for every field. That changed how the box requests were batched and moved 129 of the main model's 270 crops. Eight of its answers changed as a result: 3 for the better, 5 for the worse. Something outside the main model had changed what it saw, again.&lt;/p&gt;

&lt;p&gt;Looking at its disagreements, &lt;strong&gt;92% were false alarms&lt;/strong&gt; (55 of 60), and 42 of those were answers the main model had read from a crop that the second model re-read from the whole page. On a dense page, the small model often couldn't find the field.&lt;/p&gt;

&lt;p&gt;I tried two fixes, one at a time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Also re-check blank answers.&lt;/strong&gt; Dropped. Review load on the tuning packet rose from 27% to 36%, and every new flag was a false alarm: the small model "saw" answers on empty lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-read the same image the main model read.&lt;/strong&gt; Kept. False alarms dropped from 16 to 10 on the tuning packet, and the main model's answers there went back to exactly what they were without a second model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a second read is useful when it's a fair second look at the same thing, and much less useful as a way to confirm that something is empty. Its flags are still mostly false alarms: 92-95% in every version. Whether it earns its keep on this form is still open. In the current setup it stops 2 wrong answers that would otherwise be accepted, at the cost of 40 false alarms (about 7.5% of all answers). Two answers is more than nothing and less than proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faster, checked answer by answer
&lt;/h2&gt;

&lt;p&gt;A 70-minute packet was too slow. My condition for speed work: no change could make the answers worse, and every change got a paired run.&lt;/p&gt;

&lt;p&gt;More than half the time went into reading the prompt rather than writing the reply: each whole-page read sends about 5,100 tokens, about 3,650 of them the page image.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Time saved&lt;/th&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;read 4 answers on a page at once, and skip the page-type check when page order already settles it&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;td&gt;176 of 177 identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;also cap the short note the model writes after each answer at 60 characters&lt;/td&gt;
&lt;td&gt;24% in total&lt;/td&gt;
&lt;td&gt;2 changed, both for the better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reuse the processed page image across that page's whole-page questions (prefix caching)&lt;/td&gt;
&lt;td&gt;22% more per packet&lt;/td&gt;
&lt;td&gt;3 changed: 1 for the better, none for the worse; but 2 escaped review instead of 1 (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows are from the tuning packet, the last from all three. Across all three packets, time went from about 70 minutes to about 47, then to about 37; the rest of the drop from 70 came from the same-image re-read, which no longer finds a box for every field. Before caching, the main model had 4 answers fixed and none broken compared with the tuned pipeline without a second model.&lt;/p&gt;

&lt;p&gt;The extra escape with caching is the yes/no answer described below, and I can't say whether caching caused it. Finding the boxes is itself a whole-page request, so it gets the cache too, and caching moved 89 of the main model's 270 crop boxes, most by a few pixels. Other cached runs gave exactly the same boxes, so the moves come from caching itself, not chance. That answer's box was one of them, so the second model saw a slightly different image of it. Whether that is why it agreed would take repeat runs of the uncached setup, which I haven't done. I kept caching because none of the main model's answers got worse, and those repeat runs are next.&lt;/p&gt;

&lt;p&gt;Things I tried and dropped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;8 answers at once instead of 4:&lt;/strong&gt; 1% faster. The server was already saturated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No reading skills:&lt;/strong&gt; 6-11% faster, but one more wrong answer escaped review on the check packets. One answer is too few to call on its own, but the cause was specific: on a yes/no question whose own hint doesn't mention the underline rule, the model read it wrong either way; with the skill it was unsure enough to send it to review. I kept the skills as a safety call, not a measured win, and fixing that hint now would be a fix found on a check packet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speculative decoding&lt;/strong&gt; (a small "drafter" proposes tokens for the main model to check in one step): on synthetic test images a single request ran at 20-23 tokens/s instead of 15, but in the pipeline it was &lt;strong&gt;6.6% slower&lt;/strong&gt;. In the serving library, a running speculative batch takes no new requests until its longest reply ends, and with a drafter loaded the library refuses the structured JSON output Scribe relies on. Along the way I found a bug in the library's batched speculative decoding: when one reply in a batch finishes before the others, the replies after it can pick up another request's position offset if their images differ in size. I patched it locally and &lt;a href="https://github.com/Blaizzy/mlx-vlm/issues/2532" rel="noopener noreferrer"&gt;reported it upstream&lt;/a&gt;. I could show wrong offsets but not changed answers, and the report says so.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The two that got through
&lt;/h2&gt;

&lt;p&gt;Part 2 ended by auditing every answer that skipped review, so here are the two in the current setup.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A free-text answer with an extra letter.&lt;/strong&gt; The model added a plural "s" to one word of a two-word answer. The meaning is the same, but exact scoring counts it wrong, and the second model made the same misread, so nothing flagged it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A yes/no underline that ran under both words.&lt;/strong&gt; The line starts under one option and carries on under the other. By the rule in the reading skills, where the line starts decides; the model went by where it ended, with high confidence. In the run just before caching, the second model disagreed and the answer went to review; with caching it agreed, and it slipped through. That run is the only setup in the table that caught it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Funderline_spans_both.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Funderline_spans_both.png" alt="A synthetic yes/no question, " width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A synthetic illustration of this kind of mark: an invented question and a generated pen mark, drawn for this post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So one cosmetic error and one real flip, on a mark a person would hesitate over too. The fix I'd test next, on new packets rather than these: send any underline that spans both options straight to review.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this doesn't show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's three packets.&lt;/strong&gt; 531 answers is enough to see large effects, not small ones. Both models' answers shift a little when batching changes, and two of the changes here moved the main model's crops, so treat any one- or two-answer difference in this post as unresolved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The check packets aren't clean.&lt;/strong&gt; The schema and answer key were built from all three packets, the first experiment was scored on all three, and three later decisions used check-packet results: reverting the crops, reading only the written scores (the tick error counts behind it covered all three packets), and keeping the reading skills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Few writers.&lt;/strong&gt; Three packets can't show how this behaves across many writers and testers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not the held-out evaluation.&lt;/strong&gt; Part 1 set the targets (at least 98% accuracy on accepted answers, and at most 2% of blank answers getting an invented value past review) to be measured only on a private held-out set. These packets don't qualify, and the untuned baseline was already above 98% on the check packets, so this says more about the form than about the tuning.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What I'm taking away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tuning/checking split pays for itself, even a leaky one.&lt;/strong&gt; The clearest harmful change looked neutral on the packet I tuned on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the scorer, and look at what the model saw, before trusting a number.&lt;/strong&gt; The first "gain" was a scoring convention; the two worst bugs were silent until I saw the crops; and adding the second model and turning on caching both moved the main model's crops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read what is used&lt;/strong&gt;, and score before and after on the same set.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A second model should get a fair second look, not a harder question.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check speed work answer by answer&lt;/strong&gt;, including what the model was shown, and count a speed-up only under the batching you actually run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;
  The numbers, for engineers
  &lt;br&gt;
Every Scribe run used the same machine as parts 1 and 2. Main reader Qwen3.8-27B (8-bit), second model Qwen3-VL-8B (8-bit), served locally with mlx-vlm 0.6.15. 531 answers across 3 packets (one for tuning, two for checking), scored fairly (&lt;code&gt;[]&lt;/code&gt; = blank). &lt;em&gt;Accepted acc&lt;/em&gt; = accuracy of answers accepted without review. &lt;em&gt;Escaped&lt;/em&gt; = wrong answers that were not sent to review.

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Exact&lt;/th&gt;
&lt;th&gt;Accepted acc&lt;/th&gt;
&lt;th&gt;Wrong&lt;/th&gt;
&lt;th&gt;Escaped&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;th&gt;Min / packet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline (main model, crops by field type, no second model)&lt;/td&gt;
&lt;td&gt;93.2%&lt;/td&gt;
&lt;td&gt;97.5%&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;18.1%&lt;/td&gt;
&lt;td&gt;~52 (older, larger schema)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned (fixes above, written scores only; on the tuning packet, only the changed pages were re-run)&lt;/td&gt;
&lt;td&gt;95.3%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;17.3%&lt;/td&gt;
&lt;td&gt;~36 (estimate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ second model (re-reads from the other source; moved 129 crops)&lt;/td&gt;
&lt;td&gt;94.9%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;26.7%&lt;/td&gt;
&lt;td&gt;~70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+ same-image re-read, 4 at once, no page-type check, note cap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~47&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ prefix caching (current)&lt;/td&gt;
&lt;td&gt;96.2%&lt;/td&gt;
&lt;td&gt;99.5%&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.2%&lt;/td&gt;
&lt;td&gt;~37&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Part 1's second target counts invented answers (a value where the key has a blank) that escape review: 3 of 160 blank answers at baseline (1.9%), 0 in the last two rows. &lt;em&gt;Escaped&lt;/em&gt; in this post is broader: any wrong answer that skipped review.&lt;/p&gt;

&lt;p&gt;Check packets only (354 answers), exact / accepted acc / escaped: baseline 94.4% / 99.3% / 2; bold row 96.0% / 100.0% / 0 (of 275 accepted answers, which still allows a true error rate of about 1%); current 96.3% / 99.6% / 1.&lt;/p&gt;

&lt;p&gt;Paired comparisons (exact McNemar on the answers that changed): current vs baseline 16 fixed / 0 broken, p = 0.00003 (check packets only: 7 / 0, p = 0.016); bold row vs baseline 15 / 0, p = 0.00006 (check packets only: 6 / 0, p = 0.03); tuned vs baseline 12 / 1, p = 0.003 (check packets only: 5 / 1, p = 0.22); bold row vs tuned 4 / 0, p = 0.125; bold row vs the first second-model run 8 / 2, p = 0.11, with 7 of the 10 on crops that run had moved; skills vs no skills p = 0.73. Read these as rough: the all-packet figures include the packet the changes were chosen on, one fix often flips several answers on the same page, and I ran many comparisons.&lt;/p&gt;

&lt;p&gt;Prefix caching: the main model mixes two attention types, so mlx-vlm caches exact prompt snapshots, by default 16 tokens before the end of the prompt, a point no two questions share. A 4,000-token guard moves the snapshot to the end of the page image (cached prefix about 3,984 tokens; prompt processing for later whole-page questions about 6.6 s to 1.8 s). Cropped reads and the second model get no reuse. It isn't output-neutral, though: the main model's boxes moved for 89 of 270 cropped answers (median 2 px, largest 80 px), and 3 values changed.&lt;/p&gt;

&lt;p&gt;Other rounds: no JSON constraint on its own, no gain; speculative drafter 89% of drafted tokens accepted on the synthetic probe, +6.6% time vs the same setup without the JSON constraint and about +9% vs the current configuration.&lt;br&gt;
&lt;/p&gt;

&lt;br&gt;
&lt;p&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This is part 3 of a series.&lt;/strong&gt; Part 1: &lt;a href="https://dev.to/sohakanu/confidence-is-theater-benchmarking-nine-local-vlm-pipelines-on-handwritten-clinical-forms-38h6"&gt;&lt;em&gt;Confidence Is Theater: What I Learned Building Scribe&lt;/em&gt;&lt;/a&gt;. Part 2: &lt;a href="https://dev.to/sohakanu/one-imperfect-page-per-hundred-ambushing-our-pipeline-with-data-nobody-tuned-fh8"&gt;&lt;em&gt;One Imperfect Page per Hundred: Testing Scribe on Data I Didn't Curate&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pipeline code and the public benchmarks from parts 1 and 2: &lt;a href="https://github.com/kod201/scribe" rel="noopener noreferrer"&gt;github.com/kod201/scribe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every Scribe run in this post ran locally on one machine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>machinelearning</category>
      <category>llm</category>
    </item>
    <item>
      <title>One Imperfect Page per Hundred: Testing Scribe on Data I Didn't Curate</title>
      <dc:creator>Stephen Ohakanu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:30:25 +0000</pubDate>
      <link>https://dev.to/sohakanu/one-imperfect-page-per-hundred-ambushing-our-pipeline-with-data-nobody-tuned-fh8</link>
      <guid>https://dev.to/sohakanu/one-imperfect-page-per-hundred-ambushing-our-pipeline-with-data-nobody-tuned-fh8</guid>
      <description>&lt;p&gt;I tested Scribe on 100 handwritten prescriptions with annotations supplied by someone else. I had not used these pages to tune the pipeline.&lt;/p&gt;

&lt;p&gt;With the Qwen verifier, &lt;strong&gt;97 pages were sent for human review&lt;/strong&gt;. Of the three accepted automatically, two were correct and one missed a medicine. With the Gemma verifier, 99 pages went to review and the single page accepted automatically was correct.&lt;/p&gt;

&lt;p&gt;The review checks limited the number of unreviewed errors, but the pipeline still needed human help on almost every page. This post walks through what the run showed and what it did not establish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this started
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/sohakanu/confidence-is-theater-benchmarking-nine-local-vlm-pipelines-on-handwritten-clinical-forms-38h6"&gt;part 1&lt;/a&gt;, I built Scribe — a local pipeline that turns handwritten clinical forms into structured data and, more importantly, learned when &lt;em&gt;not&lt;/em&gt; to trust a model's answer. The headline was that model confidence carried no signal, and disagreement between two independent reads did.&lt;/p&gt;

&lt;p&gt;But every number in part 1 had a limitation.&lt;/p&gt;

&lt;p&gt;I curated the gold answers. I wrote the extraction schema. I tuned the prompts against the same 23 pages I was scoring on. Even done carefully, that means the system and the test were developed together.&lt;/p&gt;

&lt;p&gt;So I wanted a test set I had no hand in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The external test
&lt;/h2&gt;

&lt;p&gt;I found a &lt;a href="https://huggingface.co/datasets/chaithanyakota/100-handwritten-medical-records" rel="noopener noreferrer"&gt;public dataset of 100 handwritten Indian prescriptions&lt;/a&gt;, where the ground truth — the list of medicines on each page — was written by the dataset's author, not by me.&lt;/p&gt;

&lt;p&gt;The rules I set for myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the extraction schema gets written once, from the task description, before seeing results&lt;/li&gt;
&lt;li&gt;the prompts stay frozen exactly as they were in part 1&lt;/li&gt;
&lt;li&gt;the author's answer conventions are the answer key, not mine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things make this set difficult. The handwriting is real doctors' cursive — not the careful volunteer writing my benchmark was built from. And the drug names are Indian brand names the models have seen little of.&lt;/p&gt;

&lt;p&gt;That difficulty is useful: a system built for clinics in one country will eventually meet forms from another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading accuracy dropped sharply
&lt;/h2&gt;

&lt;p&gt;Brand names came back right 38% of the time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;CEPODEM&lt;/em&gt; became &lt;strong&gt;Capelin&lt;/strong&gt;. &lt;em&gt;ESOTAB&lt;/em&gt; became &lt;strong&gt;Esotrib&lt;/strong&gt;. &lt;em&gt;NIVEOLI&lt;/em&gt; became &lt;strong&gt;Ulfah Niveoli&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;These are the same kind of confident misreads that affected patient names in part 1, and they happened more often here: the handwriting is harder and the vocabulary is unfamiliar. (The two evaluations use different measures — field-level accuracy on the development set, brand-name recall here — so I'm not putting a before-and-after number on it.)&lt;/p&gt;

&lt;p&gt;I ran both of my best configurations, which differ only in which model does the second read. They produced &lt;strong&gt;identical reading results&lt;/strong&gt; — same primary model, so the underlying reading was unchanged; the verifier only affected how much got flagged.&lt;/p&gt;

&lt;p&gt;That repeats part 1's central lesson: &lt;strong&gt;verification cannot fix reading. It can only catch it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No prompt I own fixes the reading either. For the local vision models I tested, hard cursive remains a major limitation — and a &lt;a href="https://arxiv.org/abs/2604.16504" rel="noopener noreferrer"&gt;recent benchmark on South African maternity records&lt;/a&gt; reported similar difficulty even with frontier cloud models.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review gate held
&lt;/h2&gt;

&lt;p&gt;This is the part that mattered to me.&lt;/p&gt;

&lt;p&gt;On these harder pages, the models were much less willing to claim certainty: most confidence scores fell to around 0.60–0.70, and &lt;strong&gt;97–99% of pages were sent for human review.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For this input, "a person must look at this" &lt;em&gt;is&lt;/em&gt; the correct output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing the escapes
&lt;/h2&gt;

&lt;p&gt;With the Qwen verifier, three pages out of a hundred skipped human review. I checked each one by hand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two were correct — single-medicine prescriptions where the extracted medicine matched the annotation.&lt;/li&gt;
&lt;li&gt;One was an eleven-medicine page that got ten of them right and &lt;strong&gt;dropped one&lt;/strong&gt; (&lt;code&gt;BETNESOL INJ&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the imperfect page is one out of 100 pages tested — but also one out of only three pages accepted without review. Those two denominators tell different stories. The result shows the pipeline routed almost everything to a person; it does not demonstrate high accuracy among automatically accepted pages, because there were too few of them to say.&lt;/p&gt;

&lt;p&gt;With the Gemma verifier, one page was accepted automatically, and it was correct.&lt;/p&gt;

&lt;p&gt;Part 1 ended with a rule: &lt;em&gt;count what escapes, and you're grading the system.&lt;/em&gt; This is what that looks like in practice — a specific, audited count of unreviewed errors rather than a single accuracy number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test also caught a bug in my scorer
&lt;/h2&gt;

&lt;p&gt;One more thing this exercise caught — in my own tooling.&lt;/p&gt;

&lt;p&gt;My first scoring pass penalized extracted rows for containing dose schedules like &lt;code&gt;(1-0-1)&lt;/code&gt;, even when they named the right drug. The scorer's precision check compared token sets in the wrong direction. External data debugs your evaluator, not just your model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading accuracy didn't transfer to new handwriting; the review-gate behavior did.&lt;/strong&gt; The parts that held up were the system parts — confidence behavior, dual reads, the review queue. The reading itself did not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neither evaluation establishes performance on the intended target forms.&lt;/strong&gt; Part 1's benchmark is public data; this set is a different form type from a different country. The private held-out evaluation is what will measure the 98% target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A high flag rate is not failure here.&lt;/strong&gt; Flagging 97% of pages the models genuinely couldn't read is correct behavior. The bad outcome would have been a normal flag rate with wrong drug names passing through unreviewed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The held-out evaluation: real target-form registers, hand-filled, photographed, never published, created after every model's training cutoff. Those are the numbers that will actually count, and they'll be part 3 of this series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; part 3 turned out to be the tuning work that has to come first: &lt;a href="https://dev.to/sohakanu/one-packet-to-tune-two-to-check-tuning-scribe-without-fooling-myself-51m5"&gt;&lt;em&gt;One Packet to Tune, Two to Check&lt;/em&gt;&lt;/a&gt;. The held-out evaluation is still to come.&lt;/p&gt;

&lt;p&gt;And the checkbox problem from part 1 still needs its own detection stage, likely using the one model in my tests that produced no hallucinated values.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  The numbers, for engineers
  &lt;br&gt;
Both finalist configurations (same Qwen3.8-27B primary; Qwen3-VL-8B vs Gemma-3-12B verifier) against all 100 pages, scored on the medicines list:

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Qwen-8B verifier&lt;/th&gt;
&lt;th&gt;Gemma-3 verifier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strict page-level exact&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medicine token-match F1&lt;/td&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand-name recall&lt;/td&gt;
&lt;td&gt;38% (136/359)&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page flag rate&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two definitions of "correct" appear in this post, and they differ on purpose. &lt;em&gt;Strict page-level exact&lt;/em&gt; requires every extracted medicine to match the annotation's wording and order — so an extraction of "Drop VITAMIN D3 800 IU/ML (Depura)" fails against the annotation "DEPURA" even though it's the same medicine. The hand audit of auto-accepted pages, and the token-match metric, count a medicine as found when the brand name matches (60% gold-token coverage), which is why the audit reports pages as correct that the strict metric does not. Full scoring code: &lt;a href="https://github.com/kod201/scribe/blob/main/scripts/eval_rx100.py" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/eval_rx100.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Dataset: &lt;a href="https://huggingface.co/datasets/chaithanyakota/100-handwritten-medical-records" rel="noopener noreferrer"&gt;100-handwritten-medical-records&lt;/a&gt; (CC-BY-ND-4.0), evaluated as-is and not redistributed.&lt;br&gt;
&lt;/p&gt;

&lt;br&gt;
&lt;p&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This is part 2 of a series.&lt;/strong&gt; Part 1: &lt;a href="https://dev.to/sohakanu/confidence-is-theater-benchmarking-nine-local-vlm-pipelines-on-handwritten-clinical-forms-38h6"&gt;&lt;em&gt;Confidence Is Theater: What I Learned Building Scribe&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Code and methodology: &lt;a href="https://github.com/kod201/scribe" rel="noopener noreferrer"&gt;github.com/kod201/scribe&lt;/a&gt; · My own benchmark gold set: &lt;a href="https://huggingface.co/datasets/sohakanu/scribe-flow-gold" rel="noopener noreferrer"&gt;sohakanu/scribe-flow-gold&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All extraction ran locally on one machine; no page left it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Confidence Is Theater: What I Learned Building Scribe</title>
      <dc:creator>Stephen Ohakanu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:29:54 +0000</pubDate>
      <link>https://dev.to/sohakanu/confidence-is-theater-benchmarking-nine-local-vlm-pipelines-on-handwritten-clinical-forms-38h6</link>
      <guid>https://dev.to/sohakanu/confidence-is-theater-benchmarking-nine-local-vlm-pipelines-on-handwritten-clinical-forms-38h6</guid>
      <description>&lt;p&gt;I'm building Scribe, a local pipeline that turns handwritten clinical forms into structured data.&lt;/p&gt;

&lt;p&gt;The obvious problem is reading the handwriting.&lt;/p&gt;

&lt;p&gt;The harder problem turned out to be this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you know when the model is wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first idea was simple: ask the model how confident it was.&lt;/p&gt;

&lt;p&gt;That failed almost immediately.&lt;/p&gt;

&lt;p&gt;Median confidence when the answer was correct: &lt;strong&gt;0.95&lt;/strong&gt;.&lt;br&gt;
Median confidence when the answer was wrong: &lt;strong&gt;0.95&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So confidence wasn't giving me a useful signal.&lt;/p&gt;

&lt;p&gt;That sent the project in a different direction: instead of asking a model whether I should trust it, I started comparing two independent reads of each field and treating disagreement as the signal to send it to a person.&lt;/p&gt;

&lt;p&gt;This is the engineering log of what happened next.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Scribe does
&lt;/h2&gt;

&lt;p&gt;Scribe takes scanned or photographed handwritten clinical forms and converts them into structured records.&lt;/p&gt;

&lt;p&gt;The pipeline runs entirely on one machine. That matters for environments where connectivity is unreliable or the data should not leave the device.&lt;/p&gt;

&lt;p&gt;Each extracted value keeps a link back to where it came from on the page. If the system is not comfortable with a value, it sends that field for human review instead of quietly writing it into the record.&lt;/p&gt;

&lt;p&gt;The basic flow is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;page → find fields → read → validate → second read → human review if needed → structured record&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are more technical names for those stages, but that is really what the system is doing.&lt;/p&gt;
&lt;h2&gt;
  
  
  A quick note about the numbers
&lt;/h2&gt;

&lt;p&gt;I tested nine pipeline configurations on the same 23 handwritten pages and the same 221 hand-curated field values.&lt;/p&gt;

&lt;p&gt;The dataset used for this development bench is &lt;a href="https://huggingface.co/datasets/Nigeria-Health-data-OCR-pipeline/African-Medical-Records" rel="noopener noreferrer"&gt;public&lt;/a&gt;, which means some models may have seen similar material during training.&lt;/p&gt;

&lt;p&gt;So I'm treating these numbers as engineering comparisons between configurations, not as claims about the absolute performance of the models.&lt;/p&gt;

&lt;p&gt;Production sign-off will use a private held-out set created after model training cutoffs.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Whole page or crop?
&lt;/h2&gt;

&lt;p&gt;One of the first questions was whether the model should read the whole page or just the area containing the field.&lt;/p&gt;

&lt;p&gt;Both approaches had problems.&lt;/p&gt;

&lt;p&gt;Reading the whole page preserved context. Names, IDs and header-labeled fields were easier to interpret.&lt;/p&gt;

&lt;p&gt;But the extra context also created hallucinations. A model looking for a document date could "borrow" another date from a nearby table.&lt;/p&gt;

&lt;p&gt;Reading only a crop fixed some of that. For example, document-date hallucination dropped from 67% to 17%.&lt;/p&gt;

&lt;p&gt;But crops sometimes removed too much context. Hospital-number accuracy, for example, dropped from 87% to 65%.&lt;/p&gt;

&lt;p&gt;So I stopped trying to choose one.&lt;/p&gt;

&lt;p&gt;Scribe now routes by field type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tables, dates, enums and checkbox groups → crop&lt;/li&gt;
&lt;li&gt;scalars and free text → whole page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That hybrid approach beat both pure modes on the combined quality measures and became the default.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Confidence didn't work
&lt;/h2&gt;

&lt;p&gt;The next problem was deciding which extracted values could safely skip human review. I initially planned to use the model's own confidence score for this, but as the opening numbers showed, confidence did not separate correct answers from mistakes on this test: even badly misread handwritten names came back at 0.95, and there was no threshold worth tuning. So instead of asking the model how sure it was, I tried reading each field a second time and flagging the ones where the two reads disagreed.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. A second read helped much more than confidence
&lt;/h2&gt;

&lt;p&gt;The first verification approach simply re-read every field that would otherwise have been accepted automatically.&lt;/p&gt;

&lt;p&gt;The second read used the complementary source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;first read from crop → verify from whole page&lt;/li&gt;
&lt;li&gt;first read from whole page → verify from crop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the two reads disagreed, the field went to human review.&lt;/p&gt;

&lt;p&gt;That one change moved the accuracy of automatically accepted fields from 79% to 93%, while silent errors dropped from 21% to 7%.&lt;/p&gt;

&lt;p&gt;The underlying model had not suddenly become a better reader.&lt;/p&gt;

&lt;p&gt;The pipeline had become better at recognizing when it should not trust the answer.&lt;/p&gt;

&lt;p&gt;That became the main design principle of Scribe.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Then I tried a different model for the second read
&lt;/h2&gt;

&lt;p&gt;There was still an obvious problem.&lt;/p&gt;

&lt;p&gt;If the same model has the same bias twice, two reads can agree on the same wrong answer.&lt;/p&gt;

&lt;p&gt;So I switched the verifier to a smaller, different model.&lt;/p&gt;

&lt;p&gt;That moved auto-accepted accuracy again, from 93% to 95%.&lt;/p&gt;

&lt;p&gt;More importantly, confidently misread patient names were now caught before they passed through automatically.&lt;/p&gt;

&lt;p&gt;The smaller model was not necessarily a better reader.&lt;/p&gt;

&lt;p&gt;Sometimes it was useful precisely because it was wrong in a different way.&lt;/p&gt;

&lt;p&gt;That was enough to surface disagreements.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Some mistakes were shared by nearly everyone
&lt;/h2&gt;

&lt;p&gt;The most stubborn example involved checkboxes.&lt;/p&gt;

&lt;p&gt;One synthetic form contained a lab-request section where nothing was checked. The correct answer was null.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fcheckbox_trap_syn003.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fcheckbox_trap_syn003.png" alt="An untouched checkbox row: Specimen type — Blood, Urine, Stool, CSF, all boxes empty" width="800" height="75"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Several model families still confidently chose an option.&lt;/p&gt;

&lt;p&gt;Qwen selected &lt;code&gt;"blood"&lt;/code&gt;. GLM showed the same kind of behavior. Gemma interpreted an empty box as a tick.&lt;/p&gt;

&lt;p&gt;Cross-model verification did not help much because both readers could share the same bias.&lt;/p&gt;

&lt;p&gt;That changed the engineering plan. Checkboxes stopped being treated as "just another prompt." They became their own detection problem.&lt;/p&gt;

&lt;p&gt;Later, Muse Glimmer produced an interesting counterexample: it hallucinated nothing in that run, but performed much worse overall. That makes it potentially useful as a specialist rather than as the main reader.&lt;/p&gt;

&lt;p&gt;That was a useful reminder:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second model only helps when the models fail differently.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Changing the verifier eventually stopped helping
&lt;/h2&gt;

&lt;p&gt;After the initial gains, I tested several different verifier families.&lt;/p&gt;

&lt;p&gt;GLM-4.5V. Gemma 3. Gemma 4.&lt;/p&gt;

&lt;p&gt;They all ended up within roughly two fields of the small Qwen verifier on this 221-value set.&lt;/p&gt;

&lt;p&gt;GLM was actually worse operationally because it sent far more fields to review without improving the overall result much.&lt;/p&gt;

&lt;p&gt;That told me something useful. The next improvement probably wasn't hiding in yet another verifier.&lt;/p&gt;

&lt;p&gt;The remaining gains were more likely to come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a better primary model&lt;/li&gt;
&lt;li&gt;explicit checkbox detection&lt;/li&gt;
&lt;li&gt;resolving a few annotation conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I stopped testing new verifiers.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. The metric I care about changed too
&lt;/h2&gt;

&lt;p&gt;At first I tracked hallucination as one number: did the model invent a value where the correct answer was empty?&lt;/p&gt;

&lt;p&gt;Later I split that into two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did the model invent something?&lt;/li&gt;
&lt;li&gt;Did that invention escape all the safety checks?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second one matters much more to the final system.&lt;/p&gt;

&lt;p&gt;For the routed pipeline without verification, 12% of null fields were invented and 12% escaped.&lt;/p&gt;

&lt;p&gt;With cross-model verification, the model still invented values, but only 2% escaped.&lt;/p&gt;

&lt;p&gt;That distinction changed how I think about evaluation.&lt;/p&gt;

&lt;p&gt;If you only measure raw hallucination, you are grading the model.&lt;/p&gt;

&lt;p&gt;If you measure what survives validation, verification and review, you are grading the system.&lt;/p&gt;

&lt;p&gt;And the system is what actually goes into production.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the bench ended up
&lt;/h2&gt;

&lt;p&gt;Across the nine configurations, raw exact accuracy only moved from roughly 75% to 81%.&lt;/p&gt;

&lt;p&gt;But the accuracy of fields allowed to bypass human review reached 96% in the current default configuration.&lt;/p&gt;

&lt;p&gt;That is the result I find most interesting.&lt;/p&gt;

&lt;p&gt;The project did not succeed because I found a model that suddenly became perfect at handwriting.&lt;/p&gt;

&lt;p&gt;It improved because the surrounding pipeline got better at recognizing when the model should not be trusted.&lt;/p&gt;
&lt;h2&gt;
  
  
  What still needs to be tested
&lt;/h2&gt;

&lt;p&gt;There are still three important open questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contamination.&lt;/strong&gt; The development corpus is public, so the next evaluation needs data the models could not have seen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production targets.&lt;/strong&gt; The target is at least 98% accuracy on automatically accepted fields and no more than 2% escaped hallucination, measured only on the private held-out set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generalization.&lt;/strong&gt; This one already has a first answer: in &lt;a href="https://dev.to/sohakanu/one-imperfect-page-per-hundred-ambushing-our-pipeline-with-data-nobody-tuned-fh8"&gt;part 2 of this series&lt;/a&gt;, I ran the two best configurations against 100 independently labeled handwritten prescription pages I had no hand in. Reading accuracy dropped sharply; the review system held.&lt;/p&gt;

&lt;p&gt;So the next useful number should come from genuinely unseen data, not from squeezing another decimal point out of this development set.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'm taking away from it
&lt;/h2&gt;

&lt;p&gt;A few things changed my thinking while building Scribe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model confidence was not useful for deciding whether an answer was safe&lt;/li&gt;
&lt;li&gt;disagreement between independent reads was much more useful&lt;/li&gt;
&lt;li&gt;crops and whole-page context solve different problems&lt;/li&gt;
&lt;li&gt;changing models does not fix every failure mode&lt;/li&gt;
&lt;li&gt;some problems need deterministic engineering rather than another prompt&lt;/li&gt;
&lt;li&gt;the most important hallucinations are the ones that escape the system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scribe now runs locally, and new form types are defined through schema YAML rather than custom extraction code. The evaluation setup also keeps model revisions, provenance and review decisions tied to each result.&lt;/p&gt;

&lt;p&gt;I'm still testing it.&lt;/p&gt;

&lt;p&gt;But the central lesson so far is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal is not to build a model that never makes mistakes. It is to build a system that knows when a model's answer needs another look.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  The full scoreboard, for engineers
  &lt;br&gt;
All nine configurations, same 23 pages, same 221-value gold set. &lt;em&gt;Trusted-output acc&lt;/em&gt; = accuracy of fields accepted without human review. &lt;em&gt;Escaped&lt;/em&gt; = invented values that passed every check.

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Primary&lt;/th&gt;
&lt;th&gt;Verifier&lt;/th&gt;
&lt;th&gt;s/field&lt;/th&gt;
&lt;th&gt;Exact&lt;/th&gt;
&lt;th&gt;Trusted-output acc&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;th&gt;Escaped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whole-page&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;9.8&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crop-all&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;td&gt;71%&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;routed&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;6.4&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;td&gt;14%&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ same-model verify&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;itself&lt;/td&gt;
&lt;td&gt;9.9&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ cross-model verify&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;Qwen3-VL-8B&lt;/td&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;34%&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM verifier&lt;/td&gt;
&lt;td&gt;Qwen3-VL-30B-A3B&lt;/td&gt;
&lt;td&gt;GLM-4.5V&lt;/td&gt;
&lt;td&gt;11.9&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;new primary (shipping)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Qwen3.8-27B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Qwen3-VL-8B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 verifier&lt;/td&gt;
&lt;td&gt;Qwen3.8-27B&lt;/td&gt;
&lt;td&gt;Gemma-3-12B&lt;/td&gt;
&lt;td&gt;19.8&lt;/td&gt;
&lt;td&gt;81%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 verifier&lt;/td&gt;
&lt;td&gt;Qwen3.8-27B&lt;/td&gt;
&lt;td&gt;Gemma-4-12B&lt;/td&gt;
&lt;td&gt;20.0&lt;/td&gt;
&lt;td&gt;81%&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A tenth run (Muse Glimmer 30B as primary) read worse across the board (74% exact, 42% flags) but invented nothing: 0% hallucination.&lt;/p&gt;

&lt;p&gt;The pipeline in one picture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fpipeline.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fdatasets%2Fsohakanu%2Fscribe-article-assets%2Fresolve%2Fmain%2Fpipeline.png" alt="Scribe pipeline: locate fields, read each from crop or page by type, validate, second-model check, then human review or record" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hardware, pinned model revisions, per-field tables, metric definitions and one documented restatement: &lt;a href="https://github.com/kod201/scribe/blob/main/docs/bench.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/bench.md&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;br&gt;
&lt;p&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;This is part 1 of a series.&lt;/strong&gt; Part 2: &lt;a href="https://dev.to/sohakanu/one-imperfect-page-per-hundred-ambushing-our-pipeline-with-data-nobody-tuned-fh8"&gt;&lt;em&gt;One imperfect page per hundred&lt;/em&gt;&lt;/a&gt; — testing Scribe on an external prescription dataset.&lt;/p&gt;

&lt;p&gt;Code and methodology: &lt;a href="https://github.com/kod201/scribe" rel="noopener noreferrer"&gt;github.com/kod201/scribe&lt;/a&gt; · Benchmark gold set: &lt;a href="https://huggingface.co/datasets/sohakanu/scribe-flow-gold" rel="noopener noreferrer"&gt;sohakanu/scribe-flow-gold&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every name on every sample page shown or discussed is fictional. All extraction ran locally on one machine; no page left it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
