<?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: Hideki Mori</title>
    <description>The latest articles on DEV Community by Hideki Mori (@hidekimori).</description>
    <link>https://dev.to/hidekimori</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%2F3903757%2F5d1a2986-7f25-4c35-b5e8-4d489fc18a94.png</url>
      <title>DEV Community: Hideki Mori</title>
      <link>https://dev.to/hidekimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hidekimori"/>
    <language>en</language>
    <item>
      <title>Translation was a special case all along</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/translation-was-a-special-case-all-along-2j34</link>
      <guid>https://dev.to/hidekimori/translation-was-a-special-case-all-along-2j34</guid>
      <description>&lt;p&gt;I look after one of the services at a company that's been translating things for forty years. The engine behind it now reads medical records and contracts and hands them back as structured JSON — fields you can drop straight into a database.&lt;/p&gt;

&lt;p&gt;People hear that and assume it's a pivot. A translation company that bolted on a document-AI product to chase the moment.&lt;/p&gt;

&lt;p&gt;It wasn't a pivot. It was translation, taken too seriously to stop at the obvious.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that was always there
&lt;/h2&gt;

&lt;p&gt;Localization has a quiet habit most people outside it never notice. Whatever format the work arrives in — Word, Excel, PDF, subtitles, a plain text file — you don't process the format. You convert it into one neutral interchange format, do the language work on that, and convert it back. The standard for that interchange has existed for years; the industry settled it long ago.&lt;/p&gt;

&lt;p&gt;So "handle any document format" was never something we had to invent. It was the floor we already stood on. Plain text isn't special in that world — it's just one more format, the one with a &lt;code&gt;.txt&lt;/code&gt; on the end. A PDF and a single sentence go through the same door.&lt;/p&gt;

&lt;p&gt;I want to be clear that this came first, and from the industry, not from us. It matters for the rest of the story.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that actually changed
&lt;/h2&gt;

&lt;p&gt;On top of that floor, translation did one narrow thing: take a chunk of source-language text, hand back the same meaning in another language.&lt;/p&gt;

&lt;p&gt;For years we did that by wiring up machine-translation engines — each one a dedicated language-mapping machine and nothing else. Then generative AI arrived, and the realization was small and total at the same time. This new kind of engine didn't map languages; it took an instruction. Translation is just this: here is some text, here is an instruction, return the result. "Translate to Japanese" is one instruction. "Fix the grammar" is another. "Make this more formal." "Summarize it." The engine was never really translating. It was applying an instruction to a segment and giving the segment back.&lt;/p&gt;

&lt;p&gt;Once you see that, "translation" stops being the thing the engine does — it becomes one value of a parameter. What we had been calling translation was a narrower operation that had been living inside a much larger one all along. We'd just never had a reason to name the larger one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The target stopped being a language
&lt;/h2&gt;

&lt;p&gt;The next step was smaller and stranger. If the engine only applies an instruction, the result doesn't have to be text in another language. It can be a shape you define.&lt;/p&gt;

&lt;p&gt;It's easier to see than to say. Inside, a unit of work is just a source and a target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No prior history of diabetes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask it to translate, and the target comes back as a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No prior history of diabetes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"糖尿病の既往なし。"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask it to structure, and the request is identical. The only thing that changes is the shape I let &lt;code&gt;target&lt;/code&gt; hold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No prior history of diabetes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"diabetes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"history"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same envelope, same engine. The one thing that moved is what &lt;code&gt;target&lt;/code&gt; is allowed to be — a string, or a structure I defined.&lt;/p&gt;

&lt;p&gt;That is still the operation translation was always performing: moving meaning from one form into another. The target "language" is a schema now instead of Japanese. Internally we eventually gave this capability a name — StructFlow — but the engine never changed to earn it. We just injected a schema where the target text used to go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the two meet
&lt;/h2&gt;

&lt;p&gt;Here is the part that still feels like a small trick.&lt;/p&gt;

&lt;p&gt;Because the format-agnostic floor was already there — any document in, any document out — the moment the operation became "give me a structure," it could structure anything. A scanned contract, a spreadsheet of customer reviews, a Word file: all of it was already being turned into neutral segments to be worked on. Now those segments could come back as structured data instead of a translation.&lt;/p&gt;

&lt;p&gt;We didn't build a document-structuring product. We pointed a forty-year-old pipe at a new instruction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two things keep it honest
&lt;/h2&gt;

&lt;p&gt;It would be easy to dress this up after the fact. Two things stop me.&lt;/p&gt;

&lt;p&gt;The first: structuring a contract and translating a sentence run the same code. There is no translation engine and a separate structuring engine inside. There is one engine that takes a segment and an instruction, and the instruction and the output shape are the only things that differ.&lt;/p&gt;

&lt;p&gt;The second is my favorite. We have a feature that refines a finished translation — pass after pass, catching the mistranslations and the dropped clauses, leaving a note on each change. It used to run on its own hand-written prompts. We rebuilt it on the structuring engine; now it is that engine, called up to six times — each pass hands it a segment and asks for a structured result: the revised translation, plus a note. The oldest thing we do, translation, now runs on top of the newest. The origin sits on the destination.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;None of this was on a roadmap. Nobody decided to enter the document-AI market. We took one definition seriously — translation is moving meaning from one form into another — and refused to stop at the form everyone expects.&lt;/p&gt;

&lt;p&gt;Generalize the thing you actually do, far enough, and you don't get a better version of that thing. You get a different one — and if you're lucky, you reach it standing on infrastructure someone already built and proved, so it costs almost nothing.&lt;/p&gt;

&lt;p&gt;Forty years of translation will make any company look like a translation company. But translation was only ever a special case of something larger — moving meaning from one form into another — and it was just the first market anyone had found for it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>ai</category>
      <category>startup</category>
    </item>
    <item>
      <title>The cheap tier doesn't go blank — it writes</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-cheap-tier-doesnt-go-blank-it-writes-5aoo</link>
      <guid>https://dev.to/hidekimori/the-cheap-tier-doesnt-go-blank-it-writes-5aoo</guid>
      <description>&lt;p&gt;Here is the bank block that &lt;code&gt;azure/gpt-5.6-sol@low&lt;/code&gt; returned for a Japanese invoice rendered at 300 dpi — a document sharp enough that you can count the pixels in the 7.5 pt fine print:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"bank_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"みずほ銀行"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"bank_branch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"本店営業部"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"account_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of it is on the page. The printed bank is ほしかげ信用金庫 — a fictional credit union invented for a benchmark, with no real-world counterpart. The printed account number is seven digits that are not 1234567. The model didn't misread any of this. At its resolution tier it cannot see the fine print at all — and instead of leaving the fields blank, it wrote them.&lt;/p&gt;

&lt;p&gt;That's the article. The rest is counting how often, and what gets written.&lt;/p&gt;




&lt;h2&gt;
  
  
  The control column
&lt;/h2&gt;

&lt;p&gt;Last week I published &lt;a href="https://dev.to/hidekimori/reading-under-the-stamp-57bi"&gt;a benchmark about a red seal covering an invoice field&lt;/a&gt;. Every occlusion ladder needs a control: L0, the step where the seal sits clear of everything and the document is simply a razor-sharp invoice. Twenty-seven model variants read that control — four materials, five repeats, twenty reads per field per model. I built it to be the boring column.&lt;/p&gt;

&lt;p&gt;The boring column turned out to contain its own article, because it is the cleanest measurement I have of a question &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;the earlier pieces&lt;/a&gt; only saw at an angle: what does a low-detail image tier actually read, when nothing whatsoever is wrong with the input?&lt;/p&gt;




&lt;h2&gt;
  
  
  What &lt;a class="mentioned-user" href="https://dev.to/low"&gt;@low&lt;/a&gt; reads: gates, not dials
&lt;/h2&gt;

&lt;p&gt;The invoice has thirteen document fields across four font tiers — a 28 pt title, large fields like the total and invoice number, 10.5 pt body fields, 7.5 pt bank details. Score each field out of twenty for each of the ten &lt;code&gt;@low&lt;/code&gt; variants and a pattern appears that I did not expect to be this clean: &lt;strong&gt;113 of the 130 cells are exactly 0 or exactly 20.&lt;/strong&gt; A field is read every time, or never. "Unreliable" turns out to be the wrong mental model for this tier — reliability implies a dial. These are gates.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjskydfrsqdbl0zry3vhr.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjskydfrsqdbl0zry3vhr.png" alt="What each @low variant reads on a razor-sharp invoice: white = read every time, dark = never" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which gates are open depends on the generation, and the direction is the uncomfortable one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;GPT-5.5 and 5.6 generation&lt;/strong&gt; &lt;code&gt;@low&lt;/code&gt; variants read exactly four things: the title, and the three boxed money figures — total, subtotal, tax. Every name, every date, the invoice number, every bank detail: zero out of twenty (a couple of the lighter variants wobble to 18–19 on the money, nothing more).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;GPT-5.4 generation&lt;/strong&gt; at the same tier reads more — the invoice number at 20/20, the due date at 19–20 — and it is also the only place in the table with a genuine transition band: issue date at 10 and 17, issuer name at 5 and 9, counterparty at 11. The older generation has a probabilistic middle; the newer one has a cliff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3.5 Flash &lt;code&gt;@low&lt;/code&gt;&lt;/strong&gt; reads all thirteen fields at twenty out of twenty, including the 7.5 pt bank block, at 16 credits per page. &lt;code&gt;azure/gpt-5.6-sol@low&lt;/code&gt; costs 76 per page — 4.75× the price — and reads four fields. At the cheap end of the catalog, price does not order capability. It doesn't even correlate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote in the fabrication piece that the newer generations are stronger generators and weaker witnesses. The per-field table adds a quieter observation: at the low tier, the newer generation also simply &lt;em&gt;reads less&lt;/em&gt; — a capability regression that no headline benchmark will ever show, because headline benchmarks don't run the cheap variants.&lt;/p&gt;




&lt;h2&gt;
  
  
  What fills the other nine fields
&lt;/h2&gt;

&lt;p&gt;So a 5.6-generation &lt;code&gt;@low&lt;/code&gt; read of this invoice has four real fields and nine unreadable ones. The question that matters operationally is what arrives in the nine.&lt;/p&gt;

&lt;p&gt;Blanks would be fine. Blanks are honest. Across 1,120 reads of eight of those nine fields — the ninth, the bank branch, gets its own section below — the models returned a blank &lt;strong&gt;181 times&lt;/strong&gt;. They returned an invented value &lt;strong&gt;938 times&lt;/strong&gt; — an 84% fabrication rate, on a perfectly sharp document. Per response, that is on average 6.7 written fields and 1.3 blanks.&lt;/p&gt;

&lt;p&gt;And the inventions are not noise. They are the statistics of Japanese paperwork:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bank.&lt;/strong&gt; The fictional credit union came back as one of Japan's three megabanks in &lt;strong&gt;98 of the 101&lt;/strong&gt; runs that invented a bank at all — みずほ 54 times, 三井住友 41, 三菱UFJ 3. (One run answered メガバンク銀行 — "Megabank Bank" — which at least has the honesty of a placeholder.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The account number.&lt;/strong&gt; It came back as the literal &lt;strong&gt;1234567&lt;/strong&gt; in 88 of 140 reads, with or without a 普通 prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dates.&lt;/strong&gt; Of the 250 invented, &lt;strong&gt;231 said 2025&lt;/strong&gt; on a document that says 2026 — the same systematic year shift the fabrication article found, reproducing here on pristine input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The counterparty.&lt;/strong&gt; 有限会社ミナト設計 became 株式会社ミナト交通: the distinctive word survived as a silhouette, the rest was regularized to the most common corporate form. Right shape, wrong document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The earlier pieces each caught one face of this. The fabrication article showed the mechanism — when reading fails, generation fills the gap. &lt;a href="https://dev.to/hidekimori/the-model-corrected-reality-fob"&gt;The prior-capture piece&lt;/a&gt; showed the gravity — a &lt;em&gt;partially&lt;/em&gt; legible name drifts to its nearest real neighbor. This column shows the limit case: at zero legibility there is no neighbor to drift to, and the model doesn't need one. It answers with the mode of the entire category. Any Japanese invoice — therefore みずほ銀行, 本店営業部, seven ascending digits, and last year.&lt;/p&gt;

&lt;p&gt;The older generation, for what it's worth, blanks about twice as readily: one blank per 2.6 inventions, versus one per 5.2 for the new one. Progress, in this corner of the catalog, has meant becoming a more confident author of other people's invoices.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cell that lied by being right
&lt;/h2&gt;

&lt;p&gt;Which brings me to the most instructive mistake in my own results table.&lt;/p&gt;

&lt;p&gt;Six times, a 5.5/5.6 &lt;code&gt;@low&lt;/code&gt; variant scored &lt;em&gt;correct&lt;/em&gt; on the bank branch — the only fine-print field that ever flickered on for them. For a day I had it filed as a curiosity: maybe branch names render heavier, maybe the position helps. Then I looked at the six responses. In every one of them, the bank name in the same JSON was fabricated — みずほ銀行 or 三井住友銀行, banks that are not on the page. The branch wasn't read either. It was invented along with the rest of the block, and the invention collided with the truth, because the printed branch is 本店営業部 — the single most common branch name in Japan.&lt;/p&gt;

&lt;p&gt;My scorer cannot see that. Nothing inside one field can. Six of the "correct" cells in this benchmark are, on the co-occurrence evidence, fabrications that happen to be true.&lt;/p&gt;

&lt;p&gt;This series has leaned hard on fictional ground truth — unguessable values, so that a right answer proves reading. The branch field is where that doctrine shows its edge: fictional ground truth only works if the fictional value isn't the category's mode. 本店営業部 was a real, maximally common phrase, and it turned one cell per model into a coin the model didn't even know it was flipping. The next version of this benchmark gets a fictional branch name, for the same reason the bank got one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Blank or fiction
&lt;/h2&gt;

&lt;p&gt;Put the two findings side by side and the operational picture is stark. The four fields a 5.6-gen &lt;code&gt;@low&lt;/code&gt; actually reads are the title and the money triple — precisely the fields every automated validation looks at. The arithmetic reconciles because it was &lt;em&gt;read&lt;/em&gt;. The names, dates, and bank details wrapped around that true arithmetic are, five times out of six, authored. A document that is half real is the hardest kind to distrust, and at this tier it isn't a degradation mode. It's the product.&lt;/p&gt;

&lt;p&gt;The classification result from the earlier study still stands — at roughly 300 tokens a page these models see the title tier reliably, which makes &lt;code&gt;@low&lt;/code&gt; a genuinely good routing gate. My catalog sentence for these variants says text read from images is &lt;em&gt;unreliable&lt;/em&gt; at this resolution. After this column I'd sharpen the word: not unreliable — &lt;strong&gt;authored&lt;/strong&gt;. Unreliable suggests you'll get a noisy version of your document. What you get is a fluent version of the average document, with your totals attached.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Postscript (July 18): The mixed-rate follow-up that the stamp article promised has now run, and its result reaches back into this article. On a sibling invoice where the tax is not a round 10% — so total ÷ 1.1 no longer equals the printed subtotal — the same &lt;code&gt;@low&lt;/code&gt; variants return total ÷ 1.1 for the subtotal and total − total ÷ 1.1 for the tax, at every occlusion level, including zero. On that evidence, two of the four fields I counted as read above were most likely never read here either: they were derived from the total under a flat-10% assumption that this document's round tax rate made indistinguishable from reading. What a 5.6-generation &lt;code&gt;@low&lt;/code&gt; reads on this invoice may be two things, not four — the title, and the total. The operational conclusion gets stronger, not weaker: the arithmetic doesn't reconcile because it was read. It reconciles because two of its three numbers were manufactured from the third. The harness and the recorded results are public at &lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/hanko-benchmark-v2" rel="noopener noreferrer"&gt;ldxhub-io/examples › analyzedoc/hanko-benchmark-v2&lt;/a&gt;. Full write-up to follow.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Method notes: this is the L0 (zero-occlusion) slice of the seal benchmark — 540 of its 3,240 jobs — read from the same scored output; twenty reads per model per field, criteria frozen in code before the run. The harnesses, ground truth, and run summaries are public at &lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/hanko-benchmark" rel="noopener noreferrer"&gt;ldxhub-io/examples › analyzedoc/hanko-benchmark&lt;/a&gt;; the per-field analysis script behind this article ships in the same directory. Provider vision pipelines change — re-run before trusting any of this for anything current.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>Two people who never bent — and what I learned from them</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 03 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/two-people-who-never-bent-and-what-i-learned-from-them-21jg</link>
      <guid>https://dev.to/hidekimori/two-people-who-never-bent-and-what-i-learned-from-them-21jg</guid>
      <description>&lt;p&gt;In an earlier piece I mentioned a CEO at one of my earlier companies who once looked at a service running on my desktop and asked whether I could make it public, right then — as it was, that afternoon. I half-dismissed him at the time. I said it was a story for another time.&lt;/p&gt;

&lt;p&gt;This is that story. Though it turns out to be less about him than about what it does to a person to spend years in a room with two people who refused to bend.&lt;/p&gt;




&lt;p&gt;He could not read the code. He never pretended otherwise. What he could do — what he did, every single time something started to work — was figure out where to go and sell it.&lt;/p&gt;

&lt;p&gt;The company had grown out of a mobile-phone business that went into decline almost as quickly as it had risen. The next thing he reached for failed, and not quietly. Then one category started to move, and while it was still moving he was already gone, opening the next one alone. One of those eventually became the thing the company was known for. Another never took at all. He was rarely in the present tense.&lt;/p&gt;

&lt;p&gt;For a long time I read that as restlessness. Later I understood it was the same engine that runs me — the one that cannot sit still once a thing works, that is already asking what comes next before the current thing has cooled. He had simply pointed it at selling instead of building. The salesperson version of the same engine.&lt;/p&gt;

&lt;p&gt;He spent on the technical side without flinching when it counted. We started on MySQL, outgrew it, moved to Oracle Enterprise Edition, and later put the whole thing on Exadata — none of which he understood, all of which he approved, because the engineers told him it was what the next stage needed. He gave vendors a hard time when they earned it. I remember one storage system sold to us on the promise that it scaled: you simply added another unit when you needed more room. When the day came to add one, the exact model had been discontinued, and the replacement would not sit alongside what we already had. We ended up rebuilding the whole configuration. He was not gentle about that, and he was right not to be.&lt;/p&gt;

&lt;p&gt;What he never had was the inside of the machine. What he always had was the one question that mattered to him: what is the thing here that no one else has? The structure that reshaped itself instead of being rebuilt. The numbers that were live instead of a day stale. The parts that did not fall over under load. He could not have written a line of it. But he always knew which part was the sentence you could sell, and he kept that sentence in his head, ready.&lt;/p&gt;

&lt;p&gt;None of it was something he was born with. I watched it get made. It came out of the venture that failed and the stretch where money was tight enough that the company nearly went under. He was not, when I first met him, a person you would have called a hard worker. The shortage changed him. By the time it was behind us he had taught himself to find the one true selling point in anything — and that skill was scar tissue, the same as anyone's.&lt;/p&gt;




&lt;p&gt;The CFO held his line just as hard, in a quieter register. He carried risk that a job title does not capture — the kind you take on with your own name attached, when the company you believe in is closer to the edge than anyone outside the room can see. The specifics are his, not mine, and they are not going on this page. But I watched him keep the numbers that should have kept us all awake off our desks, and carry them himself — the company's survival was never someone else's problem to solve. It was his, in a way that cost him personally.&lt;/p&gt;

&lt;p&gt;Neither of them started from a name, or from the shape of a thing. The work came first; the name, if it ever arrived, arrived after the work had earned one. Each held himself to producing a result at the execution level, with his own hands, and when something fell short, no one in that room reached for someone to blame. Each had his own way of operating. The details evolved over the years, but neither ever bent the core of it. Whether the way was right or wrong, in the end, mattered less to them than that they believed in it — and in themselves, doing it their own way.&lt;/p&gt;




&lt;p&gt;There was a stretch, when the money was tight, when the three of us each did the most our own role allowed and nothing less. That is when the company turned.&lt;/p&gt;

&lt;p&gt;I was the third one in that room. The CTO. And what being there did to me was not hand me a method. It gave me permission to have my own — or, closer to the truth, it made clear that I had no choice but to build one.&lt;/p&gt;

&lt;p&gt;It is still most of what I run on. That one person can be a complete unit of responsibility, not a fraction of one. That the next thing should already be in mind before the current one is finished — I caught that from the CEO directly. That the result is mine to deliver, and when it breaks, mine to answer for, with no one else in the sentence — I caught that from both of them. None of it arrived as advice. It arrived as two people, in front of me, every day, refusing to do it any other way.&lt;/p&gt;




&lt;p&gt;Whether I belonged in that room — whether the two of them would have called the three of us a team — I don't know. I used to want to know. I've stopped needing to. That part is two-sided, and I only get to speak for mine.&lt;/p&gt;

&lt;p&gt;My side is simple. I was there. I learned this. And I have been grateful for it for a long time, in a way that doesn't depend on the answer to the other question.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43"&gt;The graph nobody is watching&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p"&gt;I survived 24 years because I'm lazy&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/three-failures-i-still-think-about-1fok"&gt;Three failures I still think about&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>leadership</category>
      <category>softwareengineering</category>
      <category>startup</category>
    </item>
    <item>
      <title>Reading under the stamp</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/reading-under-the-stamp-57bi</link>
      <guid>https://dev.to/hidekimori/reading-under-the-stamp-57bi</guid>
      <description>&lt;p&gt;Here is the issuer name from a Japanese invoice, rendered at 300 dpi. A red company seal sits directly on top of it — the kind stamped on nearly every invoice in Japan. On the left, the seal is a normal vermilion impression: translucent, the way real 朱肉 ink sits on paper. On the right, the same seal at full opacity, covering the same characters.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feucobxq3oeqgkzdgb3xr.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feucobxq3oeqgkzdgb3xr.png" alt="The same name under a translucent seal and an opaque one" width="800" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Under the translucent seal, &lt;code&gt;gpt-5.6-sol@high&lt;/code&gt; read the company name perfectly — the black ink survives underneath the red, and the model reads straight through it. Under the opaque seal, it read nothing. Same model, same characters, same position. The only variable is whether the ink underneath still exists.&lt;/p&gt;

&lt;p&gt;That gap is the first of two findings. The second one is stranger, and it runs the other way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this measures
&lt;/h2&gt;

&lt;p&gt;This is a companion to &lt;a href="https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567"&gt;a benchmark I published recently&lt;/a&gt;, which degraded one Japanese invoice through seven scan resolutions to find where each vision model stops reading. That axis was blur. This one holds the document perfectly sharp and introduces the second stress factor Japanese paperwork routinely carries and no Western benchmark tests: &lt;strong&gt;occlusion by a seal&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One variable: how much of a target field's ink a stamp covers, from 0% (a control, the seal sitting just below the text) to 100% (fully buried), in six steps. Two things vary in a 2×2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opacity.&lt;/strong&gt; A translucent seal (multiply blend, the black ink readable underneath) versus an opaque pad (the ink physically gone — the generator asserts, pixel by pixel, that nothing dark survives inside the target box).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target.&lt;/strong&gt; The issuer name, which has no arithmetic relationship to anything else on the page. And the subtotal, which by construction equals total − tax, with both of those left visible at every step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two splits ask two different questions. Opacity tests whether a model can see through red ink to the black underneath. Target tests whether it can rebuild a buried field from the ones around it. Finding 1 comes from the first axis; Finding 2, from the second.&lt;/p&gt;

&lt;p&gt;The extraction prompt never mentions the stamp. Whether a model reads through it, leaves the field empty, or fills it some other way is the thing being measured — so instructing any of those behaviors would destroy the measurement. Everything runs against 27 model variants from the same catalog as the blur benchmark, five repetitions per cell, 3,240 jobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 1: the chromatic channel
&lt;/h2&gt;

&lt;p&gt;Human OCR pipelines have handled seals for decades with a red-channel drop — strip the vermilion, the black text underneath comes back. The translucent-versus-opaque split measures whether a vision model does the same thing implicitly.&lt;/p&gt;

&lt;p&gt;The top-resolution variants do, almost universally. Under the translucent seal at full 100% coverage of the issuer name, &lt;code&gt;@high&lt;/code&gt; variants read it correctly &lt;strong&gt;60 out of 60 times&lt;/strong&gt;; under the opaque seal at the same coverage, the same variants scored &lt;strong&gt;0 out of 60&lt;/strong&gt;. The red is not what stops them — the destruction of the ink underneath is. The effect holds across nearly every strong reader, with a chromatic advantage of 0.56 to 0.76.&lt;/p&gt;

&lt;p&gt;One model inverts it. &lt;code&gt;nova-2-lite&lt;/code&gt; posts a &lt;strong&gt;negative&lt;/strong&gt; score: the translucent seal hurts it more than the opaque one — layered color confuses it worse than a field that is simply gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 2: the readers go silent, the non-readers answer
&lt;/h2&gt;

&lt;p&gt;Now the subtotal, under the opaque seal, deep in the ladder — 80% and 100% coverage, where the digits are physically destroyed. Any correct answer here was not read off the page. There is nothing left to read.&lt;/p&gt;

&lt;p&gt;The Anthropic models score &lt;strong&gt;0 out of 30&lt;/strong&gt; — three models, two coverage levels, five runs each. When the number disappears, they leave it blank.&lt;/p&gt;

&lt;p&gt;The OpenAI and Azure &lt;code&gt;@low&lt;/code&gt; variants score &lt;strong&gt;80 out of 100&lt;/strong&gt;. On a field whose pixels are gone, they return the correct value four times in five.&lt;/p&gt;

&lt;p&gt;This is not a small effect at the margins. It is a clean inversion, and the ladder makes it visible: on the destroyed-subtotal panel, the models that &lt;em&gt;can&lt;/em&gt; read the number go red — blank — at the bottom, while the models that &lt;em&gt;can't&lt;/em&gt; stay white. The scariest OCR errors in this whole series aren't misreads. This one isn't even a read.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fakp6u826aup93kjjtrl2.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fakp6u826aup93kjjtrl2.png" alt="Opaque seal over the subtotal: the readers go red at deep coverage, the non-readers stay white" width="800" height="836"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's happening is arithmetic. The subtotal equals total − tax, and both of those are visible on the page at every step by construction. A model that has given up reading the buried cell can still reconstruct it from its neighbors. This is a cousin of &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;a pattern I've written about before&lt;/a&gt;: when a model can't read a field, some models invent a value rather than leave it blank. There the invented number was pulled from a plausible prior; here it's computed from the visible neighbors. Either way the model answers with something other than what the document says. And here is the part that matters for anyone wiring this into an accounts-payable flow: &lt;strong&gt;a derived value is internally consistent by definition.&lt;/strong&gt; It reconciles. Every validation check that asks "do subtotal, tax, and total add up?" passes — because the number was built to add up, not read. The one instrument that would catch it is the thing you were trying to avoid buying: a human who looks at the actual document.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which arithmetic
&lt;/h2&gt;

&lt;p&gt;Two routes could produce it — total − tax, or total ÷ 1.1 — and since the tax is exactly 10%, they give the same answer, so the value can't distinguish them. But the &lt;code&gt;@low&lt;/code&gt; variants answered the tax field correctly &lt;strong&gt;49 times out of 50&lt;/strong&gt; at zero occlusion: with tax visible and answered, subtraction is available directly and division is redundant. The materials for total − tax were in hand. That is as far as the evidence goes — I can say the materials were present, not that I watched them used.&lt;/p&gt;




&lt;h2&gt;
  
  
  What doesn't generalize, and one honest gap
&lt;/h2&gt;

&lt;p&gt;Two cautions, because this series lives on stating them.&lt;/p&gt;

&lt;p&gt;The route conclusion above holds &lt;em&gt;only&lt;/em&gt; because the tax here is a round 10%, which collapses two arithmetic paths into one answer. A future version with a mixed tax rate would make total − tax and total ÷ 1.1 produce different numbers, fingerprinting the route in the output itself. That's the clean way to settle it, and this run can't.&lt;/p&gt;

&lt;p&gt;And the seal text. The stamp reads 検収済印 — an inspection stamp applied by the receiving company, chosen precisely because it carries zero issuer information. &lt;code&gt;claude-fable-5&lt;/code&gt; reads it correctly &lt;strong&gt;94% of the time&lt;/strong&gt;, including the traditional right-to-left column order, while most models ignore it as decoration. But 検収済印 is a common, real phrase, so a model reading it can't be separated from a model recognizing it from training. Whether that number reflects reading or memory is genuinely unresolved. A future version needs a fictional seal phrase — the same lesson the blur benchmark taught about fictional ground truth, applied to the stamp.&lt;/p&gt;




&lt;h2&gt;
  
  
  The picture across all four panels
&lt;/h2&gt;

&lt;p&gt;Put the two findings together. A translucent seal is not much of an obstacle to a strong reader — the black ink is right there under the red. An opaque seal destroys the field, and what a model does next sorts it into a type. The strongest readers in the benchmark fall silent. A specific class of weak readers quietly computes the answer from the fields around it, and hands it back with no indication that it was never seen.&lt;/p&gt;

&lt;p&gt;The blur benchmark had readers who invent and readers who go blank. This one adds a third: the deriver, who answers a question you didn't ask — what should this number be, given the others — in place of the one you did: what does the document say. And because the derived answer reconciles, it is the hardest of the three to catch.&lt;/p&gt;

&lt;p&gt;The companion to this finding is &lt;a href="https://dev.to/hidekimori/the-model-corrected-reality-fob"&gt;The model corrected reality&lt;/a&gt; — a case where a model read a field perfectly and overruled it. Both are about answers that survive every plausibility check.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Method notes: 3,240 analysis jobs against a synthetic invoice (all names fictional), five repetitions per cell, run July 2026 via my document-processing gateway. The stamp compositing, the pixel-level occlusion checks, the scorer, and the recorded runs are public: &lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/hanko-benchmark" rel="noopener noreferrer"&gt;ldxhub-io/examples › analyzedoc/hanko-benchmark&lt;/a&gt;. Every figure in this article is reproducible from the scored output.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>Three failures I still think about</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/three-failures-i-still-think-about-1fok</link>
      <guid>https://dev.to/hidekimori/three-failures-i-still-think-about-1fok</guid>
      <description>&lt;p&gt;People sometimes ask where the discipline comes from — the three-line rule, the habit of trusting nothing except my own running code, the assumption that production will surprise me. None of it is theory. Each piece came from a specific failure that reached real users and made me change how I work, permanently. Here are three I still think about. All three, I noticed later, are things I &lt;em&gt;didn't&lt;/em&gt; do.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The collation I didn't know about
&lt;/h2&gt;

&lt;p&gt;This was early. I was working on a content platform for Japanese feature phones — an official carrier service, the kind where charges land on your phone bill and the carrier has to approve you. User IDs were case-sensitive by spec. &lt;code&gt;userA&lt;/code&gt; and &lt;code&gt;usera&lt;/code&gt; were two different accounts.&lt;/p&gt;

&lt;p&gt;The database was MySQL. The user lookup was a &lt;code&gt;WHERE&lt;/code&gt; on a &lt;code&gt;varchar&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;What I didn't know — what I didn't know was even a concept — was that the default collation made that comparison case-insensitive. &lt;code&gt;WHERE id = 'userA'&lt;/code&gt; matched &lt;code&gt;usera&lt;/code&gt; too. I had never set the column to a binary collation.&lt;/p&gt;

&lt;p&gt;It ran fine for a long time. Then a user wrote in: points were missing from their account, and they had no memory of spending them.&lt;/p&gt;

&lt;p&gt;I still remember the moment I understood it. Two accounts whose IDs differed only in case were colliding in the lookup. One person's spending was drawing down the other's balance. It had been happening quietly, to whoever shared a case-collision with someone active.&lt;/p&gt;

&lt;p&gt;A few hundred users were affected before I found it. It remains the largest single mistake I have ever shipped.&lt;/p&gt;

&lt;p&gt;The fix was one line of column configuration. The lesson was not about that line. The lesson was that I had built on top of a default I had never examined — and the default did not match the spec I was supposed to implement. After that, I stopped assuming I knew how a tool behaved. I check, especially the defaults, because the default is the part you never chose, and therefore never thought about.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The connection I forgot to release
&lt;/h2&gt;

&lt;p&gt;A few years in. We were seeing access patterns that looked like abuse, and I wrote a process to catch them — flag suspicious source IPs, periodically push them into a blacklist. It was a small, defensive piece of code. I wrote it quickly and shipped it, because the abuse was happening &lt;em&gt;now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The logic was fine. Each time the process judged a request as suspicious, it opened a database connection, recorded the IP, and moved on.&lt;/p&gt;

&lt;p&gt;It opened a connection. It did not always close one.&lt;/p&gt;

&lt;p&gt;I had left the connection release out of the &lt;code&gt;finally&lt;/code&gt; block. The pool didn't drain all at once — each flagged request leaked a single connection, so nothing broke at release time. The service ran normally while connections quietly disappeared in the background, one at a time. The more abuse we saw, the more connections we lost.&lt;/p&gt;

&lt;p&gt;When it finally went down, the diagnosis was immediate. Something that had been working stopped working right after a release — there is only ever a short list of suspects, and I found the missing &lt;code&gt;finally&lt;/code&gt; quickly. That part was not hard.&lt;/p&gt;

&lt;p&gt;The painful part was the gap before it. Because the drain was slow, the outage didn't arrive until well after the release, by which point cause and symptom were separated in time. And I had believed I was watching the service. I wasn't — not in a way that would catch a gradual leak. The monitoring I thought I had was not the monitoring I actually had.&lt;/p&gt;

&lt;p&gt;That is the part I still think about. The code I wrote to defend the service was the code that took it down — and the watch I thought I was keeping never saw it coming.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;finally&lt;/code&gt; block. The lessons were two. First: cleanup is not a step you add once the logic works — it is part of the code. Something that acquires a resource without guaranteeing its release in the same breath is not finished; it only looks finished, until the one path you didn't cover is the path that runs most. Second, quieter and slower to sink in: a gradual failure is more dangerous than a sudden one, because it separates cause from effect — and the monitoring you assume is protecting you may not be looking at the thing that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The release note I didn't read
&lt;/h2&gt;

&lt;p&gt;This one is recent — recent enough that I am slightly embarrassed it still caught me, this far in.&lt;/p&gt;

&lt;p&gt;We relied on a third-party library for a core piece of what we produced. A new major version came out — a clean major-version bump, the kind you apply without much ceremony. I updated it, ran the unit tests, they passed, and I released.&lt;/p&gt;

&lt;p&gt;Then a user wrote in: the output was coming out blank. Not an error, not a crash — a valid, empty result. The kind of failure that passes every automated check except the only one that counts, which is a person opening the file and seeing nothing there.&lt;/p&gt;

&lt;p&gt;I had a guess immediately, and the release notes confirmed it within a minute. The new major version had changed an assumption the old one had quietly tolerated — about how the input and the output were allowed to overlap. My code had leaned on the old, lenient behavior for years. The unit tests passed because they exercised the shape of my code, not the behavior of the dependency against real data.&lt;/p&gt;

&lt;p&gt;The fix was small. What changed permanently was how I treat that dependency — and every dependency like it.&lt;/p&gt;

&lt;p&gt;I read release notes now, especially the section most people skip, the one labeled "breaking changes," because that is the version telling you in writing which of your assumptions it just invalidated. But reading is not trusting. A release note tells me what the maintainers chose to write down — not what they changed and didn't think worth a line. So I verify against the real service, every time. Not just on major versions: on minor bumps, on patch releases, on upgrades that have nothing to do with the part I care about. Especially the library that taught me this, which I now carry a permanent and slightly unfair suspicion of — the one I trust the least, and check the hardest.&lt;/p&gt;

&lt;p&gt;It is an ungenerous way to treat a tool I depend on and need. But some libraries earn it, and the check costs me a few minutes while the alternative costs a user opening a blank file. So I run it and look at the real output myself, every time. Unit tests check the shape of my code. Only the real service tells me the truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the three have in common
&lt;/h2&gt;

&lt;p&gt;Three mistakes, spread across the years, with one shape in common: each carved a habit into me, and I have the habit because I have the wound. None of these is a principle I reasoned my way to. It is all scar tissue.&lt;/p&gt;

&lt;p&gt;But there is one more thing, and it is the one that still makes me feel like a beginner after twenty-four years. I almost left it out, because it is the least flattering.&lt;/p&gt;

&lt;p&gt;Every time something breaks — every single time — there is a flicker, less than a second long, where some part of me thinks: &lt;em&gt;maybe this one isn't my fault.&lt;/em&gt; Maybe it's the library, the user, the infrastructure. And then, just as fast, I know better. It is almost always my fault, and I turn to the only useful question — what did I do? — but the flicker was there first. It is always there first.&lt;/p&gt;

&lt;p&gt;Twenty-four years has not removed it. I know, completely, that when my code is involved and something is wrong, the suspect is me. And still the instinct to look away fires before anything else, and has to be overridden every time.&lt;/p&gt;

&lt;p&gt;I have stopped expecting it to go away. It is probably just part of being the person who wrote the code — the small, animal reluctance to be the cause. What changed is not the instinct. It is the half-second. The override is faster now, that is all. Maybe that is what maturity is: not the absence of the flinch, but the speed of turning back toward the thing you would rather not look at.&lt;/p&gt;

&lt;p&gt;That turn — toward your own code, toward the real output, toward the default you never checked — is where these three habits actually live.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43"&gt;The graph nobody is watching&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p"&gt;I survived 24 years because I'm lazy&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>career</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>The model corrected reality</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-model-corrected-reality-fob</link>
      <guid>https://dev.to/hidekimori/the-model-corrected-reality-fob</guid>
      <description>&lt;p&gt;Here is the bank-transfer block from a Japanese invoice, rendered at 300 dpi. The fine print is 7.5 pt and every character is crisp. The bank is みずなら銀行 — a fictional institution I invented for a benchmark. It exists nowhere except in this document.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fffscx1mjclmfm24nblub.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fffscx1mjclmfm24nblub.png" alt="What the model saw, and what it answered" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gemini-3.5-flash@high&lt;/code&gt; read this block five times. It answered みずほ銀行 — Japan's largest megabank — five times out of five.&lt;/p&gt;

&lt;p&gt;It did not fail to read the document. It read it, and overruled it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;This is the strangest cell in &lt;a href="https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567"&gt;the legibility map I published recently&lt;/a&gt;. That project degraded one Japanese invoice through seven simulated scan resolutions and ran 27 vision model variants down the ladder, to find where each one stops reading — and what it does after. An &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;earlier article&lt;/a&gt; established the pattern everyone now expects: when a model &lt;em&gt;can't&lt;/em&gt; read, some models invent.&lt;/p&gt;

&lt;p&gt;This is different. This happened at the very top of the ladder, on a fully legible original. The trigger wasn't degradation.&lt;/p&gt;




&lt;h2&gt;
  
  
  An accidental controlled experiment
&lt;/h2&gt;

&lt;p&gt;The benchmark's fine print held two fictional financial institutions, and — more by instinct than by plan — they differed in exactly one way.&lt;/p&gt;

&lt;p&gt;The first, みずなら銀行, sits one character away from みずほ銀行, a real megabank. The second, ほしかげ信用金庫, is a small credit union with no real-world neighbor: nothing in the space of Japanese financial institutions sounds like it.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzjmy1i8sz5ud7e8ixz6.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzjmy1i8sz5ud7e8ixz6.png" alt="Same tier, same 300 dpi, same prompt" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same font size, same resolution, same prompt. The results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At 300 dpi, &lt;code&gt;@high&lt;/code&gt; read ほしかげ信用金庫 correctly in every run — while turning みずなら into みずほ in every run.&lt;/li&gt;
&lt;li&gt;Across the three Gemini variants and the full ladder, the substitution happened &lt;strong&gt;48 times&lt;/strong&gt;: 25 on &lt;code&gt;@low&lt;/code&gt;, 18 on &lt;code&gt;@high&lt;/code&gt;, 5 on &lt;code&gt;@medium&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When ほしかげ finally did break, deep in the blur, it drifted to はしかぜ信用金庫 and はしかわ信用金庫 — plausible-sounding institutions that &lt;strong&gt;do not exist&lt;/strong&gt;. Across the entire run it was never once pulled to a real one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trigger is not legibility. It is the existence of a nearby real entity. Where a real neighbor exists, there is an attraction. Where none exists, the model reads what is on the page — or, at worst, invents something exactly as fictional as the truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vision proposes, language disposes
&lt;/h2&gt;

&lt;p&gt;I can't see inside these pipelines, so take this as the simplest explanation rather than a claim about architecture. These systems read with their language model as much as with their eyes. The visual evidence says みずなら; the language prior says みずほ is overwhelmingly more probable; and somewhere in decoding, probability wins — silently, with no flag, at settings you don't control. One character of visual evidence is not enough to outvote a name the model has seen millions of times.&lt;/p&gt;

&lt;p&gt;Which is also why the credit union survived. There was no gravity well next to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  It's a trait, not a law
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;claude-fable-5&lt;/code&gt; read the same field on the same ladder and never substituted the real bank — not once, at any resolution, while reading the 7.5 pt tier correctly down to 50 dpi. Same input, same prompt, opposite disposition.&lt;/p&gt;

&lt;p&gt;Prior capture is not a law of vision models. It is a measurable individual trait — which means you can select against it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is the scary one
&lt;/h2&gt;

&lt;p&gt;The earlier fabrication article showed invented values that reconcile: totals that add up around a counterparty that was never there. This failure is worse in one specific way: &lt;strong&gt;the fabricated value is more plausible than the truth.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run the human review in your head. A reviewer checking "does this bank look real?" passes みずほ銀行 without blinking — and would actually flag the &lt;em&gt;true&lt;/em&gt; value as a typo. Every plausibility check you have, human or automated, is aligned with the error.&lt;/p&gt;

&lt;p&gt;And notice what made it visible at all: the ground truth was fictional. Real invoices carry real banks, so in production this substitution produces output indistinguishable from a correct read. A benchmark built on real documents is structurally blind to prior capture. Unguessable, fictional ground truth isn't a convenience for scoring — it is the only instrument that detects this failure mode.&lt;/p&gt;

&lt;p&gt;Concretely: never validate payment fields by plausibility. Validate against your counterparty master — the extracted bank either matches the registered account or a human looks at the page. And if fine print matters in your pipeline, benchmark for &lt;em&gt;disposition&lt;/em&gt;, not just accuracy: a model's willingness to overrule the page matters as much as its eyesight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;p&gt;Everything is published in the benchmark repo — &lt;strong&gt;github.com/ldxhub-io/examples → &lt;code&gt;analyzedoc/legibility-benchmark/&lt;/code&gt;&lt;/strong&gt;: the deterministic material generator (the crops above are the actual benchmark materials), the raw outputs of all 4,158 jobs, and the scorer. The free-tier reproduction subset described in the map article includes &lt;code&gt;@high&lt;/code&gt;, so you can watch the correction happen on your own API key without paying anything.&lt;/p&gt;

&lt;p&gt;Disclosure, as before: I run LDX hub, the harness used here. It builds no models, and nothing in this post gets better or worse for me depending on which model wins.&lt;/p&gt;

&lt;p&gt;The errors to fear are not the implausible ones — those get caught. The ones to fear are the corrections: answers improved in the direction of the world's expectations, away from what the page says. A model that corrects reality will pass every review that checks for plausibility. The only defense is knowing what the document really said — which is exactly the thing you were asking the model to tell you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>I survived 24 years because I'm lazy</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p</link>
      <guid>https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p</guid>
      <description>&lt;p&gt;I've shipped code for 24 years. Same job, mostly the same stack, mostly alone.&lt;/p&gt;

&lt;p&gt;People assume that takes discipline. It doesn't.&lt;/p&gt;

&lt;p&gt;The truth is simpler and less flattering: I'm lazy.&lt;/p&gt;

&lt;p&gt;Not Larry Wall's "automate the boring stuff" lazy. Regular lazy. I avoid hassle. I skip work I don't want to do. I work around things that feel like chores.&lt;/p&gt;

&lt;p&gt;Twenty four years happened because I built around that. The first thing I built around was deadlines.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't set my deadlines
&lt;/h2&gt;

&lt;p&gt;I don't want to accept deadlines from other people.&lt;/p&gt;

&lt;p&gt;Not because I'm undisciplined. The opposite. Once I'm inside a problem, I stay there until it ships. Interrupting the flow costs me more energy than continuing. That part isn't the issue.&lt;/p&gt;

&lt;p&gt;The issue is that nobody outside my head can predict when "done" is. They don't know what's easy for me and what's hard. They guess. Then they tell me their guess as if it were a fact.&lt;/p&gt;

&lt;p&gt;It grinds on me. Every project where someone tried to schedule me, I ended up resenting the scheduler more than the work itself.&lt;/p&gt;

&lt;p&gt;The answer is always: "I'll ship it when I can. And yes, I'm working flat out."&lt;/p&gt;

&lt;p&gt;Most of the time, "when I can" is the next business day. There's not much point setting a deadline for that.&lt;/p&gt;

&lt;p&gt;What "when I can" actually looks like is the rest of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a lazy survivor actually does
&lt;/h2&gt;

&lt;p&gt;Here's the daily shape of it. Some of these will sound like discipline. They aren't. They're what laziness produces when you've been at it long enough.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I don't stop until it's done.&lt;/strong&gt; This is where "lazy" gets confusing. When I'm in a problem, stopping is more work than continuing. I sleep and eat enough to not fall over. The work isn't the chore. Pulling myself out of it and back into it is the chore.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I write the spec by being my own user.&lt;/strong&gt; Nobody knows what's easy to use and what's consistent better than the person stress-testing it. That's me.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The 3-line discipline.&lt;/strong&gt; (See &lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;010&lt;/a&gt;.) By the time the code is written, it's already been tested.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;After release, I watch the logs.&lt;/strong&gt; Access logs, batch logs, error logs — I keep them tailed. A weird line scrolls past and something catches. The alerts haven't fired. I already know.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I redeploy as many times a day as I need to.&lt;/strong&gt; The first release barely matters. What matters is the 10, 15, 20 years of changes that come after. The earlier you ship, the longer that window is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I throw real data at my own software, hard.&lt;/strong&gt; Big data, malformed data, weird-shaped data. The only confidence I trust is the kind that survives that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When an internal user overloads my system, I thank them.&lt;/strong&gt; Live data is a gift. I have never had the opposite feeling about a real-world failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A bug found before anyone else sees it isn't a bug.&lt;/strong&gt; It's just an edit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If there's an update worth making, the next day is too late.&lt;/strong&gt; GPT-5.5 ships → I verify it on my app today, ship it as a selectable option today. I don't want to be the one lagging behind.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch processing is what I love most.&lt;/strong&gt; Shaving milliseconds. Cutting load. Watching batch logs scroll. Every part of it is a reward. I'm always hoping more batch jobs come in from users.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one more — the most important one, the one 24 years actually paid for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discomfort means something is wrong.&lt;/strong&gt; Always. When something feels off, I hunt it down and crush it. The hunch isn't mystical — it's 24 years of pattern recognition without a vocabulary, and it hasn't been wrong yet. The part of me that wants to look past it has always paid for it later.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where being lazy actually hurts
&lt;/h2&gt;

&lt;p&gt;Not all laziness is helpful. Some of it is just being a person who avoids reading.&lt;/p&gt;

&lt;p&gt;I don't read manuals. My wife has a car with cruise control. I've driven it for years. I still don't know how to turn the cruise control on. Every time, I'm too lazy to figure it out, and I drive without it.&lt;/p&gt;

&lt;p&gt;That instinct does real damage at work. I don't read API docs unless I have to. I don't read change logs carefully. I skim. I assume. I run code instead of finishing a manual.&lt;/p&gt;

&lt;p&gt;English is the other one. I'm Japanese — my technical reading is fine, but the gap between "I can read this" and "I want to read this carefully" is wide, and laziness lives in that gap. Long English documentation is exactly the thing I will not voluntarily face.&lt;/p&gt;

&lt;p&gt;For most of my 24 years, this cost me real time. I'd build something that worked, then discover three weeks later that the API I'd wrapped had a flag I'd missed because I never read past the example block.&lt;/p&gt;

&lt;p&gt;The honest answer is that Claude reads for me now. I describe the problem, Claude reads the docs, Claude points me at the part I need to verify. It doesn't replace judgment. It removes the friction between me and information I was always going to avoid anyway.&lt;/p&gt;

&lt;p&gt;I don't say that to praise AI. I say it because it's true for me, and it would be dishonest to write a piece about how I work in 2026 without saying it out loud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The engine
&lt;/h2&gt;

&lt;p&gt;If you re-read the list above, two things are doing the work. Both are unglamorous.&lt;/p&gt;

&lt;p&gt;The first is the laziness. Each new component means ten years of maintenance I'll have to carry. Each new methodology means bugs I haven't learned yet. Each methodology argument is time not spent writing code. Each promised date is someone else's deadline I'll have to keep. I avoid all of it by default.&lt;/p&gt;

&lt;p&gt;The second is profit. What I build has to make money. Not in some abstract sense — actual revenue from actual users. That part of me has no patience. If a service isn't paying for itself, something is wrong with it, and I want to know what. So I ship early, watch logs, redeploy whenever there's something to fix. Not out of discipline. Out of refusal to leave money on the table.&lt;/p&gt;

&lt;p&gt;These two forces don't agree on much. Laziness says "don't bother." Profit says "if it makes money, bother." What's left after they negotiate is everything I actually do.&lt;/p&gt;

&lt;p&gt;That's why "shipping continuously for 24 years" looks like discipline from the outside. It isn't. It's laziness pushed in a useful direction by the only thing that ever moved me — the need for the result to actually pay.&lt;/p&gt;

&lt;p&gt;A CEO at one of my earlier companies once asked me: "That service running on your desktop — can you make it public right now?" I half-dismissed him at the time. Years later I understood he was the salesperson version of the same engine. That's a story for another time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Other solos
&lt;/h2&gt;

&lt;p&gt;There are other people doing what I do. Solo. Operating something they built years ago that's still running.&lt;/p&gt;

&lt;p&gt;I assume — based on no evidence except how this kind of work shapes a person — that no two of us work the same way. Each of us has wrapped a different cocoon around a different temperament. What I do would be unworkable for them. What they do would be unworkable for me.&lt;/p&gt;

&lt;p&gt;I respect that more than I can put into words. Solo developers who keep their own systems running for years are doing a job that doesn't show up in any career framework I've seen. Most of them never write about it because writing is also work.&lt;/p&gt;

&lt;p&gt;Whatever way you do it — keep going. I see you.&lt;/p&gt;




&lt;h2&gt;
  
  
  What survived
&lt;/h2&gt;

&lt;p&gt;People who watch me work sometimes call it persistence. Conviction. The shape of a long arc.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;I survived because every alternative was more work than continuing. I stayed lazy. I got paid. Together, those two facts ran the clock for 24 years and counting.&lt;/p&gt;

&lt;p&gt;What looks like a disciplined career from the outside has always been, on the inside, the path of least resistance — provided someone was paying me at the end of it.&lt;/p&gt;

&lt;p&gt;The lazy way is the only way I know.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43"&gt;The graph nobody is watching&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>productivity</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Where vision models stop reading — and start inventing</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567</link>
      <guid>https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567</guid>
      <description>&lt;p&gt;Earlier this week I published &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;a strange finding&lt;/a&gt;: GPT's low-detail image mode doesn't &lt;em&gt;misread&lt;/em&gt; documents it can't see — it invents them, fluently, with reconciling totals. That was one failure mode, in one model family, at one legibility level.&lt;/p&gt;

&lt;p&gt;It left an uncomfortable question: where exactly does &lt;strong&gt;each&lt;/strong&gt; model stop reading — and what does it do after that? Leave the field blank, or fabricate something plausible?&lt;/p&gt;

&lt;p&gt;One result to hold onto while you scroll: a model that can no longer read a document can usually still tell what kind of document it is. That held across almost the entire board.&lt;/p&gt;

&lt;p&gt;So I built a ladder.&lt;/p&gt;

&lt;p&gt;Then I ran 27 vision model variants down it: 4,158 jobs, about $141 at list price, one afternoon. This post is the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup, in one paragraph
&lt;/h2&gt;

&lt;p&gt;One Japanese invoice, rendered on a fixed 2480×3508 canvas (A4 at 300 dpi), then degraded through seven simulated scan resolutions: &lt;strong&gt;300 → 150 → 100 → 70 → 50 → 35 → 25 dpi&lt;/strong&gt; (L0–L6). Degradation is resampling only — no noise, no blur, no rotation — so legibility is the &lt;em&gt;only&lt;/em&gt; variable. The invoice carries twelve fields across four font tiers: a 28 pt title, 16–14 pt "large" fields (total, invoice number), 10.5 pt body fields (counterparty, dates, amounts), and 7.5 pt fine print (bank details). Every value is fictional and unguessable, and &lt;code&gt;subtotal + tax = total&lt;/code&gt; reconciles — so a &lt;em&gt;plausible but wrong&lt;/em&gt; answer is detectable, not just a wrong one. Each variant reads each ladder step five times. The extraction prompt is deliberately neutral: it never says what to do with unreadable text, because that choice is the thing being measured.&lt;/p&gt;

&lt;p&gt;Scoring is deterministic, four classes per field: &lt;strong&gt;correct / near&lt;/strong&gt; (edit distance 1, strings only) &lt;strong&gt;/ blank / fabricated&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The map
&lt;/h2&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1o634s54bcmdqifs7tz1.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1o634s54bcmdqifs7tz1.png" alt="Body-tier fabrication rate, 27 variants × 7 ladder steps" width="800" height="1150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rows are model variants, columns are ladder steps, color is the &lt;strong&gt;fabrication rate on the 10.5 pt body tier&lt;/strong&gt; — the tier where invoice counterparties and amounts live. White means the model either read correctly or stayed silent. Red means it filled unreadable fields with invented values.&lt;/p&gt;

&lt;p&gt;The companion table below gives each variant's &lt;strong&gt;frontier&lt;/strong&gt;: the deepest ladder step where it still keeps ≥90% field accuracy, per tier (× = below 90% already at the crisp 300 dpi original).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;title&lt;/th&gt;
&lt;th&gt;large&lt;/th&gt;
&lt;th&gt;body&lt;/th&gt;
&lt;th&gt;fine&lt;/th&gt;
&lt;th&gt;body fab @25dpi&lt;/th&gt;
&lt;th&gt;classified correctly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-sol@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-sol@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-terra@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L3&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-terra@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-luna@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-luna@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;81/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.5@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.5@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.4@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.4-mini@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-sol@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-sol@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-terra@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;56%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-terra@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-luna@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-luna@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4-mini@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4-mini@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;54%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-fable-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-sonnet-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-opus-4-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bedrock/global.amazon.nova-2-lite-v1:0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;63/84&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest surprise in this table isn't where the frontiers sit. It's what happens past them — some models go silent, and some keep talking.&lt;/p&gt;

&lt;p&gt;Six observations fell out of the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "&lt;a class="mentioned-user" href="https://dev.to/low"&gt;@low&lt;/a&gt;" means different things per provider
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;google/gemini-3.5-flash@low&lt;/code&gt; — the second-cheapest variant on the board — read the body tier correctly at &lt;strong&gt;every&lt;/strong&gt; step down to 25 dpi, with &lt;strong&gt;zero&lt;/strong&gt; fabrications. Under exactly the same conditions, every OpenAI and Azure &lt;code&gt;@low&lt;/code&gt; variant collapsed at L0, on the pristine original. Same suffix, opposite behavior. The difference isn't the models' eyesight; it's what each provider's low-detail pipeline does to the image before the model ever sees it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. For GPT &lt;code&gt;@low&lt;/code&gt;, a worse scan is a better scan
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;@low&lt;/code&gt; accuracy curves are not monotonic. Most GPT &lt;code&gt;@low&lt;/code&gt; variants read a 70 dpi scan &lt;em&gt;better&lt;/em&gt; than the 300 dpi original — body accuracy climbing from ~40% at L0 to 70–80% at L3–L4 before falling again. My resampling acts as an anti-alias filter for the provider's own aggressive downscale. The practical corollary is genuinely odd: if you are stuck with a &lt;code&gt;@low&lt;/code&gt; pipeline, pre-blurring your documents can improve extraction.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. After collapse, models split into fabricators and blankers
&lt;/h2&gt;

&lt;p&gt;What a model does &lt;em&gt;past&lt;/em&gt; its frontier is a personality trait, and it's measurable. At 25 dpi, most GPT &lt;code&gt;@high&lt;/code&gt; variants fill 75–100% of the body fields they can no longer read with invented values. &lt;code&gt;openai/gpt-5.6-terra@high&lt;/code&gt; is the outlier of the entire board: 96% of its failures are blanks. Anthropic and Google models fail less to begin with and fabricate less when they do (0–26%). If your pipeline feeds payment systems, a blanker that admits defeat is worth more than a stronger reader that bluffs.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Same model, different gateway, different eyes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gpt-5.6-sol@high&lt;/code&gt; reads the 7.5 pt fine tier at 100% down to 100 dpi when called via OpenAI — and starts at 92% and degrades immediately when the same model is called via Azure. The failure &lt;em&gt;style&lt;/em&gt; shifts too: &lt;code&gt;terra&lt;/code&gt;'s blank rate drops from 96% (OpenAI) to 39% (Azure). This matches an earlier measurement suggesting the Azure pipeline applies a lower effective-resolution ceiling before the model ever sees the document. Your gateway choice is silently part of your model choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Fabrication doesn't need degradation (teaser)
&lt;/h2&gt;

&lt;p&gt;One fine-print field held a fictional bank whose name is one character away from a real megabank. At 300 dpi — fully legible, five out of five runs — some models "corrected" it to the real one. 48 substitutions across Gemini variants, while a fictional credit union with no real-world neighbor was read perfectly under the same conditions. The trigger isn't legibility; it's the existence of a nearby real entity. This one deserves its own write-up, with the receipts: &lt;a href="https://dev.to/hidekimori/the-model-corrected-reality-fob"&gt;The model corrected reality&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Classification survives reading loss
&lt;/h2&gt;

&lt;p&gt;25 of 27 variants classified all 84 documents (invoice / receipt / business card / meeting minutes) correctly at &lt;strong&gt;every&lt;/strong&gt; degradation step — including variants whose extraction had collapsed completely. A model that cannot read a document can still tell what kind of document it is. The two exceptions are instructive: the cheapest model on the board confuses receipts with invoices (21 out of 21 times — consistently, not randomly), and one &lt;code&gt;@low&lt;/code&gt; variant dropped three classifications at the bottom of the ladder.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd take into production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Route by tier, not by document.&lt;/strong&gt; Titles survive almost anything; fine print dies first. If a field matters, measure the frontier of the tier it lives in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick blankers for payment fields.&lt;/strong&gt; A fabricated bank name passes every visual plausibility check. Prefer models that return &lt;code&gt;""&lt;/code&gt; over models that return something convincing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume &lt;code&gt;@low&lt;/code&gt; is one thing.&lt;/strong&gt; Benchmark the variant you'll actually call, on the gateway you'll actually use.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reproduce it (a free key is enough)
&lt;/h2&gt;

&lt;p&gt;Everything — the deterministic material generator, the runner, the scorer, the raw outputs of all 4,158 jobs — is published:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;github.com/ldxhub-io/examples → &lt;code&gt;analyzedoc/legibility-benchmark/&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The materials are byte-identical on any platform (the generator downloads a pinned, checksum-verified font). A three-variant reproduction subset runs in 147 jobs ≈ 17,600 credits, which fits inside LDX hub's free tier (25,000 credits/month, no card):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 gen_materials.py
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LDXHUB_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...   &lt;span class="c"&gt;# free key: gw.portal.ldxhub.io&lt;/span&gt;
python3 run_benchmark.py &lt;span class="nt"&gt;--models&lt;/span&gt; ume &lt;span class="nt"&gt;--t1-instances&lt;/span&gt; A &lt;span class="nt"&gt;--t1-reps&lt;/span&gt; 3 &lt;span class="nt"&gt;--t2-reps&lt;/span&gt; 1 &lt;span class="nt"&gt;--yes&lt;/span&gt;
python3 score_results.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 report.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because raw model outputs ship with the results, you can disagree with my scoring rules and re-score everything without re-running a single job.&lt;/p&gt;

&lt;p&gt;Full disclosure: I run LDX hub. It builds no models — it's the harness here, not a subject. One API key across OpenAI, Azure, Google, Anthropic and AWS is the only reason a 27-variant matrix fits in one afternoon, and that convenience is exactly what I'm selling. The measurements stand on the published raw data either way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;Degradation is synthetic resampling, not real scanner noise — claims are limited to simulated legibility. One document type, one language (Japanese; if anything, a harder test than Latin script). The strict scorer counts character-level misreadings as fabrications, which flatters nobody. Results are a July 2026 snapshot; the ladder re-runs on every model addition, so the map will stay current.&lt;/p&gt;

&lt;p&gt;The next time a provider ships a new vision model, it gets a row within a day. That's the point of building a ladder instead of writing a review.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>When AI can't read, it invents — but it still sees the shape</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac</link>
      <guid>https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac</guid>
      <description>&lt;p&gt;&lt;em&gt;I ran 110 vision-extraction jobs against a synthetic invoice. The low-resolution modes of the newest models never once returned the correct document — and the way they failed is worse than random noise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All measurements in this post are as of July 10, 2026. Vision pipelines change; if you're reading this later, re-run the test before trusting the numbers.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The invoice that would have passed review
&lt;/h2&gt;

&lt;p&gt;Here is a fragment of what GPT-5.6 (Sol, low-detail image mode) returned when I asked it to extract a synthetic invoice from a PNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"K Northwind Trading Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bill_to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Accora Manufacturing Inc."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-2025-0731"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-07-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;757.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;75.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;833.25&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every number is correct. Subtotal, tax, total, all four line-item amounts, all quantities, all unit prices — perfect, down to the cents. The vendor is right too.&lt;/p&gt;

&lt;p&gt;The bill-to company does not exist. The real document says &lt;strong&gt;Aozora&lt;/strong&gt; Manufacturing Inc.; the model wrote &lt;em&gt;Accora&lt;/em&gt; (and, on other runs, &lt;em&gt;Alcora&lt;/em&gt;). The invoice number is wrong in one systematic way: the year. The document says INV-&lt;strong&gt;2026&lt;/strong&gt;-0731; the model wrote INV-&lt;strong&gt;2025&lt;/strong&gt;-0731 — and then, consistently, dated the invoice &lt;strong&gt;2025&lt;/strong&gt;-07-31 to match. The two invented values agree with each other.&lt;/p&gt;

&lt;p&gt;That last detail is the one that bothers me. A misreading scatters; over five runs you'd expect 2020, 2028, a garbled digit. This didn't scatter. Twenty out of twenty runs — five repeats, two image formats, two providers — said 2025, and the date field followed along. The model didn't fail to read the year — it composed a coherent document in which the year is 2025.&lt;/p&gt;

&lt;p&gt;An extraction where the totals reconcile but the counterparty is fictional is precisely the kind of error that sails through an accounts-payable check. Nobody re-verifies the customer name when the arithmetic is clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  The false positive that came first
&lt;/h2&gt;

&lt;p&gt;Before I trust a finding like this, I have to tell you about the bug I almost blamed on the model — because it changed how I ran everything after.&lt;/p&gt;

&lt;p&gt;My platform derives the output schema from an &lt;code&gt;example_output&lt;/code&gt; the caller provides. My first test used example values like &lt;code&gt;10.0&lt;/code&gt; and &lt;code&gt;22.0&lt;/code&gt;. Somewhere between my MCP client and the Java layer that infers the schema, JSON serialization collapsed &lt;code&gt;10.0&lt;/code&gt; into &lt;code&gt;10&lt;/code&gt; — an integer. The inferred schema said &lt;em&gt;integer&lt;/em&gt;, and every model dutifully returned integer totals. &lt;code&gt;833.25&lt;/code&gt; came back as &lt;code&gt;833&lt;/code&gt;, and in one configuration as &lt;code&gt;83325&lt;/code&gt; — the decimal point simply gone.&lt;/p&gt;

&lt;p&gt;For about an hour I had a tidy, wrong conclusion: "the new flagship's low mode corrupts decimals." Then I asked the question I should have asked first — &lt;em&gt;did the example even survive the JSON round-trip?&lt;/em&gt; — and the whole finding evaporated. With examples like &lt;code&gt;12.34&lt;/code&gt;, every model produced clean decimals.&lt;/p&gt;

&lt;p&gt;Lesson one, before any lesson about models: &lt;strong&gt;when output looks corrupted, suspect your test harness before the model.&lt;/strong&gt; &lt;code&gt;X.0&lt;/code&gt; is not a safe way to say "this field is a float" in any pipeline that round-trips JSON.&lt;/p&gt;

&lt;p&gt;That embarrassment is why everything below is n=5, scripted, with the pass/fail criteria frozen in code before the runs started.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The setup: one synthetic invoice (all names fictional), rendered to JPEG and PNG. Ground truth: vendor &lt;em&gt;K Northwind Trading Ltd.&lt;/em&gt;, bill-to &lt;em&gt;Aozora Manufacturing Inc.&lt;/em&gt;, invoice number &lt;em&gt;INV-2026-0731&lt;/em&gt;, total &lt;em&gt;833.25&lt;/em&gt;. A result counts as OK only if all four fields match. Eleven model configurations, two formats, five runs each — 110 jobs through my document-analysis API, which maps an &lt;code&gt;@low&lt;/code&gt; / &lt;code&gt;@high&lt;/code&gt; variant onto each provider's image-detail setting.&lt;/p&gt;

&lt;p&gt;The low-detail modes of the &lt;strong&gt;GPT-5.5 and GPT-5.6 generations&lt;/strong&gt; (Sol, Terra, Luna; direct and Azure-hosted): &lt;strong&gt;zero correct extractions in 70 attempts.&lt;/strong&gt; Not "low accuracy" — zero. The failures were the kind shown above: arithmetic intact, identities invented.&lt;/p&gt;

&lt;p&gt;The controls, same images, same five repeats:&lt;/p&gt;

&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;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5 / GPT-5.6 family, low detail (7 configs)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 70 correct&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (Azure), low detail&lt;/td&gt;
&lt;td&gt;18 / 20 correct; 2 near-misses (a dropped space: "KNorthwind")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini (Azure), low detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash, low detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Sol, &lt;strong&gt;high&lt;/strong&gt; detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An earlier run of the same protocol used a version of the invoice with smaller, lighter text. Same models, same 0-for-70 — but there the failures were total: complete fictional invoices, different every time. A vendor called &lt;em&gt;Kramerwick Ltd.&lt;/em&gt; with a Brussels address. &lt;em&gt;KittenPaws, LLC&lt;/em&gt; on Meowth Street. A Japanese company name the document never contained. Line items for services that don't appear anywhere in the image.&lt;/p&gt;

&lt;p&gt;So the failure mode is not binary; it slides with legibility. Illegible source → the model invents the whole document. Partially legible source → the model reads what it can (the big bold totals) and invents the rest (the small print), stitching both into one internally consistent answer. &lt;strong&gt;The second mode is the dangerous one.&lt;/strong&gt; A wholesale fabrication looks wrong at a glance. A half-real document does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The generational irony
&lt;/h2&gt;

&lt;p&gt;Look at that table again. The token budgets are essentially identical: measured directly against the provider APIs (my gateway meters pages, not tokens), the OpenAI-family low mode spends about &lt;strong&gt;315 tokens&lt;/strong&gt; per image of this size; Gemini's low setting spends about &lt;strong&gt;258&lt;/strong&gt;. Gemini reads the invoice perfectly on the &lt;em&gt;smaller&lt;/em&gt; budget. And GPT-5.4 — the &lt;em&gt;older&lt;/em&gt; generation, same 315 tokens, same hosting path — gets it right, with failures that look like classic OCR noise: a dropped space, a mangled character. It degrades the way you'd expect a reader to degrade.&lt;/p&gt;

&lt;p&gt;The 5.5 and 5.6 generations do something different with the same pixels. Where 5.4 returns &lt;em&gt;less&lt;/em&gt;, they return &lt;em&gt;other&lt;/em&gt;. My best reading — and I'll flag it as interpretation, not measurement — is that the newer generations are stronger generators, and when perception runs out, generation fills the gap with whatever is most plausible. "Aozora" becomes "Alcora": right silhouette, right length, wrong word. 2026 becomes 2025, and the date agrees, because a coherent story beats a faithful blank.&lt;/p&gt;

&lt;p&gt;Newer model, better prose, worse witness.&lt;/p&gt;




&lt;h2&gt;
  
  
  The reversal
&lt;/h2&gt;

&lt;p&gt;At this point the obvious move is to kill the low-detail image modes entirely. I almost did. Then I ran the opposite experiment: instead of asking the same models to &lt;em&gt;read&lt;/em&gt; documents, I asked them to &lt;em&gt;sort&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;Round one: five synthetic documents with distinct layouts — invoice, receipt, business card, contract, blank page. Nine configurations (the seven "guilty" low modes plus two controls), three runs each: &lt;strong&gt;135 / 135 correct&lt;/strong&gt;, including refusing to force the blank page into a category.&lt;/p&gt;

&lt;p&gt;Round two was designed to be unfair. Three Japanese business documents — 請求書 (invoice), 御見積書 (quotation), 注文書 (purchase order) — with &lt;em&gt;identical&lt;/em&gt; layouts, identical tables, identical amounts, identical document numbers. The only difference is the title and one label line. You cannot sort these by shape; you must read the title. I then degraded them: photocopier noise, a 2° tilt, JPEG quality 25. Nine configurations, seven materials, three runs: &lt;strong&gt;189 / 189 correct.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same models. Same low-detail budget. 0-for-70 at reading the fine print; 324-for-324 at reading the headline and the shape.&lt;/p&gt;

&lt;p&gt;The capability boundary is suddenly crisp: &lt;strong&gt;at ~300 tokens per page, these models see the title tier of a document reliably and the body tier not at all — and where the body tier fails, the 5.5+ generations fill it with fiction.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Delisting was the wrong answer — the classification result is real, and a sorting gate that costs a tenth of a high-detail read is genuinely useful (mixed scan folder → cheap low-detail triage → route each type to the right extraction pipeline). Silence was also the wrong answer: my catalog said the low modes were for "clean, large-text documents," and my own test — a clean, large-text document — had just proven that description wrong.&lt;/p&gt;

&lt;p&gt;So the fix was one sentence of honesty. Every affected variant in my catalog now reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Low-resolution mode for fast, economical PDF extraction and document classification; **text read from JPEG/PNG images is unreliable at this resolution.&lt;/em&gt;**&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(PDFs are unaffected in my measurements — the OpenAI-family models read PDF input through its text layer, so image downscaling never touches it. That's also why this failure hid so well: every PDF test passed.)&lt;/p&gt;

&lt;p&gt;The formats stay listed. The capability stays available. The sentence tells you what 110 jobs taught me: what it's for, and what it will quietly get wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule I'm keeping
&lt;/h2&gt;

&lt;p&gt;My pipeline runs on a rule I've kept for a long time: absorb failures with deliberate retries, and always return a result. But a retry only helps when the next attempt can go differently — a network hiccup, a rate limit. This failure is worse than the structural kind. A malformed schema at least &lt;em&gt;announces&lt;/em&gt; itself; you can validate before you send. A fabricated bill-to announces nothing. The request succeeds. The JSON validates. The totals reconcile.&lt;/p&gt;

&lt;p&gt;You cannot retry your way out of this, and you cannot fully predict it either. What you can do is measure where the boundary sits — n=5, criteria frozen, controls included — and then write the boundary down where your users choose models. Not in a postmortem. In the catalog, in the sentence they read before they click.&lt;/p&gt;

&lt;p&gt;The models will keep getting better at writing. That is exactly why "I couldn't read this" increasingly comes back as fluent, internally consistent, confidently formatted text. The shape is real. The details may be fiction. Design — and document — accordingly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Method notes: 110 extraction jobs + 324 classification jobs against synthetic documents (all names fictional), run July 10, 2026 via my document-processing gateway with per-provider low/high image-detail variants; token figures measured directly against provider APIs. Pass/fail criteria were fixed in the harness before execution. The generation and test scripts, plus the recorded runs, are public: &lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/low-detail-study" rel="noopener noreferrer"&gt;ldxhub-io/examples › analyzedoc/low-detail-study&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The graph nobody is watching</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43</link>
      <guid>https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43</guid>
      <description>&lt;p&gt;If you ask me what part of the system I protect the most, the answer is the database.&lt;/p&gt;

&lt;p&gt;I've been writing software alone for twenty-four years, and across every platform I've built, the rule has stayed the same: the web servers can take whatever you throw at them, the batches can be rebuilt, but the database has to stay idle on purpose. Not because I love idle databases, but because the day a database actually starts to struggle is a day with very few good options.&lt;/p&gt;

&lt;p&gt;This article is about what "keep the database idle on purpose" actually means in practice, and about one particular kind of graph that, in my experience, almost nobody is watching.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three layers and what each of them gets
&lt;/h2&gt;

&lt;p&gt;I think of a production system as having three tiers, and each tier gets a different rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The web server tier&lt;/strong&gt; can be horizontally scaled. If load grows, you add machines. If something is wrong, you take a machine out of the pool, and the others handle it. Failures here are visible immediately, and they're cheap to recover from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The batch server tier&lt;/strong&gt; can be scaled up or out depending on the work. A batch that's too slow can be split. A batch that crashes can be retried. End users don't see batch servers, so a stuck batch is a problem for me and not for them. Some headroom up here is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database tier&lt;/strong&gt; is the one I treat completely differently. The database is not where you absorb load. The database is what you protect from load. The reason is simple: the other tiers can be rebuilt or re-scaled. The database is the irreplaceable record. If it slows down, everything slows down. If it falls over, you don't have many minutes before the rest of the stack notices.&lt;/p&gt;

&lt;p&gt;So my rule for the database is: keep it idle. Not idle in the sense of "doing nothing." Idle in the sense of "running well below its capacity, at all times, so that any extra load it picks up has somewhere to go."&lt;/p&gt;

&lt;p&gt;For more than a decade I ran a large appliance-grade database where I kept the load average below 1 at all times. Not as a target. As a fact. If the load average went up, that was the signal that something had changed in the application and I needed to find it before the database told me about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I keep it idle
&lt;/h2&gt;

&lt;p&gt;A few habits, repeated for decades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregate periodically, not on demand.&lt;/strong&gt; When an application needs a daily total, a monthly summary, a yearly count, the wrong thing is to compute it at the moment of the request. The right thing is to compute it ahead of time, on a schedule, into a summary table the application can read from cheaply. If the summary needs to be refreshed every minute, that's fine — a per-minute aggregation against a well-indexed working set is a small, predictable cost. An on-demand aggregation against the full source table is a large, unpredictable cost, and it scales with data growth in a way you don't want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cut joins dynamically.&lt;/strong&gt; When a query joins many tables but a particular filter condition makes some of those tables redundant, the query construction layer can skip them. The fewer tables in the join, the less work for the planner and the executor. This kind of work is invisible to the application engineer — it lives in the layer that builds the SQL — but it pays for itself many times over the lifetime of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse to optimize the optimization.&lt;/strong&gt; Once, an infrastructure engineer suggested running an enterprise database optimization dashboard against my database, to surface query-level improvement candidates. The pitch was that with more compute and storage capacity, we could push queries to run much faster. I declined.&lt;/p&gt;

&lt;p&gt;The headline reason was that the database wasn't there only to serve analytical queries — its primary job was to keep the user-facing OLTP layer responsive, which meant the spare capacity I was carrying was a buffer for user load, not a budget to be spent on faster reports. The secondary reason was that the optimization process itself would have consumed CPU on the database, and the database is the one place where extra processes are not free. The database I had built was already running below LA 1. There was nothing to optimize at that level. Adding optimization itself would only have added load.&lt;/p&gt;

&lt;p&gt;These three habits are not clever. They don't require special tools. They require the willingness to put the database first in design decisions, every time, even when the application engineer's instinct is to do otherwise.&lt;/p&gt;




&lt;h2&gt;
  
  
  The graph nobody is watching
&lt;/h2&gt;

&lt;p&gt;Here's the part of this that I think is genuinely under-discussed.&lt;/p&gt;

&lt;p&gt;I've been looking lately at two production database graphs, from two different services I'm involved with. Both graphs cover the last several months. Both are showing the kind of metric — read-row-count from random access — that tracks how much physical work the database is being asked to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service A's graph is bumpy.&lt;/strong&gt; Most of the time it sits near zero. Several times a week, there's a spike — 100,000 reads, sometimes more, in a short burst. The spikes are predictable in shape: they happen when a dashboard somewhere runs an on-demand aggregation. The fix would be to move that aggregation off the live database and onto a summary table refreshed periodically, or onto a separate analytics store. The shape is alarming on a single graph, but the architecture explains it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service B's graph is the opposite, and that's what makes it the more dangerous of the two.&lt;/strong&gt; It's not bumpy. It's a slow, steady upward slope. Several months ago the line sat around 12,000. Today it sits around 18,000. Usage is not growing — in fact, the user count for this service has been declining over the same window.&lt;/p&gt;

&lt;p&gt;There's no spike to point at. There's no incident to investigate. There's no alert that has fired, because no threshold has been crossed. There's only a slope.&lt;/p&gt;

&lt;p&gt;This is what I mean by "the graph nobody is watching." Spikes get attention. Sudden failures get attention. A gradual upward slope, on a metric most teams don't even look at, while usage is flat or declining — this gets no attention at all. And yet it is, in my reading, the more serious signal. Something inside the system is doing more physical work to serve a smaller number of users. The application has degraded silently, in a way the dashboard isn't designed to detect.&lt;/p&gt;

&lt;p&gt;The hard part is that the only way to spot this is to look at the graph carefully, over a long enough window that the slope can become visible. A glance at last week's numbers tells you nothing. A glance at last month's numbers tells you very little. The kind of degradation I'm describing only resolves into a recognizable shape when you've been watching the same metric over an extended period — long enough that small monthly differences become a slope.&lt;/p&gt;




&lt;h2&gt;
  
  
  The asymmetry of upward slopes
&lt;/h2&gt;

&lt;p&gt;Not every upward slope is dangerous. Disk usage climbs because logs accumulate — explainable, dismissable. Connection counts climb because a new client integration came online — explainable, dismissable. Some upward slopes have a story behind them, and the story is fine.&lt;/p&gt;

&lt;p&gt;The dangerous upward slopes are the ones without a story. A database doing more work for fewer users has no story that's good. Either the data model has grown in a way the queries weren't designed for, or some piece of code is doing many more operations per request than it used to, or some background job has multiplied without anyone noticing. None of these have an alert attached. All of them are visible only as a slope on a graph that someone has to be looking at.&lt;/p&gt;

&lt;p&gt;Twenty-four years of running databases has taught me that the alert thresholds are not the boundary between "fine" and "in trouble." The alert thresholds are the boundary between "I can keep ignoring this" and "I have to act now." There's a whole region below the threshold that contains all the early warnings, and you only see that region if you go looking for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The database is the one component in my stack that I won't let degrade. Everything else exists, in part, to keep the database from being asked to do too much. The web tier absorbs the user. The batch tier absorbs the work that doesn't have to be live. The aggregation layers absorb the queries that would otherwise hit the source tables. The dynamic-join construction absorbs the cost of joins that don't need to happen. All of this exists so that the database can stay below LA 1, all day, every day, for years at a time.&lt;/p&gt;

&lt;p&gt;When I look at a system someone else built and the database is the part that surprises me, I read it as a sign that the surrounding layers haven't been doing their job. The database telling you it's tired is a late signal. The graph nobody is watching is an earlier one.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>database</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>The example is the schema: extracting Japanese qualified invoices to JSON</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:35:40 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-example-is-the-schema-extracting-japanese-qualified-invoices-to-json-4eg1</link>
      <guid>https://dev.to/hidekimori/the-example-is-the-schema-extracting-japanese-qualified-invoices-to-json-4eg1</guid>
      <description>&lt;p&gt;Japan's qualified invoice system requires every invoice to carry a registration number (a "T" followed by 13 digits) and a per-rate tax breakdown — 8% reduced rate for food, 10% standard, frequently mixed on the same document. That makes Japanese invoices a nice stress test for structured extraction: non-Latin text, full-width characters, honorific suffixes, kanji-formatted dates, and two tax rates whose arithmetic has to reconcile to the yen.&lt;/p&gt;

&lt;p&gt;This post runs one through AnalyzeDoc (LDX hub) — PDF, JPEG, or PNG in, structured JSON out — and looks closely at what came back. The part worth your time isn't that it works. It's &lt;em&gt;how the schema is defined&lt;/em&gt;, and what that definition quietly controls.&lt;/p&gt;

&lt;p&gt;There is no JSON Schema. You hand the API an example of the output you want, and the example is compiled into the schema.&lt;/p&gt;




&lt;h2&gt;
  
  
  The document
&lt;/h2&gt;

&lt;p&gt;A fictional qualified invoice, one page (&lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/qualified-invoice-ja" rel="noopener noreferrer"&gt;sample PDF in the repo&lt;/a&gt;). Four line items: two food items at the 8% reduced rate (marked ※, as the law requires), two at 10%. Registration number, per-rate tax summary, bank details, a payment deadline. Total: ¥42,210.&lt;/p&gt;




&lt;h2&gt;
  
  
  The example is the schema
&lt;/h2&gt;

&lt;p&gt;Instead of a schema file, you send &lt;code&gt;example_output&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-2025-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issue_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-02-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"株式会社サンプル"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registration_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"T9876543210987"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer_phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"03-9876-5432"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"株式会社テスト商会"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"サンプル品目A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tax_summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bank_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"サンプル銀行 本店 普通 0000000"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make this work, and the first one is the whole article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your example values are type declarations.&lt;/strong&gt; Write &lt;code&gt;10000&lt;/code&gt; and the field is inferred as an integer; write &lt;code&gt;1234.56&lt;/code&gt; and it's a number. Japanese yen has no decimals, so integers are the &lt;em&gt;semantically correct&lt;/em&gt; choice here — every amount in the output will be a clean integer your downstream systems can trust. For a USD invoice you'd do the opposite: always write the example amounts with decimals, or a &lt;code&gt;$250.00&lt;/code&gt; line item risks coming back as an integer type. The literals you type are the contract you get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arrays receive variability.&lt;/strong&gt; &lt;code&gt;tax_summary&lt;/code&gt; is an array because mixed rates are the entire point of a qualified invoice. One entry per rate — the schema absorbs the document's core complexity instead of fighting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example values must differ from the document.&lt;/strong&gt; If the example matches the invoice, you can't tell whether the model read the page or copied the example. Different numbers, different names, different dates.&lt;/p&gt;




&lt;h2&gt;
  
  
  The prompt carries rules, not fields
&lt;/h2&gt;

&lt;p&gt;Field descriptions belong to the example. The prompt is only for rules the example can't express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extract the invoice data from this Japanese qualified invoice (適格請求書).
Rules:
- Dates in YYYY-MM-DD format.
- All monetary amounts as integers in JPY (no separators, no currency symbols).
- registration_number in "T + 13 digits" format.
- tax_rate as an integer percentage (8 or 10).
- tax_summary must contain one entry per tax rate on the invoice.
- Phone numbers must not start with "+".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last rule is scar tissue: a &lt;code&gt;+81&lt;/code&gt;-prefixed string dropped into Google Sheets gets parsed as a formula. Cheaper to kill it at the extraction boundary than to escape it everywhere downstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Four calls. No polling loop — &lt;code&gt;?wait&lt;/code&gt; parks the request server-side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Upload → file_id&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/files &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@invoice-sample-ja.pdf"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Create the job&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/analyzedoc/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; @job.json

&lt;span class="c"&gt;# 3. Wait for completion (server-side)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://gw.ldxhub.io/analyzedoc/jobs/&lt;/span&gt;&lt;span class="nv"&gt;$JOB_ID&lt;/span&gt;&lt;span class="s2"&gt;?wait=30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Fetch the result&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://gw.ldxhub.io/files/&lt;/span&gt;&lt;span class="nv"&gt;$OUTPUT_FILE_ID&lt;/span&gt;&lt;span class="s2"&gt;/content"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;job.json&lt;/code&gt; is five fields: &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;file_id&lt;/code&gt;, &lt;code&gt;output_format&lt;/code&gt;, &lt;code&gt;system_prompt&lt;/code&gt;, &lt;code&gt;example_output&lt;/code&gt;. The model here is &lt;code&gt;google/gemini-3.5-flash@high&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Completed in 26 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"INV-2026-0157"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issue_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issuer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"株式会社グリーンリーフ食品"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"registration_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"T1234567890123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issuer_phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"03-1234-5678"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"customer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"サンプルマート株式会社"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"有機緑茶ギフトセット ※"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&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="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"国産純粋はちみつ 500g ※"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"陶器マグカップ（箱入）"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;19200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"配送料"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"tax_summary"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2070&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;38700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total_tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3510&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"bank_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"サンプル銀行 本店 普通 1234567 カ）グリーンリーフショクヒン"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification: the four line amounts sum to 38,700 = subtotal. The 8% base (18,000) yields 1,440; the 10% base (20,700) yields 2,070; together 3,510 = total tax; 42,210 = total. All fifteen fields match the source. No transposed digits, no invented fields.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the model understood
&lt;/h2&gt;

&lt;p&gt;The arithmetic is table stakes. The details are where it gets interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honorific is gone.&lt;/strong&gt; The invoice addresses the customer as 「サンプルマート株式会社　御中」. 御中 (&lt;em&gt;onchū&lt;/em&gt;) is an honorific suffix appended to company names in correspondence — roughly "To the esteemed...". It is not part of the name, and &lt;code&gt;customer_name&lt;/code&gt; came back without it. Nothing in the prompt asked for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ※ marks stayed.&lt;/strong&gt; Line descriptions preserve the reduced-rate marker verbatim (「有機緑茶ギフトセット ※」) while the &lt;em&gt;meaning&lt;/em&gt; lives in &lt;code&gt;tax_rate: 8&lt;/code&gt;. Text fidelity in one field, semantics in another — a separation of concerns that exists because the schema was designed to hold it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kanji-formatted dates normalized.&lt;/strong&gt; 「2026年6月30日」 became &lt;code&gt;2026-06-30&lt;/code&gt; off a single prompt line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every amount is an integer.&lt;/strong&gt; Because the example said so. &lt;code&gt;6000&lt;/code&gt;, not &lt;code&gt;6000.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The footer disclaimer went nowhere.&lt;/strong&gt; The sample PDF carries a "this is a sample" notice at the bottom. No schema field fits it, so it correctly appears in none.&lt;/p&gt;




&lt;h2&gt;
  
  
  Swap the model, keep the code
&lt;/h2&gt;

&lt;p&gt;Changing one string — the model ID — switches the same request across OpenAI, Azure, Google, Anthropic, and Amazon models (15 at the time of writing). Clean, printed layouts run fine on fast, cheap models; degraded scans or handwriting can go to the heavyweight tier. The JSON shape doesn't change, because the schema compatibility is handled below the model line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;This page cost 285 credits (~$0.029). The free plan includes 25,000 credits — about 85 pages' worth of finding out whether your own invoices survive contact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The quickstart is built to go from signup to a completed job in about 60 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gw.portal.ldxhub.io/introduction" rel="noopener noreferrer"&gt;DevPortal / Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ldxhub-io/examples" rel="noopener noreferrer"&gt;Sample invoice + job.json + prompt, ready to clone&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fastest evaluation is the one you run on your own documents.&lt;/p&gt;

</description>
      <category>api</category>
      <category>ai</category>
      <category>json</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I removed the middleman, one phone call at a time</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l</link>
      <guid>https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l</guid>
      <description>&lt;p&gt;In the mid-2000s I worked on a content distribution platform that served over a hundred storefronts. Books, music, comics — different shops, the same underlying content, each store with its own branding.&lt;/p&gt;

&lt;p&gt;Among those hundred-plus storefronts, several dozen were technically resold through a third party — a viewer-side company that operated its own delivery infrastructure on top of ours. Their architecture required us to push every file into per-storefront slots on their servers, before the end user could download anything. Hundreds of slots. Per file.&lt;/p&gt;

&lt;p&gt;This article is about how those slots disappeared, and what I learned along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the layers got there
&lt;/h2&gt;

&lt;p&gt;When the platform launched, we were not the dominant player. The market for digital content on Japanese mobile phones was already shaped by a few established companies, and one of them sat between us and several dozen of our storefronts. They had built a viewer client that the end user installed on their phone. They had built a delivery server that the viewer pulled from. They had a working business.&lt;/p&gt;

&lt;p&gt;We came in as the catalog provider. The deal was simple in principle: their viewer, our content. To make their viewer work, we had to put files into the directory structure their delivery server expected — one directory per storefront, the same file copied into each.&lt;/p&gt;

&lt;p&gt;For a small catalog, this was tolerable. For a growing catalog, it was wasteful. A single new title meant dozens of identical writes — same bytes, different paths — every time we published.&lt;/p&gt;

&lt;p&gt;We were the latecomer. We didn't push back. We did the work their architecture required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The phone call
&lt;/h2&gt;

&lt;p&gt;One Friday afternoon I got a phone call from the lead engineer on their side.&lt;/p&gt;

&lt;p&gt;"Could you reduce the frequency of your pushes? Our servers are having trouble keeping up."&lt;/p&gt;

&lt;p&gt;The phrasing was polite, but the request was unusual. We had recently parallelized our delivery batches — moved from sequential pushes to concurrent ones — and the volume of writes had multiplied. We were doing what we should have been doing for our growing catalog. They were absorbing it on infrastructure that hadn't been designed for that rate.&lt;/p&gt;

&lt;p&gt;I could have agreed. Reducing batch concurrency was a one-line config change on our side. It would have made their afternoon easier and our publishers' release schedules a little slower.&lt;/p&gt;

&lt;p&gt;I didn't agree. I made a counter-proposal.&lt;/p&gt;

&lt;p&gt;"What if we stopped pushing per-storefront entirely? Could your viewer fetch directly from us, with the storefront as a parameter, instead of from a pre-placed copy?"&lt;/p&gt;

&lt;p&gt;There was a pause on the line. Not a hostile pause. The kind of pause an engineer makes when an idea reorganizes itself in their head.&lt;/p&gt;

&lt;p&gt;"Let me think about that and get back to you," they said.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two steps that followed
&lt;/h2&gt;

&lt;p&gt;It would be neat to say they called back the next day and we shipped it. That's not what happened. The conversation that started on that phone call took roughly two years to finish, in two discrete steps. Neither of the steps was urgent. Each of them was treated, on both sides, as a quiet refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one: stop the per-storefront duplication.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first thing we agreed was that, even if the viewer still fetched through their infrastructure, the file did not need to be duplicated per storefront on disk. They modified their delivery server to look up a single canonical copy, with the storefront determined by a parameter on the URL. We changed our push so that each file was uploaded once, not duplicated per storefront.&lt;/p&gt;

&lt;p&gt;Our delivery batch shrank by an order of magnitude. Their disk usage dropped accordingly. Nothing changed for the end user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two: remove the dependency on their server entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second thing was larger. Since the file was already canonical on our side, there was no architectural reason for the data path to go through their infrastructure at all. They updated their viewer to fetch directly from our delivery infrastructure, using the parameters they had previously injected on their server side. They kept the catalog metadata and the user-account layer; everything else went away.&lt;/p&gt;

&lt;p&gt;When the last storefront cut over, the middleman delivery server was no longer in the data path. They turned off the disks they had been running for us. We turned off the placement processing we had been running for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;It's tempting to read this as a story about how a smaller company outmaneuvered a larger one. That's not what happened. What happened was that I made a proposal in the middle of a routine operational complaint, and over two years both sides incrementally chose to remove a layer that had stopped earning its keep.&lt;/p&gt;

&lt;p&gt;There are a few things I took from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A symptom request is an opportunity to ask about the cause.&lt;/strong&gt; "Please reduce your push frequency" is a symptom request. The honest underlying question is "why are we doing this many writes at all?" Most operational complaints I've received over the years, in the moment, sounded like requests for symptom relief. Some of them turned out to be requests for an architectural conversation that nobody had explicitly opened yet.&lt;/p&gt;

&lt;p&gt;When I get a symptom request now, I always try to surface the cause version of the same request, at least once, to see what happens. Sometimes nothing. Sometimes a two-year refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conviction has to come with the other party's interest.&lt;/strong&gt; I was able to propose removing their delivery infrastructure because, by that point, our volume was material to them. A meaningful share of their viewer's traffic came from our catalog. Removing a layer that had stopped paying for itself was, for them, a saving — not a loss. Without that alignment, the same proposal would have been a polite no.&lt;/p&gt;

&lt;p&gt;The conviction wasn't only mine. It was conviction plus a real shape of interests. Without the second, no amount of conviction moves a vendor's roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latecomers can rearrange the layer cake.&lt;/strong&gt; When we started, we accepted their architecture because they were the established party and we were not. Two years later, we redefined the architecture together. The thing that changed in between was not their willingness to change. It was our weight in the system. The latecomer can become the party who proposes the new shape, once the latecomer is no longer small.&lt;/p&gt;

&lt;p&gt;I think a lot of the operational shapes that look fixed in a given industry are, in fact, shapes that nobody has been in a position to renegotiate. The middleman in our case wasn't there because anyone defended its existence. It was there because nobody yet had reason to ask whether it should be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years later
&lt;/h2&gt;

&lt;p&gt;I'm telling this story now because I keep finding myself doing variations of it. A vendor surfaces an operational complaint. I look at the complaint, and I find — sometimes — that the structure underneath the complaint is the actual subject of the conversation. The complaint is the symptom. The conversation is the door.&lt;/p&gt;

&lt;p&gt;Not every complaint opens a door. Most of them are just complaints. But some of them are an invitation to ask whether the current shape is the right one, and a year or two later you find that the shape is no longer there.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>devops</category>
      <category>business</category>
    </item>
  </channel>
</rss>
