<?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: Naitik Kapatel</title>
    <description>The latest articles on DEV Community by Naitik Kapatel (@naitik_kapatel_f96f1fb424).</description>
    <link>https://dev.to/naitik_kapatel_f96f1fb424</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%2F4067486%2Fb83b1c2c-9951-49a8-bbb4-e9adfe033da7.png</url>
      <title>DEV Community: Naitik Kapatel</title>
      <link>https://dev.to/naitik_kapatel_f96f1fb424</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/naitik_kapatel_f96f1fb424"/>
    <language>en</language>
    <item>
      <title>I Kept Retrying a Local Model Into the Right Shape. Turns Out I Didn't Have To Retry At All.</title>
      <dc:creator>Naitik Kapatel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 12:28:55 +0000</pubDate>
      <link>https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9</link>
      <guid>https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9</guid>
      <description>&lt;p&gt;The constraint on this one wasn't the AI part, it was the client: nothing leaves the device. No API calls, no cloud model, full stop, because the documents being classified weren't allowed off the machine they landed on. So the whole pipeline runs on a quantized model through llama.cpp, and its one job is boring on purpose - read a chunk of text, output exactly one of five category labels, nothing else.&lt;/p&gt;

&lt;p&gt;Boring is exactly what a local 3B model is bad at, it turns out.&lt;/p&gt;

&lt;h2&gt;
  
  
  First pass: just ask nicely and hope
&lt;/h2&gt;

&lt;p&gt;I asked for one of the five labels, plainly, in the prompt, the way you'd ask any model. It worked most of the time. "Most of the time" is not a number you get to keep when a pipeline runs unattended over a folder of a few thousand files overnight. The failures were never wild, either, which almost made it worse - a trailing period, the label wrapped in a sentence ("The category is: invoice"), once in a while a label that wasn't even in my list of five. Small drift, but small drift at that volume means a batch job you can't trust without babysitting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second pass: validate after, retry on failure
&lt;/h2&gt;

&lt;p&gt;I was already using shapecraft for the schema side of other parts of this project, so the obvious next move was its regex schema type - match the output against &lt;code&gt;^(invoice|receipt|contract|memo|other)$&lt;/code&gt;, and if it doesn't match, retry. That worked, in the sense that the final output was always correct. What it cost me was time, and on this hardware, time is the thing I had the least of. No GPU on the target machine, CPU inference only, and a chunk that fails the pattern means paying for a full second generation just to get a label I'd already told the model was one of five options. Multiply that by however many chunks drift, over a few thousand files, and the "safety net" was quietly doubling my runtime on the exact runs where it triggered.&lt;/p&gt;

&lt;p&gt;Retrying into correctness is still trusting the model to eventually get lucky. I wanted it structurally unable to answer wrong in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was already sitting in the schema-input list
&lt;/h2&gt;

&lt;p&gt;Turns out shapecraft has a schema type for exactly this, and I'd walked past it twice without registering what it did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;llamaCpp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;labelGrammar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`root ::= "invoice" | "receipt" | "contract" | "memo" | "other"`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llamaCpp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./models/llama-3.2-3b-instruct.gguf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gbnf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;labelGrammar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;chunkText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// one of the five, every time&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guaranteeLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "constrained"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{ gbnf: ... }&lt;/code&gt; hands &lt;code&gt;llamaCpp()&lt;/code&gt; a real GBNF grammar - the same grammar format llama.cpp itself has supported for ages - and it gets applied at the token level during generation. The model isn't producing "invoice." and getting caught after, it's mechanically incapable of producing a token outside &lt;code&gt;invoice | receipt | contract | memo | other&lt;/code&gt; in the first place. No retry loop, because there's nothing left for a retry to catch.&lt;/p&gt;

&lt;p&gt;That also explained the trailing-period and wrapped-sentence failures from pass one in a way "the model is being sloppy" never quite did: those weren't sloppiness, they were the model doing exactly what an unconstrained decoder does, which is treat every token as fair game unless something is physically stopping it. A grammar is that something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption that cost me twenty confused minutes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llamaCpp()&lt;/code&gt; reports &lt;code&gt;guaranteeLevel: "constrained"&lt;/code&gt; as its nominal level, and for a minute I read that as "everything I run through this backend is constrained." It isn't - that label only means what it says for actual &lt;code&gt;gbnf&lt;/code&gt; inputs. Point the same backend at a plain Zod schema and it's still a prompt-and-hope path underneath, same as any cloud backend without a native grammar hook. Made sense once I thought about it - there's no generic JSON-Schema-to-grammar step doing that translation for you - but I'd assumed the guarantee traveled with the backend instead of the schema type, and briefly wondered why my Zod-typed calls on the same local model weren't as bulletproof as the label ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode I didn't expect to appreciate
&lt;/h2&gt;

&lt;p&gt;I fat-fingered a rule reference in one grammar revision - referenced a rule I'd renamed and forgotten to update. Expected to find that out however these things usually surface: deep into a run, after the multi-gigabyte model had already loaded, staring at a stack trace from whatever &lt;code&gt;node-llama-cpp&lt;/code&gt; does with a broken grammar. Instead it errored immediately, before the model load even started, with a plain message pointing at the bad reference. Small thing, but on hardware where loading the model itself takes a noticeable chunk of a minute, catching a typo before that step instead of after is the difference between a five-second fix and a five-minute one, repeated every time I tweak the grammar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves the pipeline
&lt;/h2&gt;

&lt;p&gt;Same five-label job, zero retries, and the failure cases that used to be quiet drift I only caught by spot-checking output are now not reachable in the first place. The overnight batch run that used to occasionally need a rerun over its failed chunks now doesn't have failed chunks to rerun.&lt;/p&gt;

&lt;p&gt;If you've got a local model that needs to hit a fixed shape every time and no budget to retry it there, check &lt;code&gt;{ gbnf: ... }&lt;/code&gt; against &lt;code&gt;llamaCpp()&lt;/code&gt; in &lt;code&gt;@aviasole/shapecraft&lt;/code&gt; before you build a retry loop to paper over the drift. I built the retry loop first. Didn't need it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>node</category>
      <category>typescript</category>
      <category>gbnf</category>
    </item>
  </channel>
</rss>
