<?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: Vasyl</title>
    <description>The latest articles on DEV Community by Vasyl (@mrviduus).</description>
    <link>https://dev.to/mrviduus</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%2F333461%2Fea3cc6b2-e942-4848-8606-30c345279779.jpg</url>
      <title>DEV Community: Vasyl</title>
      <link>https://dev.to/mrviduus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mrviduus"/>
    <language>en</language>
    <item>
      <title>My Backup Leaked 156 GB, Filled the Disk, and Broke Itself. The Fix Was One Letter.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 04 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter-3ldd</link>
      <guid>https://dev.to/mrviduus/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter-3ldd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔨 &lt;strong&gt;#bugsmash, week by week.&lt;/strong&gt; Every week I take one real production bug from &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt; — my public-domain e-reader side project — and write up the full detective story: symptom, wrong suspects, root cause, fix. This one is about a backup that ate its own disk. &lt;em&gt;Originally published on &lt;a href="https://vasyl.blog/2026/08/03/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter/" rel="noopener noreferrer"&gt;vasyl.blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My nightly backup has a verify step. It restores the fresh dump into a throwaway postgres container and runs sanity queries — because a backup you never restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;One week it started flapping. &lt;code&gt;postgres did not become ready&lt;/code&gt;. That was the whole error. The dump itself succeeded. The storage tarball succeeded. Only the gate failed — the step whose entire job is to tell me the backup is real.&lt;/p&gt;

&lt;p&gt;Then one morning the box stopped answering SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  The innocent suspect
&lt;/h2&gt;

&lt;p&gt;First check, obviously: disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;span class="go"&gt;Filesystem      Size  Used Avail Use%
/dev/sda1        96G   56G   36G  61%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;61%. Plenty of room. Disk is innocent. Move on.&lt;/p&gt;

&lt;p&gt;Except &lt;code&gt;df -h /&lt;/code&gt; answers a narrower question than the one I asked. On this box, Docker's data-root lives on a separate partition — &lt;code&gt;/mnt/data&lt;/code&gt;. Everything Docker writes — images, containers, volumes — goes there, not to &lt;code&gt;/&lt;/code&gt;. And &lt;code&gt;/mnt/data&lt;/code&gt; looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /mnt/data
&lt;span class="go"&gt;Filesystem      Size  Used Avail Use%
/dev/sdb1       196G  183G     0 100%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero bytes free. And &lt;code&gt;docker system df&lt;/code&gt; pointed at the culprit: 156 GB of dangling anonymous volumes. Fifty-seven of them. All identical. All pgdata.&lt;/p&gt;

&lt;h2&gt;
  
  
  One volume per run, every run, for 34 days
&lt;/h2&gt;

&lt;p&gt;Here's the mechanism, and it's built from three facts that are each harmless on their own.&lt;/p&gt;

&lt;p&gt;Fact one: the official postgres image declares &lt;code&gt;VOLUME /var/lib/postgresql/data&lt;/code&gt;. If you don't mount something there yourself, Docker silently creates an &lt;em&gt;anonymous volume&lt;/em&gt; for every container you start.&lt;/p&gt;

&lt;p&gt;Fact two: &lt;code&gt;--rm&lt;/code&gt; only fires when the container exits on its own. A run that gets killed or times out never reaches that point.&lt;/p&gt;

&lt;p&gt;Fact three: my cleanup path for exactly those killed runs did &lt;code&gt;docker rm -f&lt;/code&gt;. Without &lt;code&gt;-v&lt;/code&gt;. That removes the container and leaves its anonymous volume behind — a full initialized pgdata directory, orphaned, every time.&lt;/p&gt;

&lt;p&gt;One leaked volume per bad run. Daily backups. Thirty-four days. 156 GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop closes
&lt;/h2&gt;

&lt;p&gt;This is the part I find beautiful, in the way you can only appreciate after the incident is over.&lt;/p&gt;

&lt;p&gt;The docker root fills up. A fresh throwaway postgres can no longer &lt;code&gt;initdb&lt;/code&gt; — nowhere to write its data dir. So the verify step fails. A failed, timed-out run is exactly the kind that skips &lt;code&gt;--rm&lt;/code&gt; and goes through the leaky cleanup. Which leaks another volume. Which leaves the disk fuller than before.&lt;/p&gt;

&lt;p&gt;The backup broke the very step that verified the backup. A self-reinforcing failure, powered entirely by its own cleanup code.&lt;/p&gt;

&lt;p&gt;One thing that mattered a lot at 11pm: real data was never at risk. Prod postgres and file storage bind-mount to &lt;code&gt;/&lt;/code&gt; — the partition sitting comfortably at 61%. The only thing bloating &lt;code&gt;/mnt/data&lt;/code&gt; was fifty-seven copies of a database that existed for ninety seconds each, just to prove a dump restores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One letter, in two places:&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;# -v removes the container's ANONYMOUS volume too. postgres declares an&lt;/span&gt;
&lt;span class="c"&gt;# anonymous VOLUME at /var/lib/postgresql/data, so every run that reaches&lt;/span&gt;
&lt;span class="c"&gt;# `docker rm -f` (a killed/timed-out run where --rm never fired) otherwise&lt;/span&gt;
&lt;span class="c"&gt;# leaks a full pgdata volume.&lt;/span&gt;
cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-fv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker rm -f&lt;/code&gt; became &lt;code&gt;docker rm -fv&lt;/code&gt; — in the trap handler and in the reap of leaked verify containers from previous runs. Plus a catch-all for the runs even a trap can't cover (SIGKILL):&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;# Belt-and-suspenders: drop any dangling anonymous volumes orphaned before&lt;/span&gt;
&lt;span class="c"&gt;# this fix (or by an OOM-killed `docker rm`). Named volumes are untouched.&lt;/span&gt;
docker volume prune &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because the original failure hid behind a bare &lt;code&gt;postgres did not become ready&lt;/code&gt;, the verify script now diagnoses itself: on readiness failure it dumps the container's &lt;code&gt;docker logs&lt;/code&gt;, &lt;code&gt;df -h&lt;/code&gt;, and &lt;code&gt;docker system df&lt;/code&gt; straight into the CI log, and bails early if the container dies during startup instead of waiting out the full window. The next time this class of bug shows up, the error message will contain its own root cause.&lt;/p&gt;

&lt;p&gt;Live remediation was one command. &lt;code&gt;docker volume prune&lt;/code&gt; reclaimed 163 GB. &lt;code&gt;/mnt/data&lt;/code&gt; went from 100% to 17%.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;code&gt;df -h /&lt;/code&gt; is not "the disk."&lt;/strong&gt; If Docker's data-root lives on its own partition, the partition you check by habit can say 61% while the one that matters says 100%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any image with a &lt;code&gt;VOLUME&lt;/code&gt; declaration is a leak waiting for a missing &lt;code&gt;-v&lt;/code&gt;.&lt;/strong&gt; You don't opt into anonymous volumes; they happen to you. Every &lt;code&gt;docker rm&lt;/code&gt; without &lt;code&gt;-v&lt;/code&gt; on such a container strands one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--rm&lt;/code&gt; is happy-path cleanup.&lt;/strong&gt; The trap handler is the real cleanup — and it must be at least as thorough as &lt;code&gt;--rm&lt;/code&gt; would have been, which means it needs &lt;code&gt;-v&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verify step needs its own observability.&lt;/strong&gt; Mine guarded the backups for months and then failed with seven words and no evidence. Any gate that can fail should dump the state needed to diagnose the failure, in the failure itself.&lt;/p&gt;

&lt;p&gt;And one for the road: a full disk still lets you SSH in. When the box stopped answering entirely, that was a clean manual reboot — not the disk. An SSH &lt;em&gt;connect&lt;/em&gt; timeout is a host-offline signal. Knowing which symptom belongs to which failure saved me from chasing a second ghost that night.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stuck at "Preparing… 0/0" Forever. My State Machine Had No Exit.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 28 Jul 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/stuck-at-preparing-00-forever-my-state-machine-had-no-exit-1pmb</link>
      <guid>https://dev.to/mrviduus/stuck-at-preparing-00-forever-my-state-machine-had-no-exit-1pmb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;TextStack is an open-source reader for technical books, built in .NET. It has an "Ask this book" feature: RAG over the book you uploaded. Before you can ask, the book must be indexed: split into chunks, and for PDFs each page goes through a paid vision parse that takes minutes. The code is public: &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A user clicks "Ask this book". The screen shows "Preparing… 0/0". And for some books it stayed there forever. Not slow. Forever. The retry button did nothing. The only cure was me poking the database by hand.&lt;/p&gt;

&lt;p&gt;Here is what the old flow did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /me/books/{id}/index
  -&amp;gt; flip rag_status to Indexing        (in the endpoint)
  -&amp;gt; run the whole chunking inline      (minutes of vision parsing, still in the HTTP request)
  -&amp;gt; return 202 when done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two problems living inside one line: the work ran inside the HTTP request, and the status flip happened before the work.&lt;/p&gt;

&lt;p&gt;A vision parse of a big PDF runs for many minutes. Cloudflare cuts the request long before that. The API container also restarts on every deploy. In both cases the process doing the work dies quietly, and the row stays like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rag_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Indexing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_chunk_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is the real bug. The retry path only re-claimed rows in status &lt;code&gt;NotIndexed&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// old claim: an Indexing row can never be claimed again&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RagStatus&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;RagIndexStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotIndexed&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;RagIndexStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Indexing&lt;/code&gt; with zero chunks was a state with no exit. Not failed, so no retry. Not ready, so no answers. A dead end that looks like progress. "Preparing… 0/0", forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in three pieces
&lt;/h2&gt;

&lt;p&gt;The shape of the fix: the endpoint only claims and answers, a background worker does the work, and every path ends in a terminal state. (Full code: &lt;a href="https://github.com/mrviduus/textstack/blob/main/backend/src/Worker/Services/RagIndexingService.cs" rel="noopener noreferrer"&gt;RagIndexingService.cs&lt;/a&gt; and &lt;a href="https://github.com/mrviduus/textstack/blob/main/backend/src/Worker/Services/RagIndexingWorker.cs" rel="noopener noreferrer"&gt;RagIndexingWorker.cs&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The endpoint returns 202 immediately.&lt;/strong&gt; It flips the status and goes home. No paid work on the HTTP path, nothing for Cloudflare to kill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A worker claims the row atomically.&lt;/strong&gt; No locks, no queue infrastructure. One UPDATE where the WHERE clause is the whole concurrency story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteSqlInterpolatedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"""
&lt;/span&gt;    &lt;span class="n"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;user_books&lt;/span&gt;
    &lt;span class="n"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;rag_indexing_started_at&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bookId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rag_status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;              &lt;span class="p"&gt;--&lt;/span&gt; &lt;span class="n"&gt;Indexing&lt;/span&gt;
      &lt;span class="n"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rag_chunk_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="n"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rag_indexing_started_at&lt;/span&gt; &lt;span class="n"&gt;IS&lt;/span&gt; &lt;span class="n"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="s"&gt;""", ct);
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// someone else owns it, walk away&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two workers pick the same row, one gets rowcount 1 and proceeds, the other gets 0 and returns. Postgres is the referee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A sweep recovers dead rows.&lt;/strong&gt; Every 30 seconds the worker also checks: any row still &lt;code&gt;Indexing&lt;/code&gt; with zero chunks whose &lt;code&gt;rag_indexing_started_at&lt;/code&gt; is older than 15 minutes means the process that claimed it died mid-parse. That row flips to a terminal &lt;code&gt;Failed&lt;/code&gt; with a human-readable reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;user_books&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;rag_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'indexing interrupted, retry'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rag_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rag_chunk_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rag_indexing_started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;staleCutoff&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it does NOT do: auto-requeue. A vision parse is real paid spend. The row fails loudly, the user re-triggers deliberately. Retry is a human decision when every retry costs money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that bit me anyway
&lt;/h2&gt;

&lt;p&gt;The failure path had its own bug waiting. When indexing dies because the worker is shutting down, the cancellation token is already cancelled. If you write the &lt;code&gt;Failed&lt;/code&gt; status using that same token, the error write gets cancelled too, and you are back to the forever-Indexing dead end you just fixed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the terminal write runs on a FRESH context with CancellationToken.None:&lt;/span&gt;
&lt;span class="c1"&gt;// the token that killed the work must not kill the record of its death&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateDbContextAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same cancellation also produces a second trap: the chunker returns 0 chunks on shutdown, which looks identical to a genuinely empty book. Without checking &lt;code&gt;ct.IsCancellationRequested&lt;/code&gt;, a perfectly good book gets labeled "No chapters to index" and a user believes their upload is broken. Zero is the least trustworthy number in a distributed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Work runs&lt;/td&gt;
&lt;td&gt;inside the HTTP request&lt;/td&gt;
&lt;td&gt;in a background worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request dies mid-parse&lt;/td&gt;
&lt;td&gt;row stuck Indexing forever&lt;/td&gt;
&lt;td&gt;sweep flips it to Failed in 15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry button&lt;/td&gt;
&lt;td&gt;dead for stuck rows&lt;/td&gt;
&lt;td&gt;works, Failed is claimable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shutdown during parse&lt;/td&gt;
&lt;td&gt;"No chapters to index" (wrong)&lt;/td&gt;
&lt;td&gt;"indexing interrupted, retry"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency control&lt;/td&gt;
&lt;td&gt;none needed (and none possible)&lt;/td&gt;
&lt;td&gt;one atomic UPDATE, rowcount as the lock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;A status enum is not a state machine until every state has an exit. &lt;code&gt;Indexing&lt;/code&gt; was a real status, rendered nicely in the UI, and it was also a trap: no transition out except success. The fix was not clever code. It was drawing the diagram and asking one question about every state: how does a row leave here if the process dies right now?&lt;/p&gt;

&lt;p&gt;And the operational rule on top: never let an HTTP request own work that outlives an HTTP request. The request is a messenger, not a worker.&lt;/p&gt;

&lt;p&gt;What is the longest a "temporary" status has survived in your database? I found rows that had been "Preparing" for weeks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build TextStack, an open-source reader for technical books, in .NET. This fix is from the indexing pipeline behind its "Ask this book" feature. &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>dotnet</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Nothing Crashed. Nothing Errored. My CPU Sat at 390% for an Hour.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/nothing-crashed-nothing-errored-my-cpu-sat-at-390-for-an-hour-335a</link>
      <guid>https://dev.to/mrviduus/nothing-crashed-nothing-errored-my-cpu-sat-at-390-for-an-hour-335a</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A user uploaded a 106-page PDF and clicked "Ask this book".&lt;/p&gt;

&lt;p&gt;The screen said "Preparing… 0/0".&lt;/p&gt;

&lt;p&gt;It said that for an hour.&lt;/p&gt;

&lt;p&gt;No errors. No failed requests. No exceptions in the logs. Just one graph I still think about: a CPU-only container, pinned at 390%. Almost exactly four cores, flat, like a ruler.&lt;/p&gt;

&lt;p&gt;The strange part: I pay for GPT-4.1 exactly so this kind of work never runs on my CPU. And the OpenAI dashboard showed zero traffic. Something was doing the work. Just not the thing I configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was supposed to happen
&lt;/h2&gt;

&lt;p&gt;TextStack is a reading app with an "Ask this book" feature. It is RAG over the book you uploaded. For table-heavy PDFs, plain text extraction is useless, so each page goes through vision parsing: page image in, Markdown out. That job belongs to GPT-4.1.&lt;/p&gt;

&lt;p&gt;The provider is picked by a routing config. Simplified:&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;"Ai"&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;"DefaultProvider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ollama"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Routes"&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;"pdf.parse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai-pdf"&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;If a task has a route, it goes to that provider. If it doesn't, it falls back to the default. The default is a local Ollama, which I use for small cheap tasks.&lt;/p&gt;

&lt;p&gt;Keep that fallback in mind. It is the villain of this story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging by absence
&lt;/h2&gt;

&lt;p&gt;Most debugging starts with an error. This bug gave me none. The strongest clue was a thing that was missing.&lt;/p&gt;

&lt;p&gt;Here is what I could see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The indexing job was running. Not failed, not stuck in a queue. Running.&lt;/li&gt;
&lt;li&gt;Zero requests to OpenAI. Not slow requests, not errors. Zero.&lt;/li&gt;
&lt;li&gt;The Ollama container was burning four cores. On a box with no GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the vision parsing was happening. On the local model. On CPU. I measured it later: about 42 seconds per page. The book had 106 pages. That is roughly 74 minutes of parsing for one book, all of it awaited inline by the indexing worker. The whole RAG pipeline sat behind it. From the outside it looked frozen at "Preparing… 0/0". From the inside it was working very hard on the wrong hardware.&lt;/p&gt;

&lt;p&gt;Nothing failed. That was the problem. A crash would have paged me in a minute. A fallback that "works" can burn for an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause hurt a little
&lt;/h2&gt;

&lt;p&gt;A few days earlier I shipped a reliability fix. Indexing used to run chunking inside the HTTP request, and it could get permanently stuck if the request died. So I moved the whole thing into a background Worker. Good change. I would do it again.&lt;/p&gt;

&lt;p&gt;But the API and the Worker are separate processes, each with its own &lt;code&gt;appsettings.json&lt;/code&gt;. The API config had the route:&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;"Routes"&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;"pdf.parse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai-pdf"&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;The Worker config did not.&lt;/p&gt;

&lt;p&gt;So when the parsing moved from the API into the Worker, the router looked up &lt;code&gt;pdf.parse&lt;/code&gt;, found nothing, and did what fallbacks do. It quietly handed a vision parsing job for a 106-page book to a local model on a CPU-only container.&lt;/p&gt;

&lt;p&gt;The work moved. The config did not. My reliability fix caused the regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One config block in the Worker's &lt;code&gt;appsettings.json&lt;/code&gt;, mirroring the API:&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;"Routes"&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;"pdf.parse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai-pdf"&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;plus the matching &lt;code&gt;Ai:Pdf&lt;/code&gt; settings block. No code change. The container dropped from 390% to idle, OpenAI traffic came back, the book indexed in minutes.&lt;/p&gt;

&lt;p&gt;Five minutes to write. Much longer to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bug hid so well
&lt;/h2&gt;

&lt;p&gt;Three reasons, and I think they generalize:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fallback doesn't fail. It succeeds with the wrong tool.&lt;/strong&gt; Every health check was green. The job was making progress. Slowly, expensively, but progress. There was no moment where the system could say "this is wrong", because by its own rules nothing was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody alerts on silence.&lt;/strong&gt; I had alerts for errors and for high latency on my API. I had nothing for "an expensive external provider we depend on received zero calls today". Absence of a signal is the hardest thing to notice, because there is nothing to point at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config lives per process, but my mental model didn't.&lt;/strong&gt; In my head there was one routing table. In reality there were two files, and they agreed only by accident, until they didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in how I work
&lt;/h2&gt;

&lt;p&gt;Three rules I took from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When work moves between processes, config moves with it.&lt;/strong&gt; That is now a checklist item on any "move X to the Worker" change: diff the relevant config sections between the two processes before shipping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make routing loud.&lt;/strong&gt; One log line per job that says which provider was resolved and why: &lt;code&gt;pdf.parse -&amp;gt; ollama (no route, DefaultProvider fallback)&lt;/code&gt;. That line would have turned an hour of confusion into a 30-second fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For expensive work, prefer fail-fast over fallback.&lt;/strong&gt; A missing route for a cheap task can fall back. A missing route for vision parsing of a whole book should throw. A silent fallback is a decision the system makes without telling you. If the choice of provider matters, it should not be made silently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable lesson on top: the bug was introduced by a fix I was proud of. Reliability work changes where code runs, and "where" is exactly what config is about.&lt;/p&gt;

&lt;p&gt;Where in your system could a fallback take over silently, and how long would it take you to notice? Mine took a 106-page PDF and a CPU graph. I would love to hear yours in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build TextStack, an open-source reader for technical books, in .NET. This bug is from the RAG indexing pipeline behind its "Ask this book" feature. &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>dotnet</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your RAG Eval Isn't Flaky. Your Retrieval Is Non-Deterministic.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:48:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/your-rag-eval-isnt-flaky-your-retrieval-is-non-deterministic-42ab</link>
      <guid>https://dev.to/mrviduus/your-rag-eval-isnt-flaky-your-retrieval-is-non-deterministic-42ab</guid>
      <description>&lt;p&gt;Same query.&lt;br&gt;
Same documents.&lt;br&gt;
Same model.&lt;br&gt;
And the RAG eval can still hand back a different Recall@8.&lt;/p&gt;

&lt;p&gt;Not because the model is flaky. Because of an &lt;code&gt;ORDER BY&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;I didn't find this by watching a metric wobble. I found it reading the retrieval code, and realized the score would drift run to run even if the model never changed.&lt;/p&gt;

&lt;p&gt;This came out of a habit I've adopted recently: I write the eval before the feature. Reviewing the retrieval pipeline behind my "Ask this Book" feature, I saw it: the retrieval layer wasn't deterministic.&lt;/p&gt;
&lt;h2&gt;
  
  
  Order isn't presentation. It's part of the input.
&lt;/h2&gt;

&lt;p&gt;My RAG implementation is intentionally simple: plain PostgreSQL and .NET. Two retrieval strategies over the same table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;semantic search using pgvector&lt;/li&gt;
&lt;li&gt;lexical search using PostgreSQL full-text search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The results are merged with Reciprocal Rank Fusion (RRF).&lt;/p&gt;

&lt;p&gt;Here's the important part: &lt;strong&gt;RRF doesn't care about the retrieval scores. It only cares about rank.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If one retriever returns&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A
B
C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B
A
C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF produces different fused scores. Different fused scores mean a different Top-K. Different Top-K means different Recall@K.&lt;/p&gt;

&lt;p&gt;In RRF, order isn't a display detail. Order is data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;My lexical query ended like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks perfectly reasonable. Except &lt;code&gt;ts_rank_cd&lt;/code&gt; produces ties surprisingly often. Multiple chunks can have exactly the same score.&lt;/p&gt;

&lt;p&gt;And SQL only guarantees the ordering you explicitly request. If multiple rows compare equal, PostgreSQL is free to return them in any order.&lt;/p&gt;

&lt;p&gt;Nothing changed. Same database. Same query. Same model. Only the order of equally-ranked rows. Yet that's enough for RRF to assign different ranks, producing different fused scores and a different evaluation result.&lt;/p&gt;

&lt;p&gt;The semantic retrieval had the same issue. Distance ties are much rarer than lexical ties, but "rare" isn't good enough for an evaluation pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix was almost embarrassingly small.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A deterministic tie-breaker on both retrieval queries. Now equal-scoring rows always appear in the same order, RRF receives the same input every run, and the Top-K stays identical.&lt;/p&gt;

&lt;p&gt;Notice what didn't happen. The retrieval didn't become better. &lt;strong&gt;It became reproducible.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;We spend a lot of effort making the model deterministic during evaluation: temperature 0, fixed datasets, golden answers, reproducible prompts.&lt;/p&gt;

&lt;p&gt;But it's easy to assume everything underneath the model is already deterministic. Often it isn't. Retrieval. Ranking. Sampling. Data loading. Any non-deterministic stage in the pipeline can quietly invalidate your eval.&lt;/p&gt;

&lt;p&gt;A fluctuating eval isn't just annoying. It's dangerous. Eventually you stop trusting the number, even when it's pointing at a real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I took away
&lt;/h2&gt;

&lt;p&gt;Before debugging the model, debug determinism. An evaluation can only be as deterministic as the pipeline feeding it. Same query. Same rows. Same order. Only then can you trust what your eval is telling you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;TextStack&lt;/a&gt;, an open-source reader for technical books, in .NET. This is from the retrieval layer behind its "Ask this Book" feature. Code on &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>rag</category>
      <category>postgres</category>
    </item>
    <item>
      <title>AI Wrote a Thread-Safe Counter. The CPU Made It 5x Slower.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-wrote-a-thread-safe-counter-the-cpu-made-it-5x-slower-45n6</link>
      <guid>https://dev.to/mrviduus/ai-wrote-a-thread-safe-counter-the-cpu-made-it-5x-slower-45n6</guid>
      <description>&lt;p&gt;&lt;em&gt;Meet the cache line.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I asked an AI assistant for a simple thing: &lt;strong&gt;per-thread counters.&lt;/strong&gt; Four threads, each incrementing its own slot in an array. No shared variables. No locks needed. The code it wrote was clean and correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// thread t does: counters[t]++&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every thread writes only to its own element. There is no race here. Any code review would pass it. Every test would pass too.&lt;/p&gt;

&lt;p&gt;Then I measured it against a version that does &lt;em&gt;exactly the same work&lt;/em&gt;, and the correct code lost by 5x.&lt;/p&gt;

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

&lt;p&gt;This is from my machine (Apple Silicon, .NET 10, 4 threads, 200 million increments per thread):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Round 1:  adjacent (one cache line):  464 ms   padded (line per thread):  85 ms   ratio: 5.4x
Round 2:  adjacent (one cache line):  397 ms   padded (line per thread):  85 ms   ratio: 4.7x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same loop. Same number of increments. Same "each thread touches only its own counter". The only difference between the two versions is &lt;em&gt;where the counters live in memory&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the hardware actually does
&lt;/h2&gt;

&lt;p&gt;A CPU never reads one byte from memory. It moves data in fixed blocks called &lt;strong&gt;cache lines&lt;/strong&gt; — 64 bytes on x86, 128 bytes on Apple Silicon. Ask for one &lt;code&gt;long&lt;/code&gt; and the whole block it lives in travels into the core's cache. Think of a cook whose ingredients are in a basement fridge: going downstairs is expensive, so you never carry one carrot — you carry the whole crate.&lt;/p&gt;

&lt;p&gt;(Don't take my word for the 128: run &lt;code&gt;sysctl hw.cachelinesize&lt;/code&gt; on an M-series Mac.)&lt;/p&gt;

&lt;p&gt;Usually this works for you. Array elements sit side by side, so scanning an array is fast: you touch one element and the next fifteen arrive in the same crate for free.&lt;/p&gt;

&lt;p&gt;But with multiple cores there is a rule: &lt;strong&gt;to write into a cache line, a core must own it exclusively.&lt;/strong&gt; The moment core 1 writes, every other core's copy of that line is declared stale. And the unit of ownership is not your variable. It is the whole line.&lt;/p&gt;

&lt;p&gt;Now look at my four counters. Four &lt;code&gt;long&lt;/code&gt; values, 8 bytes each, side by side — 32 bytes. They all fit in &lt;em&gt;one&lt;/em&gt; cache line. Thread 1 increments its counter and takes ownership of the line. A nanosecond later thread 2 increments &lt;em&gt;its own, different&lt;/em&gt; counter — and has to rip the same line back. The line ping-pongs between cores on every single write. Four threads that share nothing in the code are fighting over one crate in the hardware.&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;false sharing&lt;/strong&gt;. False, because no data is actually shared. The fight is real anyway.&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%2Fcvrlihqsni4dj3ke8x0t.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%2Fcvrlihqsni4dj3ke8x0t.png" alt="Four threads writing to four counters in one cache line ping-pong on every write (464 ms), versus padded layout with one counter per cache line (85 ms)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four counters in one cache line vs. one counter per line — same code, 5x difference.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The demo fix is one attribute
&lt;/h2&gt;

&lt;p&gt;Give every thread its own cache line. Pad each counter so the next one starts in a different line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;StructLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LayoutKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Explicit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;PaddedCounter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FieldOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire difference between 464 ms and 85 ms. We pay a little memory — 128 bytes per counter instead of 8 — and get back the parallelism we thought we already had. (I use 128, not 64, for two reasons: Apple Silicon lines are 128 bytes, and on x86 the adjacent-line prefetcher likes to drag neighboring lines along.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The full demo is about 70 lines, no project file needed — with the .NET 10 SDK you can run a single file directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;-c&lt;/span&gt; Release FalseSharingDemo.cs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FalseSharingDemo.cs — .NET 10 file-based app&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Runtime.InteropServices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;Iterations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;200_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessorCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;MeasureAdjacent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;MeasurePadded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"adjacent: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalMilliseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;F0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms   padded: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalMilliseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;F0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms   ratio: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;F1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="nf"&gt;MeasureAdjacent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;RunThreads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;Iterations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
            &lt;span class="n"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]++;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="nf"&gt;MeasurePadded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaddedCounter&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;RunThreads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;Iterations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
            &lt;span class="n"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="nf"&gt;RunThreads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sw&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartNew&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;GC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;KeepAlive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Elapsed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;StructLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LayoutKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Explicit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;PaddedCounter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FieldOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where this hides in real code
&lt;/h2&gt;

&lt;p&gt;You will not write four counters in a loop at work. But you will write, or an AI will write for you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache statistics.&lt;/strong&gt; Almost every in-memory cache keeps hits, misses, evictions. The natural implementation is fields next to each other, or a &lt;code&gt;long[]&lt;/code&gt; with one slot per shard, updated with &lt;code&gt;Interlocked.Increment&lt;/code&gt; from every thread. Fields next to each other means one cache line. This is literally the demo above, running in your production service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharded counters.&lt;/strong&gt; The cruel version: you sharded a counter &lt;em&gt;specifically to make it parallel&lt;/em&gt;, put the shards in one array — and they still share lines. You did the architecture work and the hardware quietly undid it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LRU metadata.&lt;/strong&gt; A compact &lt;code&gt;long[] lastAccessTicks&lt;/code&gt; per cache slot means every cache &lt;em&gt;read&lt;/em&gt; becomes a &lt;em&gt;write&lt;/em&gt; into a hot shared array. A read-heavy cache that is slow because of writes is a fun thing to debug.&lt;/p&gt;

&lt;p&gt;And sometimes the problem ships inside the library. &lt;code&gt;ConcurrentDictionary&lt;/code&gt; — the base of most homemade .NET caches — internally keeps a counter per lock stripe in a plain array (&lt;code&gt;_countPerLock&lt;/code&gt; in the source). Under very hot multi-threaded writes those neighbors &lt;em&gt;can&lt;/em&gt; end up sharing lines. I have not benchmarked the real-world impact — but the layout is right there in the source, and now you know what to look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should NOT care
&lt;/h2&gt;

&lt;p&gt;Honesty section. False sharing hurts when the writes are &lt;em&gt;hot&lt;/em&gt; — millions of updates per second from several threads. If your cache updates its stats a thousand times per second, you will never notice, and padding everything "just in case" is cargo cult. The rule is the same as always: measure first. The demo above is the measurement; adapt it to your data layout and see if your ratio is 1.0x or 5x.&lt;/p&gt;

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

&lt;p&gt;The AI-generated code was not wrong. It compiled, it was race-free, it passed every test I could write for its correctness. An entire code review process could bless it. The 5x was invisible at every layer we normally check.&lt;/p&gt;

&lt;p&gt;That is what changed with AI-assisted coding, and it is why hardware fundamentals became more valuable, not less. The model will happily generate a thread-safe counter, a sharded cache, an LRU eviction policy — and none of its correctness guarantees say anything about cache lines. Correctness and mechanical sympathy are different layers. Tests catch the first. Only understanding catches the second.&lt;/p&gt;

&lt;p&gt;You do not need to memorize cache sizes. You need to know the crate exists. Keep data that is used together close. Keep data that is &lt;em&gt;written by different threads&lt;/em&gt; apart. That one rule, read in both directions, is most of "cache-aware" programming.&lt;/p&gt;

&lt;p&gt;The tools write the code now. Knowing why it is slow is still our job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://vasyl.blog/2026/07/03/false-sharing-meet-the-cache-line/" rel="noopener noreferrer"&gt;vasyl.blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>performance</category>
    </item>
    <item>
      <title>An AI Feature Has No "Tests Pass" Moment. So I Write the Eval First.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 23 Jun 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/an-ai-feature-has-no-tests-pass-moment-so-i-write-the-eval-first-1f7p</link>
      <guid>https://dev.to/mrviduus/an-ai-feature-has-no-tests-pass-moment-so-i-write-the-eval-first-1f7p</guid>
      <description>&lt;p&gt;I was building an "Ask This Book" feature: readers can ask questions about a book while they're reading it.&lt;/p&gt;

&lt;p&gt;One requirement sounded simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A reader on chapter 3 must never receive spoilers from chapter 30.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first instinct was the same as everyone else's: tell the model not to spoil future chapters. Something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Please don't reveal information from chapters the reader hasn't reached yet."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And honestly, it mostly worked.&lt;/p&gt;

&lt;p&gt;The problem is that "mostly" is useless. A user only needs one spoiler.&lt;/p&gt;

&lt;p&gt;That was the moment I realized the feature had no definition of done.&lt;/p&gt;

&lt;p&gt;With normal software, something pushes back. The compiler complains. The tests fail. The types don't line up.&lt;/p&gt;

&lt;p&gt;With an LLM feature, none of that happens. The output looks plausible by default — fluent, confident, well formatted — even when it's wrong.&lt;/p&gt;

&lt;p&gt;So "it looked right in the demo" quietly becomes the finish line.&lt;/p&gt;

&lt;p&gt;That's exactly why I write the eval before I write the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eval Is the Specification
&lt;/h2&gt;

&lt;p&gt;Most teams treat evals as QA. Build the feature, ship something that works, add evals later.&lt;/p&gt;

&lt;p&gt;I increasingly think that's backwards. For AI systems, the eval is often the only concrete definition of success.&lt;/p&gt;

&lt;p&gt;The moment I wrote the spoiler eval, I had to define failure: spoiler leakage must be zero. Not low. Not acceptable. Zero.&lt;/p&gt;

&lt;p&gt;And that requirement immediately exposed a problem. No prompt can guarantee zero.&lt;/p&gt;

&lt;p&gt;Prompts are probabilistic. Users can phrase questions differently. Models can interpret instructions differently. Future model updates can behave differently. You cannot get a hard guarantee from a soft instruction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eval Changed the Architecture
&lt;/h2&gt;

&lt;p&gt;Once the eval demanded zero spoilers, the solution stopped being a prompt problem. It became a retrieval problem.&lt;/p&gt;

&lt;p&gt;Instead of telling the model not to reveal future chapters, I prevented future chapters from entering the context at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;chapter_ord&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;maxChapterOrd&lt;/span&gt;&lt;span class="nv"&gt;`&lt;/span&gt;&lt;span class="se"&gt;``&lt;/span&gt;&lt;span class="nv"&gt;

Anything beyond the reader's progress never enters the retrieval set. The model can't leak information it never saw.

And the eval that checks it is just as blunt — a retrieved chunk past the reader's progress is a leak:



&lt;/span&gt;&lt;span class="se"&gt;``&lt;/span&gt;&lt;span class="nv"&gt;`&lt;/span&gt;&lt;span class="n"&gt;csharp&lt;/span&gt;
&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;One&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="n"&gt;past&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="s1"&gt;'s progress = one spoiler leak.
public static int LeakCount(IEnumerable&amp;lt;RetrievedChunk&amp;gt; retrieved, int gateChapterOrd) =&amp;gt;
    retrieved.Count(c =&amp;gt; c.ChapterOrd &amp;gt; gateChapterOrd);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across the adversarial test cases, that number has to be zero. That's the moment the idea really clicked for me: the eval didn't test the design. It produced the design.&lt;/p&gt;

&lt;p&gt;A measurable failure condition forced a better architecture than I would have built if I had started with prompt engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Thing Happened to Retrieval Quality
&lt;/h2&gt;

&lt;p&gt;The spoiler requirement wasn't the only eval. I also defined two other targets before building the feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retrieval must surface the correct passage near the top of the results.&lt;/li&gt;
&lt;li&gt;Answers must remain grounded in the passages they cite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because those requirements were measurable, every change received a verdict instead of an opinion.&lt;/p&gt;

&lt;p&gt;A single semantic search wasn't clearing the bar. So I ended up combining two retrieval approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vector search for semantic similarity&lt;/li&gt;
&lt;li&gt;full-text search for exact names, phrases, and quotations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The results are fused using Reciprocal Rank Fusion — less mysterious than it sounds. Each chunk scores the sum of 1/(k+rank) across the lists it appears in, so anything ranked highly by both retrievers floats to the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ranked highly by both vector AND lexical -&amp;gt; floats to the top.&lt;/span&gt;
&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// i is 0-based; RRF rank is 1-based&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't choose hybrid retrieval because it's fashionable. I chose it because it moved the number. The eval said the system wasn't good enough. The architecture changed until it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on the Stack
&lt;/h2&gt;

&lt;p&gt;None of this is a no-dependencies flex. The judge that scores grounding is a custom evaluator on Microsoft.Extensions.AI.Evaluation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RubricEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rubric&lt;/span&gt; &lt;span class="n"&gt;rubric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEvaluator&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I lean on the Microsoft stack on purpose. What I keep hand-rolled is the part that decides quality — the retrieval, the fusion, the spoiler gate. The line I draw isn't "no libraries." It's no agent framework hiding the parts that determine whether the thing actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eval-First Development
&lt;/h2&gt;

&lt;p&gt;Traditional software development gives us confidence almost for free. Compilers. Type systems. Unit tests. Integration tests.&lt;/p&gt;

&lt;p&gt;AI systems don't. The difficult part isn't implementing the feature. The difficult part is defining what "correct" means.&lt;/p&gt;

&lt;p&gt;That's why I increasingly think of eval-first development as the AI equivalent of TDD. With traditional software, tests verify the implementation. With AI systems, evals often define the implementation.&lt;/p&gt;

&lt;p&gt;Build the feature first and the eval later, and the eval can only grade what you've already built. Build the eval first and it starts shaping the system itself.&lt;/p&gt;

&lt;p&gt;It defines done. It tells you when you've regressed. And sometimes it forces a better architecture than the one you originally had in mind.&lt;/p&gt;

&lt;p&gt;Otherwise you're not shipping a feature. You're shipping a guess that happened to demo well.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want to go deeper on evals? I've written a separate, more hands-on series on building production AI on .NET: &lt;a href="https://vasyl.blog/2026/06/10/what-are-ai-evals/" rel="noopener noreferrer"&gt;what evals actually are&lt;/a&gt;, &lt;a href="https://vasyl.blog/2026/06/10/error-analysis-for-evals/" rel="noopener noreferrer"&gt;error analysis&lt;/a&gt;, &lt;a href="https://vasyl.blog/2026/06/10/golden-datasets-that-dont-lie/" rel="noopener noreferrer"&gt;golden datasets&lt;/a&gt;, &lt;a href="https://vasyl.blog/2026/06/10/llm-as-judge-done-right/" rel="noopener noreferrer"&gt;LLM-as-judge&lt;/a&gt;, and &lt;a href="https://vasyl.blog/2026/06/10/evals-in-ci-and-production/" rel="noopener noreferrer"&gt;evals in CI and production&lt;/a&gt;. This post was originally published on &lt;a href="https://vasyl.blog/2026/06/17/evals-before-rag/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>AI Evals, Part 5: From a Number to a Gate Evals in CI and Production</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Wed, 17 Jun 2026 17:43:25 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-evals-part-5-from-a-number-to-a-gate-evals-in-ci-and-production-1j33</link>
      <guid>https://dev.to/mrviduus/ai-evals-part-5-from-a-number-to-a-gate-evals-in-ci-and-production-1j33</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5, the finale, of a series on building production AI on .NET. We've built the pieces — &lt;a href="https://vasyl.blog/what-are-ai-evals/" rel="noopener noreferrer"&gt;what evals are&lt;/a&gt;, &lt;a href="https://vasyl.blog/error-analysis-for-evals/" rel="noopener noreferrer"&gt;error analysis&lt;/a&gt;, &lt;a href="https://vasyl.blog/golden-datasets-that-dont-lie/" rel="noopener noreferrer"&gt;golden datasets&lt;/a&gt;, and a &lt;a href="https://vasyl.blog/llm-as-judge-done-right/" rel="noopener noreferrer"&gt;trustworthy judge&lt;/a&gt;. Now we make them earn their keep.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By now you can produce a defensible quality score for an AI feature. But a score you only &lt;em&gt;look at&lt;/em&gt; is a vanity metric. The entire point of all that work is to make quality something your engineering process &lt;strong&gt;acts on automatically&lt;/strong&gt; — the same way a failing unit test stops a bad commit. That means two homes for your evals: a &lt;strong&gt;gate&lt;/strong&gt; before you ship, and &lt;strong&gt;monitoring&lt;/strong&gt; after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Home 1: CI — a safety net against regressions
&lt;/h2&gt;

&lt;p&gt;Because TextStack's judge is a custom &lt;code&gt;IEvaluator&lt;/code&gt; on Microsoft.Extensions.AI.Evaluation, an eval is just a &lt;code&gt;dotnet test&lt;/code&gt;. The MEAI evaluator emits the rubric's axes plus an overall as numeric metrics, and a quality &lt;em&gt;floor&lt;/em&gt; is expressed as a Pass/Fail interpretation on the overall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the evaluator: the overall metric is interpreted Pass/Fail against a floor.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overallFloor&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Interpretation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EvaluationMetricInterpretation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;RatingFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mean&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"floor &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (mean &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0.00&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches &lt;em&gt;gross&lt;/em&gt; breakage — "something is badly wrong." But the more valuable gate is &lt;strong&gt;relative&lt;/strong&gt;: store a baseline score per feature, and fail the build when a change drops quality by more than a threshold versus that baseline. That turns "did this prompt change help?" into a red/green answer and makes improving a prompt a tight loop — change, run, compare, keep or revert. It's the AI equivalent of TDD.&lt;/p&gt;

&lt;p&gt;Honest status from our codebase: the floor and on-demand runs exist today; the automatic &lt;em&gt;baseline-versus-regression&lt;/em&gt; gate is the next step. I'm flagging that deliberately, because plenty of "we do eval-driven development" claims are really "we have a number nobody gates on." The hard 80% — the measuring instrument — is built; wiring the ratchet is the lighter remaining 20%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint CI forces: evals cost money
&lt;/h2&gt;

&lt;p&gt;Every eval case is a real generation &lt;strong&gt;plus&lt;/strong&gt; a real judge call. Running the full suite on every commit is slow and expensive, so evals have to be deliberate. TextStack's are &lt;strong&gt;opt-in&lt;/strong&gt;: tagged so default CI skips them, and they self-skip when the provider isn't configured.&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="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;… dotnet &lt;span class="nb"&gt;test &lt;/span&gt;tests/TextStack.AiEvals &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="nv"&gt;Category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Eval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default CI stays green and free; the expensive truth runs on purpose. The pragmatic pattern: a small, cheap subset on pull requests for a fast signal, and the full suite nightly or pre-release. Treat eval spend like any cloud cost — budget it, don't let it run unbounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Home 2: Production — monitoring and guardrails
&lt;/h2&gt;

&lt;p&gt;A curated golden set, however good, is a snapshot of inputs you &lt;em&gt;imagined&lt;/em&gt;. Production sends inputs you didn't. So the offline gate is only half the system; the other half runs against live traffic.&lt;/p&gt;

&lt;p&gt;This is where evals and observability become one thing. Every AI call in TextStack is tagged with its feature and recorded — cost, latency, tokens, errors — and runs persist to an &lt;code&gt;eval_runs&lt;/code&gt; table surfaced on an internal &lt;strong&gt;&lt;code&gt;/ai-quality&lt;/code&gt;&lt;/strong&gt; dashboard (Traces and Evals tabs), with an admin "Run evals" button to trigger the suite on demand. Because the judge is the &lt;em&gt;same&lt;/em&gt; component offline and online, you can sample real outputs per feature and score them with the identical rubric. Two modes fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background monitoring&lt;/strong&gt; — sample a slice of live outputs, judge them, and watch the score over time to catch drift before users complain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails&lt;/strong&gt; — for high-stakes outputs, judge &lt;em&gt;in the critical path&lt;/em&gt; and block, retry, or fall back when a result fails. (Use sparingly: it adds a judge call's worth of latency and cost to the request.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The flywheel
&lt;/h2&gt;

&lt;p&gt;Put the two homes together and you get a loop that compounds. Production surfaces a new failure mode → you do error analysis on it → it becomes a new golden case → your gate now defends against it → quality climbs → cleaner output produces cleaner traffic. Each turn makes the next regression harder to ship. That continuous-improvement flywheel — not any single dashboard — is the real product of an eval system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A number nobody gates on&lt;/strong&gt; — if a bad score can't fail a build or page someone, it's decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed floor mistaken for a regression gate&lt;/strong&gt; — a floor catches breakage, not a 2%-worse change. You want both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evals on every commit&lt;/strong&gt; — the bill and the wait will kill the habit; subset on PRs, full suite nightly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline-only&lt;/strong&gt; — you'll ship regressions from inputs your golden set never imagined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails everywhere&lt;/strong&gt; — judging in the critical path is powerful but costs latency; reserve it for outputs that matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online scores you never read&lt;/strong&gt; — monitoring you don't look at is just a more expensive log.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The series, in one line each
&lt;/h2&gt;

&lt;p&gt;That's the whole discipline, start to finish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Evals are the test suite for non-deterministic code&lt;/strong&gt; — graded judgement over a representative sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error analysis comes first&lt;/strong&gt; — read your failures and name them; the taxonomy decides what to measure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The golden set is the ruler&lt;/strong&gt; — representative, leak-free, fresh, and run through the &lt;em&gt;real&lt;/em&gt; prompt and gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The judge is a model too&lt;/strong&gt; — defensive, dedicated, routed, and validated against humans with Cohen's κ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A score must become a gate&lt;/strong&gt; — CI to catch regressions before ship, monitoring to catch drift after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it requires Python or a heavyweight platform. On .NET it's an &lt;code&gt;ILlmService&lt;/code&gt; seam, a golden dataset in JSON, a custom &lt;code&gt;IEvaluator&lt;/code&gt; on Microsoft.Extensions.AI.Evaluation, and an opt-in test category — built on a real product, in production. Done right, evals turn &lt;em&gt;"I think this AI feature is fine"&lt;/em&gt; into &lt;em&gt;"I can prove it, and I'll know the moment it stops being true."&lt;/em&gt; That's the difference between shipping AI and gambling with it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;TextStack is a reader that helps you finish the dense technical book you keep quitting — it builds every modern AI primitive (observability, evals, RAG, agents) as a real production feature on .NET. Try it at &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt;, or read the code at &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>llm</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>AI Evals, Part 4: LLM-as-Judge, Done Right</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Wed, 17 Jun 2026 17:28:22 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-evals-part-4-llm-as-judge-done-right-31eg</link>
      <guid>https://dev.to/mrviduus/ai-evals-part-4-llm-as-judge-done-right-31eg</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of a series on building production AI on .NET. We've covered &lt;a href="https://vasyl.blog/what-are-ai-evals/" rel="noopener noreferrer"&gt;what evals are&lt;/a&gt;, &lt;a href="https://vasyl.blog/error-analysis-for-evals/" rel="noopener noreferrer"&gt;error analysis&lt;/a&gt;, and &lt;a href="https://vasyl.blog/golden-datasets-that-dont-lie/" rel="noopener noreferrer"&gt;golden datasets&lt;/a&gt;. Now: how do you turn a paragraph into a number you can trust?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have a golden dataset and your feature's real output for each case. Now you need a score. But you can't &lt;code&gt;assert ==&lt;/code&gt; two paragraphs — there's no single right answer, and exact-match comparison is meaningless for prose. String-similarity metrics (BLEU, ROUGE) don't help either; they reward overlapping words, not correct meaning.&lt;/p&gt;

&lt;p&gt;The pragmatic answer the field has converged on is &lt;strong&gt;LLM-as-judge&lt;/strong&gt;: use a second, capable model to read the reference and the actual output and score it against a rubric. It's powerful, it scales, and — handled carelessly — it will hand you confident, biased numbers that feel rigorous and aren't. This post is about doing it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic shape
&lt;/h2&gt;

&lt;p&gt;A judge takes the rubric and an &lt;em&gt;evidence&lt;/em&gt; block (the inputs, the reference answer, and the model's actual output), and returns a structured verdict. In TextStack the judge is one feature-agnostic component built on &lt;a href="https://learn.microsoft.com/dotnet/ai/conceptual/evaluation-libraries" rel="noopener noreferrer"&gt;Microsoft.Extensions.AI.Evaluation&lt;/a&gt; — Microsoft's official .NET evaluation library — implemented as a custom &lt;code&gt;IEvaluator&lt;/code&gt;. The core is a single judge call asking for strict JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;"You are a strict, fair evaluator of an AI feature's output. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Score each of three dimensions on an integer scale 1-5 (5 = excellent, 1 = poor):\n"&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;$"- d1 = &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rubric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dim1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\n- d2 = &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rubric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dim2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\n- d3 = &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rubric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dim3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\n"&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Return ONLY strict JSON: {\"d1\": int, \"d2\": int, \"d3\": int, \"rationale\": \"...\"}"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rubric is a &lt;strong&gt;parameter, not a hardcode&lt;/strong&gt; — three named axes passed in per feature. That's what lets one judge score Explain, Translate, distractors, and book metadata, each on the dimensions its own error analysis surfaced (Explain → accuracy / conciseness / usefulness; Translate → accuracy / fluency / register; and so on). One judge, many rubrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that separate a toy judge from a production one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Parse defensively.&lt;/strong&gt; Judges wrap their JSON in prose or code fences no matter how firmly you forbid it. Don't trust the whole string — extract the first &lt;code&gt;{…}&lt;/code&gt; span:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'{'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JudgeScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"unparseable: no JSON object"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fail to a number, not an exception.&lt;/strong&gt; An unparseable or failed judge call returns a zero score with the reason attached, which drags the run's mean &lt;em&gt;down&lt;/em&gt; instead of crashing it. A judge that silently throws is worse than one that scores zero — the zero is a visible signal you can investigate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a dedicated, stronger judge — and route it like everything else.&lt;/strong&gt; The model that &lt;em&gt;judges&lt;/em&gt; should be more capable than the models that &lt;em&gt;generate&lt;/em&gt;. TextStack generates features on small, cheap models but judges with a &lt;code&gt;gpt-4.1&lt;/code&gt;-class model. And the judge call carries the same &lt;code&gt;eval.judge&lt;/code&gt; feature tag and flows through the same gateway as production traffic, so it's traced and cost-accounted like any other call. Evaluating is itself an AI feature; treat it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biases that quietly wreck your judge
&lt;/h2&gt;

&lt;p&gt;This is the part that separates people who &lt;em&gt;use&lt;/em&gt; an LLM judge from people who can &lt;em&gt;trust&lt;/em&gt; one. A judge is a language model, and it brings model-shaped biases to grading. Ignore them and your scores are precise and wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Position bias.&lt;/strong&gt; In pairwise comparisons ("is A or B better?"), judges favour whichever answer appears first (sometimes second) regardless of content. &lt;em&gt;Mitigation:&lt;/em&gt; run each comparison both ways and average, or randomise order and watch the swap rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verbosity bias.&lt;/strong&gt; Judges reliably prefer longer, more elaborate answers even when the extra words add nothing — actively harmful for a feature like Explain whose rubric &lt;em&gt;demands&lt;/em&gt; conciseness. &lt;em&gt;Mitigation:&lt;/em&gt; name length explicitly in the rubric and watch for score creeping up with token count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-preference bias.&lt;/strong&gt; A judge scores text from its own model family higher. I'll be concrete about where TextStack sits here: features generated on a local model (distractors, book metadata) are judged cross-family by OpenAI — good, that's independent. But Explain and Translate are generated &lt;em&gt;and&lt;/em&gt; judged within the OpenAI family (different sizes — &lt;code&gt;gpt-4.1-nano&lt;/code&gt; to generate, &lt;code&gt;gpt-4.1&lt;/code&gt; to judge — but the same lineage), so some self-preference is still in play. The honest read: the absolute number is treated as soft; the &lt;em&gt;deltas between runs&lt;/em&gt; are what we trust. A fully independent second judge is on the roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sycophancy and scale compression.&lt;/strong&gt; Judges drift toward agreeable, middling scores, clustering around 3–4 on a 1–5 scale and flattening your signal. &lt;em&gt;Mitigation:&lt;/em&gt; anchor each dimension with a concrete description (not just a one-word label), always give the judge the reference answer as a yardstick, and consider a coarser scale if the judge can't use the full range reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your judge needs its own eval
&lt;/h2&gt;

&lt;p&gt;Here's the step almost everyone skips: &lt;strong&gt;validate the judge against humans.&lt;/strong&gt; You wouldn't ship a feature on an unvalidated model, and a judge &lt;em&gt;is&lt;/em&gt; a model — so prove it agrees with human judgement before you trust its scores.&lt;/p&gt;

&lt;p&gt;Hand-label a sample of outputs yourself, then measure agreement between you and the judge. The right metric is &lt;strong&gt;inter-rater agreement&lt;/strong&gt; — Cohen's κ (kappa), which corrects for the agreement you'd get by chance — not raw percent-agreement, which flatters you when scores cluster. A judge around κ ≥ 0.6 against human labels is usable; near zero means it's rolling dice and your whole pipeline is theatre. Re-check it whenever you change the judge model or the rubric.&lt;/p&gt;

&lt;p&gt;There's a design subtlety worth applying here: treat the &lt;em&gt;judge prompt itself&lt;/em&gt; as something you iterate on against a labelled split. Tune the judge prompt on one slice of human-labelled cases, validate κ on a held-out slice — exactly the train/test discipline from the last post, applied one level up. The judge is software; it deserves the same rigour as the feature it grades.&lt;/p&gt;

&lt;p&gt;This closes a loop people miss. The golden set evaluates the feature; a human-labelled slice evaluates the judge. Skip the second and you've just moved your trust problem one level up and hidden it from yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trusting an unvalidated judge&lt;/strong&gt; — measure κ against human labels or it's theatre.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same model generating and judging&lt;/strong&gt; — self-preference inflates the score; prefer a different (ideally cross-family) judge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A weak judge model&lt;/strong&gt; — the judge should be &lt;em&gt;more&lt;/em&gt; capable than the generator, not the same one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring position/verbosity bias&lt;/strong&gt; — randomise order, penalise padding, anchor the rubric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-word rubric axes&lt;/strong&gt; — "accuracy" alone means different things to the model each run; describe it concretely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throwing on a bad verdict&lt;/strong&gt; — score it zero and surface it; don't let one parse failure kill the run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge is the only practical way to score prose at scale, but a judge is a model with a model's biases — so build it like production code (defensive parsing, a dedicated stronger model, routed and traced) and validate it like a model (human labels, Cohen's κ, a tuned-and-tested judge prompt). Do that and your scores mean something. Skip it and you've automated the production of confident nonsense.&lt;/p&gt;

&lt;p&gt;Next, and last in the series: &lt;strong&gt;from a number to a gate&lt;/strong&gt; — wiring evals into CI and online monitoring so quality regressions turn the build red, on Microsoft.Extensions.AI.Evaluation, without bankrupting your pipeline.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;TextStack is a reader that helps you finish the dense technical book you keep quitting — it builds every modern AI primitive (observability, evals, RAG, agents) as a real production feature on .NET. Try it at &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt;, or read the code at &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>llm</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>AI Evals, Part 3: Golden Datasets That Dont Lie</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 16 Jun 2026 21:28:24 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-evals-part-3-golden-datasets-that-dont-lie-3fog</link>
      <guid>https://dev.to/mrviduus/ai-evals-part-3-golden-datasets-that-dont-lie-3fog</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of a series on building production AI on .NET. &lt;a href="https://vasyl.blog/what-are-ai-evals/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; was the overview; &lt;a href="https://vasyl.blog/error-analysis-for-evals/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; was error analysis. Now we turn the failure taxonomy you built into something you can measure against — without quietly fooling yourself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A golden dataset is a set of representative inputs, each paired with a reference answer a knowledgeable human would accept. It's the ruler you hold every model output against. And it is, in my experience, the single most important and most neglected asset in an eval pipeline — because a sloppy ruler doesn't announce itself. Your scores still come out green. They're just measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;This post is about building a golden set that tells the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;In TextStack, each AI feature has ~30 hand-curated cases stored as plain JSON, loaded at runtime into a typed record that mirrors exactly what the production endpoint receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ExplainGolden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Sentence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Genre&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TargetLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ExpectedExplanation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain JSON on disk, deserialised case-insensitively. No database, no platform lock-in — the dataset is a checked-in artifact you can diff in code review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;goldens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoldenData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExplainGolden&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"explain.json"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The format is the easy part. The honesty is in four properties of the &lt;em&gt;content&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Representativeness — mirror reality, not the demo
&lt;/h2&gt;

&lt;p&gt;Your set should reflect the real distribution of inputs your feature meets in production, including the hard, weird, and adversarial cases. This is where Part 2 pays off: the failure taxonomy tells you which kinds of input break things, so you deliberately stock the set with them.&lt;/p&gt;

&lt;p&gt;The opposite — a set of only easy, happy-path cases — is the most common way an eval lies. The model aces them, your average climbs, and meanwhile the inputs that actually matter never get measured. Stratify on purpose: domains, lengths, languages, edge cases. For TextStack's Explain set that means technical passages &lt;em&gt;and&lt;/em&gt; casual prose, common words &lt;em&gt;and&lt;/em&gt; rare ones, several target languages — not thirty variations of the same easy lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reference quality — the ceiling you measure against
&lt;/h2&gt;

&lt;p&gt;The reference answer defines what "good" means for that case, so a lazy reference caps the meaning of your whole score. If the reference for explaining &lt;em&gt;idempotent&lt;/em&gt; is a paraphrased dictionary entry, your judge will happily reward dictionary entries — the exact failure mode you were trying to eliminate.&lt;/p&gt;

&lt;p&gt;References should be written or vetted by someone who understands the domain. For Explain, that means genuinely good in-context explanations: what the word means &lt;em&gt;here&lt;/em&gt;, in &lt;em&gt;this&lt;/em&gt; sentence, the way you'd want it explained to you. The reference is the bar; set it where you actually want the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Leakage — keep a real train/test split
&lt;/h2&gt;

&lt;p&gt;Here's the subtle statistical sin. If you tune your prompt against the same cases you score against, you're overfitting to the test, and your number is fiction — you've optimised for those thirty examples, not for the feature. It's the prompt-engineering version of training on your test set.&lt;/p&gt;

&lt;p&gt;Keep a slice you never look at while iterating. Tune on one part; report on the held-out part. This feels heavy for thirty cases, but the discipline is what keeps the score meaningful as you iterate. The split is just as real for prompts as it is for model weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Size and freshness — a floor, and a living asset
&lt;/h2&gt;

&lt;p&gt;Thirty cases is a deliberate &lt;em&gt;floor&lt;/em&gt;, not a target: enough to catch gross regressions cheaply, small enough to run often and to keep every reference high quality. (It's statistically thin for detecting small changes — that's the next post's problem.) More important than size is that the set is &lt;strong&gt;alive&lt;/strong&gt;: every new failure mode you find in production should earn a new case. A golden set that never changes slowly stops resembling reality, and a stale ruler is a lying ruler.&lt;/p&gt;

&lt;p&gt;When you genuinely lack real examples — a brand-new feature with no traffic — you can bootstrap with &lt;em&gt;synthetic&lt;/em&gt; cases (have a strong model generate realistic inputs across your taxonomy's dimensions). It's a legitimate starting point, but treat it as scaffolding: replace synthetic cases with real ones as traffic arrives, because real users are more creative than any generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent killer: dataset drift from production
&lt;/h2&gt;

&lt;p&gt;Now the trap that quietly invalidates an otherwise perfect golden set, and the one I'd most want a reviewer to check for.&lt;/p&gt;

&lt;p&gt;You write your feature's prompt in the API endpoint. You write the eval, and — naturally — you write the prompt &lt;em&gt;again&lt;/em&gt; in the test. Two copies. Someone tweaks the production prompt for a hotfix and doesn't touch the test copy. From that moment your eval measures a prompt &lt;strong&gt;that no longer exists in production&lt;/strong&gt;. The score stays green; the product changed underneath it. Nobody notices, because the test reports with total confidence.&lt;/p&gt;

&lt;p&gt;The fix is structural, not disciplinary: extract the prompt into one builder that &lt;em&gt;both&lt;/em&gt; production and the eval call. There is no second copy to drift.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Built once, called by BOTH the endpoint and the eval — they cannot disagree.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExplainPrompt&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;BuildSystemPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;genre&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;targetLang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;BuildUserPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The eval's case-to-request mapping wires that shared builder straight in, and crucially the request goes through the &lt;strong&gt;same model gateway&lt;/strong&gt; production uses, selected by the feature's tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;LlmRequest&lt;/span&gt; &lt;span class="nf"&gt;ToRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ExplainGolden&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SystemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExplainPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BuildSystemPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Genre&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TargetLang&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LlmMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ExplainPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BuildUserPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sentence&lt;/span&gt;&lt;span class="p"&gt;))],&lt;/span&gt;
    &lt;span class="n"&gt;MaxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FeatureTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"explain"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same routing, same model, same path as prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you remember one thing from this post: &lt;strong&gt;an eval that runs a copy of the prompt is worse than no eval, because it manufactures false confidence.&lt;/strong&gt; Same prompt, same gateway, same path — or you're measuring a ghost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A happy-path-only set&lt;/strong&gt; — the score rises while the product falls. Stock it from your failure taxonomy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak reference answers&lt;/strong&gt; — they cap your score's meaning and can reward the very failure you're chasing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train/test leakage&lt;/strong&gt; — tuning and scoring on the same cases overfits to fiction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A frozen set&lt;/strong&gt; — inputs drift; a dataset that never grows slowly measures a product that no longer exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic-forever&lt;/strong&gt; — fine to bootstrap, dangerous to rely on; real traffic is weirder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A duplicated prompt&lt;/strong&gt; — the drift trap. One shared builder, through the real gateway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A golden dataset is not a formality you generate once and forget. It's a carefully curated, honestly-split, continuously-refreshed ruler — and it has to run the &lt;em&gt;real&lt;/em&gt; prompt through the &lt;em&gt;real&lt;/em&gt; path or it measures nothing. Get the dataset right and every downstream number means something. Get it wrong and you've built an instrument that lies to you in green.&lt;/p&gt;

&lt;p&gt;Next in the series: &lt;strong&gt;LLM-as-judge, done right&lt;/strong&gt; — how to turn a paragraph into a trustworthy number, the biases that wreck judges, and why your judge needs its own eval.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;TextStack is a reader that helps you finish the dense technical book you keep quitting — it builds every modern AI primitive (observability, evals, RAG, agents) as a real production feature on .NET. Try it at &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt;, or read the code at &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>llm</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>AI Evals, Part 2: Error Analysis The Unglamorous Superpower Behind Good Evals</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Fri, 12 Jun 2026 22:46:23 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-evals-part-2-error-analysis-the-unglamorous-superpower-behind-good-evals-4k2h</link>
      <guid>https://dev.to/mrviduus/ai-evals-part-2-error-analysis-the-unglamorous-superpower-behind-good-evals-4k2h</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of a series on building production AI on .NET. &lt;a href="https://vasyl.blog/what-are-ai-evals/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; covered what evals are and the Analyze → Measure → Improve lifecycle. This post is about the step everyone wants to skip: **Analyze&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a team decides to "take evals seriously," the first thing they usually do is wrong. They open a dashboard tool, wire up a generic "correctness" score, and watch a number. It feels productive. It produces a chart. And it tells them almost nothing, because they skipped the step that decides &lt;em&gt;what the chart should even measure.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That step is &lt;strong&gt;error analysis&lt;/strong&gt;: reading your AI's actual outputs and naming, precisely, the ways they go wrong. It's unglamorous — no library, no dashboard, just you and a few dozen real examples. It is also, by a wide margin, the highest-leverage thing you will do in evals: error analysis is where the signal comes from. Everything downstream is just operationalising what you find here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't skip straight to metrics
&lt;/h2&gt;

&lt;p&gt;There's a gap between you and your running system that's easy to underestimate. Thousands of inputs flow through your AI feature daily, in shapes you never anticipated, and you have no realistic way to &lt;em&gt;see&lt;/em&gt; them at scale. Call it the &lt;strong&gt;comprehension gap&lt;/strong&gt; — the distance between the developer and a true understanding of what the data and the model are actually doing.&lt;/p&gt;

&lt;p&gt;Metrics don't bridge that gulf; they presuppose it's already bridged. To measure "conciseness" you must first have &lt;em&gt;noticed&lt;/em&gt; that verbosity is a failure mode worth caring about. If you pick your metrics before you've read your data, you're measuring your assumptions, not your product. The classic result: a dashboard glowing green while users quietly churn over a problem your metrics were never designed to catch.&lt;/p&gt;

&lt;p&gt;Error analysis is how you cross the gulf. You trade scale for truth — you can't read everything, so you read a &lt;em&gt;sample&lt;/em&gt;, carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  How error analysis actually works
&lt;/h2&gt;

&lt;p&gt;It's a three-move loop, and the moves are deliberately low-tech.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Get a starting dataset and read it.&lt;/strong&gt; Pull a sample of real (or realistic) outputs — 50 to 100 is plenty to start. Not the happy-path demo cases; the real distribution, including the weird inputs. Then actually read them. Slowly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Open-code the failures.&lt;/strong&gt; For each output that's wrong, write a short, free-text note describing &lt;em&gt;what specifically is wrong&lt;/em&gt; — in your own words, no fixed categories yet. "Explained the word using a dictionary definition instead of the meaning it has in this sentence." "Translation is correct but the tone is far too formal for a casual chat." "The quiz distractor is so obviously wrong it gives the answer away." This is open coding: you're labelling reality, not forcing it into boxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cluster the notes into a taxonomy.&lt;/strong&gt; Once you have 40–50 notes, patterns emerge. Group them. Those groups are your &lt;strong&gt;failure taxonomy&lt;/strong&gt; — a ranked list of &lt;em&gt;how your feature fails&lt;/em&gt;, with rough frequencies. Now you know what to fix first (the common, severe modes) and, crucially, &lt;em&gt;what your metrics should measure.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the whole secret. The taxonomy is the output, and it's worth more than any single score, because every later step — the rubric, the golden set, the judge — is downstream of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mindset note: be a detective, not a judge (yet)
&lt;/h2&gt;

&lt;p&gt;The hard part of error analysis isn't mechanical, it's psychological. You will be tempted to immediately assign a 1–5 score, or to jump to "the fix is to add a line to the prompt." Resist both. Scoring too early collapses rich information ("it's a 2") into a number that hides &lt;em&gt;why&lt;/em&gt;. Fixing too early means you patch the first failure you see instead of the most common one.&lt;/p&gt;

&lt;p&gt;Stay descriptive for as long as you can. Your only job in this phase is to understand and categorise. Judgement and repair come later.&lt;/p&gt;

&lt;p&gt;A second trap is doing it alone. When two people label the same outputs, they disagree — and the disagreements are gold, because they reveal that "good" isn't actually defined yet. A short alignment session to resolve them sharpens your definition of quality before you bake it into a rubric. (Solo founders can approximate this by labelling, sleeping on it, and re-labelling cold.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How error analysis shaped TextStack's evals
&lt;/h2&gt;

&lt;p&gt;This isn't abstract for us. TextStack has seven AI surfaces, and every rubric we score against came directly out of reading failures, not out of a generic template.&lt;/p&gt;

&lt;p&gt;Take &lt;strong&gt;Explain&lt;/strong&gt; (tap a word, get a short in-context explanation). Reading real outputs surfaced a recurring failure: the model would produce a competent &lt;em&gt;dictionary&lt;/em&gt; definition while ignoring the sentence the reader was actually looking at — useless for someone trying to understand &lt;em&gt;this&lt;/em&gt; passage. That single observation is why the Explain rubric scores &lt;strong&gt;accuracy in context&lt;/strong&gt; and &lt;strong&gt;usefulness to a learner&lt;/strong&gt; as distinct axes, and explicitly penalises dictionary boilerplate under &lt;strong&gt;conciseness&lt;/strong&gt;. The rubric is a direct transcription of the taxonomy.&lt;/p&gt;

&lt;p&gt;Other surfaces produced different taxonomies, and therefore different axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Translate&lt;/strong&gt; kept failing on &lt;em&gt;register&lt;/em&gt; — accurate but wrong formality — so register became its own scored dimension alongside accuracy and fluency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary distractors&lt;/strong&gt; (wrong answers in a quiz) failed by being &lt;em&gt;implausible&lt;/em&gt; (too obviously wrong) or &lt;em&gt;too similar&lt;/em&gt; to the right answer, so the rubric scores plausibility, distinctness, and difficulty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We didn't invent those dimensions in a meeting. We read outputs until the dimensions were obvious. And because every AI call is traced and viewable on an internal &lt;code&gt;/ai-quality&lt;/code&gt; page, error analysis isn't a one-time exercise — new production failures keep feeding new categories back into the taxonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scoring before describing.&lt;/strong&gt; A number erases the &lt;em&gt;why&lt;/em&gt;. Open-code in words first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vague categories.&lt;/strong&gt; "Bad output" isn't a category; "ignored the sentence context" is. Specific enough to act on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too small a sample, or only the easy cases.&lt;/strong&gt; If you only read successes, you'll conclude everything is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing during analysis.&lt;/strong&gt; Note the failure, move on. Triage &lt;em&gt;after&lt;/em&gt; you can see the whole picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labelling solo with no calibration.&lt;/strong&gt; Disagreement is information; surface it before it hardens into a bad rubric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doing it once.&lt;/strong&gt; Inputs drift. The taxonomy is a living document, refreshed from real traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Error analysis is the part of evals with no tooling, no dashboard, and the highest payoff — and that's exactly why it gets skipped. Read your failures, name them in plain language, and cluster them into a taxonomy. That taxonomy tells you what to fix and what to measure. Skip it and you'll build a beautiful measurement system pointed at the wrong target.&lt;/p&gt;

&lt;p&gt;Next in the series: &lt;strong&gt;golden datasets that don't lie&lt;/strong&gt; — turning your taxonomy into a curated set of cases you can score against, without quietly fooling yourself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;TextStack is a reader that helps you finish the dense technical book you keep quitting — it builds every modern AI primitive (observability, evals, RAG, agents) as a real production feature on .NET. Try it at &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt;, or read the code at &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>llm</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>AI Evals, Explained: How We Actually Know Our AI Is Any Good</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Wed, 10 Jun 2026 15:10:15 +0000</pubDate>
      <link>https://dev.to/mrviduus/ai-evals-explained-how-we-actually-know-our-ai-is-any-good-23hj</link>
      <guid>https://dev.to/mrviduus/ai-evals-explained-how-we-actually-know-our-ai-is-any-good-23hj</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of a series on building production AI on .NET — drawn from &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;TextStack&lt;/a&gt;, a reader with seven shipping AI features.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can build an AI feature in an afternoon. Wiring up an API call and a prompt is genuinely easy now. The hard part — the part that separates a demo from a product — is answering one deceptively simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it any good? And did my last change make it better or worse?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For normal code, that question has a normal answer: a test suite. &lt;code&gt;Add(2, 2)&lt;/code&gt; should return &lt;code&gt;4&lt;/code&gt;; if it doesn't, the build goes red. But an AI feature doesn't return &lt;code&gt;4&lt;/code&gt;. Ask it to explain a word and it returns a &lt;em&gt;paragraph&lt;/em&gt; — a slightly different paragraph every single time, and "correct" is a whole range of good answers, not one. You cannot write &lt;code&gt;Assert.Equal&lt;/code&gt; against prose. The thing software engineering relies on most — a fast, automatic signal that something broke — is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evals are how you get that signal back.&lt;/strong&gt; This post is a plain-English introduction to what they are and how we actually run them in production. No hype, no notebooks — just the mental model and a real implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what &lt;em&gt;is&lt;/em&gt; an eval?
&lt;/h2&gt;

&lt;p&gt;Strip away the jargon and an eval is just &lt;strong&gt;a systematic way to measure the quality of an AI output.&lt;/strong&gt; Where a unit test gives you pass/fail by exact match, an eval gives you a &lt;em&gt;graded judgement&lt;/em&gt; over a representative sample of inputs. Instead of "is this exactly right?" it asks "across 30 realistic cases, how good is this, on the axes I care about?"&lt;/p&gt;

&lt;p&gt;That measurement gets used in three different places, and it helps to keep them separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;As monitoring&lt;/strong&gt; — you score a sample of real traffic over time, to catch quality silently drifting downward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As a guardrail&lt;/strong&gt; — you score an output &lt;em&gt;before&lt;/em&gt; the user sees it, and block or retry if it fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As a ruler for improvement&lt;/strong&gt; — you score before and after a change, so "did this prompt edit help?" finally has an answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams want the third one first and never build it. That's the gap this series is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle: Analyze → Measure → Improve
&lt;/h2&gt;

&lt;p&gt;The most useful framing I've found is to treat evaluation as a loop of &lt;strong&gt;Analyze, Measure, Improve.&lt;/strong&gt; It's worth internalising because it stops you from doing the steps in the wrong order — which is the single most common mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Analyze — look at your failures before you measure anything.&lt;/strong&gt;&lt;br&gt;
The instinct is to jump straight to a metrics dashboard. Resist it. The highest-leverage activity in all of evals is boring: take 50–100 real outputs, read them, and label &lt;em&gt;how&lt;/em&gt; each one is wrong. Not a score — a category. "Restated the dictionary definition instead of using the sentence's context." "Translation was accurate but too formal." You cluster these into a &lt;em&gt;failure taxonomy&lt;/em&gt;, and that's what tells you which dimensions are even worth measuring. Skip this and you'll confidently measure the wrong things while users churn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Measure — turn those failure modes into a repeatable number.&lt;/strong&gt;&lt;br&gt;
This is where the golden dataset and the LLM judge come in (the next two posts go deep on each). In short: you assemble a set of representative inputs with reference answers, run your feature over them, and have a second, stronger model &lt;em&gt;score&lt;/em&gt; each output against a rubric built from your taxonomy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Improve — change something, re-run, and trust the delta.&lt;/strong&gt;&lt;br&gt;
Now you can edit a prompt, swap a model, or restructure a pipeline, run the eval, and &lt;em&gt;see&lt;/em&gt; whether quality moved. When you wire that comparison into CI, a quality regression turns the build red — the same safety net you have for ordinary code, finally extended to the non-deterministic part.&lt;/p&gt;

&lt;p&gt;It's a flywheel: production traffic reveals new failure modes → you analyze them → they become new measured cases → improvements get gated → better output produces cleaner traffic. Round and round.&lt;/p&gt;
&lt;h2&gt;
  
  
  How we run evals at TextStack
&lt;/h2&gt;

&lt;p&gt;Theory is cheap, so here's the concrete version. TextStack is an ASP.NET Core reading app with seven AI surfaces — Explain a word in context, Translate, generate vocabulary quiz distractors, book metadata, an audio podcast, and more. One rule sits above all of them:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every AI feature ships with its own eval suite from day one. Eval is part of the pull request, not a follow-up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, for each feature there's:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A golden dataset.&lt;/strong&gt; ~30 hand-curated cases per feature, stored as plain JSON, each pairing a realistic input with a reference answer a human would accept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation through the real path.&lt;/strong&gt; The eval runs each case through the &lt;em&gt;same&lt;/em&gt; code production uses — the same prompt, the same model gateway — so the test can never quietly drift away from what users actually get. (That drift is a classic, silent way to make an eval lie; more on it in the golden-dataset post.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dedicated judge.&lt;/strong&gt; A second, stronger model (we use a &lt;code&gt;gpt-4.1&lt;/code&gt;-class model, deliberately separate from the small, cheap models that &lt;em&gt;generate&lt;/em&gt; the features) scores each output 1–5 on a short, feature-specific rubric — for Explain that's &lt;em&gt;accuracy / conciseness / usefulness&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The judge runs on &lt;strong&gt;&lt;a href="https://learn.microsoft.com/dotnet/ai/conceptual/evaluation-libraries" rel="noopener noreferrer"&gt;Microsoft.Extensions.AI.Evaluation&lt;/a&gt;&lt;/strong&gt; — Microsoft's official, open-source evaluation library for .NET. This is a deliberate choice: most of the eval ecosystem assumes you're in Python (Braintrust, Phoenix, LangSmith), but a .NET shop doesn't have to leave the platform to do this properly. Our judge is implemented as a custom &lt;code&gt;IEvaluator&lt;/code&gt;, so it slots into the same harness as Microsoft's built-in evaluators and runs as an ordinary &lt;code&gt;dotnet test&lt;/code&gt;. The whole pipeline is plain C# — no Python bridge, no LangChain. The library is young and moving fast, which also makes it one of the more approachable corners of the .NET AI stack to &lt;em&gt;contribute back&lt;/em&gt; to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A custom IEvaluator on Microsoft.Extensions.AI.Evaluation.&lt;/span&gt;
&lt;span class="c1"&gt;// One judge, many features: the rubric is a parameter, not hardcoded.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Rubric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Dim1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Dim2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Dim3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;explain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Rubric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"accuracy: matches the meaning the word carries in THIS sentence"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"conciseness: 2-3 sentences, no dictionary boilerplate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"usefulness: would a learner find it genuinely helpful"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Persistence and a dashboard.&lt;/strong&gt; Every run is stored, and an internal &lt;code&gt;/ai-quality&lt;/code&gt; page shows scores and traces per feature, so quality is something we can actually watch over time — not a number that scrolls past in a CI log.&lt;/p&gt;

&lt;p&gt;The honest status: we can run the full suite on demand and gate individual features against a quality floor; turning that into an automatic "fail the build if we regress more than X% versus last week" ratchet is the next step. The measuring instrument is built — and building the instrument is the hard 80%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps (so you don't learn them the expensive way)
&lt;/h2&gt;

&lt;p&gt;A quick preview of what the rest of the series unpacks, because these are where eval setups quietly break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metrics before error analysis&lt;/strong&gt; — you measure what was easy to imagine, not what actually fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An easy golden set&lt;/strong&gt; — the score goes up while the product goes down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A judge you never validated&lt;/strong&gt; — an LLM grading prose is itself a model; if it doesn't agree with human judgement, your whole pipeline is theatre.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge bias&lt;/strong&gt; — judges quietly prefer longer answers, the first option shown, and text from their own model family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping on noise&lt;/strong&gt; — with 30 cases, a 0.1 bump in the average is probably random, not progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a post of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is going
&lt;/h2&gt;

&lt;p&gt;Evals are not a dashboard you bolt on at the end. They're the discipline that lets you change an AI product without flying blind — look at your failures, measure them honestly, and gate on the result. Done right, they turn &lt;em&gt;"I think this feature is fine"&lt;/em&gt; into &lt;em&gt;"I can prove it, and I'll know the moment it stops being true."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Next in the series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This post&lt;/strong&gt; — what evals are and how we run them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error analysis&lt;/strong&gt; — the unglamorous superpower, and how to build a failure taxonomy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Golden datasets that don't lie&lt;/strong&gt; — curation, leakage, and the drift trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM-as-judge, done right&lt;/strong&gt; — rubrics, a dedicated judge, and the biases that wreck it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From a number to a gate&lt;/strong&gt; — evals in CI and online monitoring.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;TextStack is a reader that helps you finish the dense technical book you keep quitting — it builds every modern AI primitive (observability, evals, RAG, agents) as a real production feature on .NET, not a notebook. Try it at &lt;a href="https://textstack.app" rel="noopener noreferrer"&gt;textstack.app&lt;/a&gt;, or read the code at &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>llm</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>I put Ollama on a 4 GB mobile GPU and got 2.5 — here's the VRAM math</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Wed, 13 May 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/i-put-ollama-on-a-4-gb-mobile-gpu-and-got-25-heres-the-vram-math-3mhk</link>
      <guid>https://dev.to/mrviduus/i-put-ollama-on-a-4-gb-mobile-gpu-and-got-25-heres-the-vram-math-3mhk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Write About Gemma 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📎 Companion piece to my earlier post: &lt;a href="https://dev.to/mrviduus/i-shipped-local-llm-features-two-months-ago-production-never-ran-them-once-41g7"&gt;I shipped local LLM features two months ago — production never ran them once&lt;/a&gt;. Same &lt;code&gt;gemma4:e2b&lt;/code&gt;, same box — this one is the &lt;strong&gt;GPU offload follow-up&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🔬 TL;DR
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;2.5× faster, 10°C cooler — on a 4 GB laptop GPU that "shouldn't" fit the model.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;CPU only&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;GPU hybrid&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens / sec&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-call latency&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~5.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~2.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU temp under burst&lt;/td&gt;
&lt;td&gt;hot&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−10 °C&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers on GPU&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35 / 36&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same prompt. Same model. Same hardware. The only thing that changed was whether Ollama was allowed to touch the card.&lt;/p&gt;

&lt;p&gt;Honest take: I was hoping for more. The math at the end of this post explains exactly why &lt;strong&gt;2.5× is the ceiling&lt;/strong&gt; on 4 GB of VRAM with Gemma 4, and what it would take to push higher.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚙️ Setup
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemma4:e2b&lt;/code&gt; (2 B effective params, ~7.2 GB on disk)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AMD Ryzen 5 4600H, 6 cores / 12 threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;NVIDIA GTX 1650 Ti Mobile, &lt;strong&gt;4 GB VRAM&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS / runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ubuntu + Docker, Ollama 0.23.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prompt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Distractor + hint + explanation generator from my reader app — fixed across runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Output budget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~60 tokens per call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Control&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;num_gpu=0&lt;/code&gt; → CPU only · &lt;code&gt;num_gpu=999&lt;/code&gt; → let Ollama auto-split&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Warm-up&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One throwaway call per mode before the timed samples&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both modes ran &lt;strong&gt;after warm-up&lt;/strong&gt;, so the numbers reflect steady-state inference, not first-load cost. Each &lt;code&gt;/api/generate&lt;/code&gt; response came back as NDJSON, so I pulled &lt;code&gt;eval_count&lt;/code&gt;, &lt;code&gt;eval_duration&lt;/code&gt;, and &lt;code&gt;total_duration&lt;/code&gt; straight from the engine — no external timing noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Why I picked E2B
&lt;/h2&gt;

&lt;p&gt;Gemma 4 ships in three flavours — the small E2B/E4B family, a 31B Dense model, and a 26B MoE. The model that runs in this benchmark is the smallest of those, and that wasn't accidental.&lt;/p&gt;

&lt;p&gt;The work is a fire-and-forget enrichment step inside a vocabulary-save flow — distractors plus a hint plus a short explanation, all generated in one call. It has to feel synchronous on a save action, and it has to run on the same commodity laptop as the rest of the app. Anything bigger is the wrong tool.&lt;/p&gt;

&lt;p&gt;The 31B Dense doesn't fit. The 26B MoE would, but its VRAM patterns on a 4 GB card are punishing. E4B is the obvious step up in quality from E2B, but its size pushes total memory over the line where Ollama has to keep more on CPU — slower for the same job at the latency profile a save action needs. E2B at Q4 lands the quality where I need it for distractor generation while leaving headroom for the KV cache and everything else.&lt;/p&gt;

&lt;p&gt;The framing that matters here isn't "the biggest model I could fit" but "the smallest model that gave me the output I needed." On constrained hardware, that distinction is the whole game — and it's what made the GPU experiment below worth running at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  📊 Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;CPU only&lt;/th&gt;
&lt;th&gt;GPU hybrid (35/36 layers on GPU)&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Avg output tokens / call&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;~same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Avg eval latency&lt;/strong&gt; (token gen only)&lt;/td&gt;
&lt;td&gt;3,506 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,411 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.49× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Avg total latency&lt;/strong&gt; (prompt + gen)&lt;/td&gt;
&lt;td&gt;5,390 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,174 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.48× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tokens / sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.29× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;ollama ps&lt;/code&gt; during the GPU run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME          SIZE      PROCESSOR        CONTEXT   UNTIL
gemma4:e2b    7.8 GB    74%/26% CPU/GPU  4096      Forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nvidia-smi&lt;/code&gt; during a generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NVIDIA GTX 1650 Ti, used 1998 MiB, free 1909 MiB, util 32 %
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;&lt;code&gt;ollama ps&lt;/code&gt; lies to you.&lt;/strong&gt;&lt;br&gt;
That "74%/26% CPU/GPU" string is a memory split, &lt;strong&gt;not a layer split&lt;/strong&gt;. The Ollama server logs are the only place that tells you which layers actually moved. Mine showed &lt;code&gt;offloaded 35/36 layers to GPU&lt;/code&gt;. Almost the whole transformer — minus one layer that matters a lot. More on that in a second.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧠 Why 2.5× and not 10×
&lt;/h2&gt;

&lt;p&gt;The model has 36 transformer layers. Ollama put &lt;strong&gt;35 of them on the GPU&lt;/strong&gt;. The lone holdout is the &lt;strong&gt;output projection layer&lt;/strong&gt; — the one that maps the final hidden state back into Gemma's vocabulary.&lt;/p&gt;

&lt;p&gt;Gemma 4's vocab is enormous (~256k tokens). That output layer is dense, fat, and would happily swallow what's left of the 4 GB after the rest of the stack moves over. So Ollama leaves it on CPU.&lt;/p&gt;

&lt;p&gt;The consequence is brutal in the steady state:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Every single generated token has to round-trip through the CPU at the end.&lt;/strong&gt; GPU is fast for the 35 layers it owns, then the pipeline stalls on the one layer the GPU couldn't take. Average across thousands of tokens and the CPU side becomes the floor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole story of 2.5× instead of 10×. Hybrid inference is gated by the slower of the two devices, and on this card the slower device is doing real work on every token.&lt;/p&gt;

&lt;p&gt;The takeaway worth bolding: &lt;strong&gt;if you only ever look at &lt;code&gt;ollama ps&lt;/code&gt;, you'll get the wrong picture of what your setup is doing.&lt;/strong&gt; The server load logs are the source of truth for which layers went where.&lt;/p&gt;




&lt;h2&gt;
  
  
  💡 What 2.5× actually buys you
&lt;/h2&gt;

&lt;p&gt;In the app, a single save — distractors + hint + short explanation, ~60 output tokens — used to take &lt;strong&gt;5.5 s&lt;/strong&gt;. Now it's &lt;strong&gt;just over 2 s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That moves the action from the &lt;em&gt;"is this hanging?"&lt;/em&gt; zone into the &lt;em&gt;"yeah, it's working"&lt;/em&gt; zone. That's the threshold that actually matters for a save action.&lt;/p&gt;

&lt;p&gt;Five saves in a row:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before:&lt;/strong&gt; ~30 seconds of full-tilt CPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After:&lt;/strong&gt; ~10 seconds, work split between CPU and GPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus:&lt;/strong&gt; peak CPU temperature during that burst dropped &lt;strong&gt;~10 °C&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a thin laptop in a small room, that last number is the difference between a fan you hear and a fan you don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 What would push it higher
&lt;/h2&gt;

&lt;p&gt;Three options, in order of how willing I am to do them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Smaller quant on just the output layer.&lt;/strong&gt; If that layer fit in the remaining ~1.9 GB, the whole model would run on GPU and you'd see the 10× numbers other writeups quote. The cost is real quality loss on the output distribution — worth measuring on your own prompt set rather than assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bigger GPU.&lt;/strong&gt; A 16 GB card holds the whole thing with room to spare. The point of this exercise was specifically &lt;em&gt;"what does a commodity laptop GPU do"&lt;/em&gt;, so a $500 desktop card isn't really in scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap engines.&lt;/strong&gt; llama.cpp direct, vLLM, etc. Two seconds is already inside budget for the action this model powers. Optimising past "fast enough" is how you end up with three benchmarks and zero users.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  🛠️ Reproducing this
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pull the model&lt;/span&gt;
ollama pull gemma4:e2b

&lt;span class="c"&gt;# 2. Force CPU only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "gemma4:e2b",
  "prompt": "Give me 5 distractors for the word \"warehouse\".",
  "stream": false,
  "options": { "num_gpu": 0 }
}'&lt;/span&gt; | jq &lt;span class="s1"&gt;'{tokens: .eval_count, eval_ms: (.eval_duration/1e6), total_ms: (.total_duration/1e6)}'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Let Ollama use the GPU&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "gemma4:e2b",
  "prompt": "Give me 5 distractors for the word \"warehouse\".",
  "stream": false,
  "options": { "num_gpu": 999 }
}'&lt;/span&gt; | jq &lt;span class="s1"&gt;'{tokens: .eval_count, eval_ms: (.eval_duration/1e6), total_ms: (.total_duration/1e6)}'&lt;/span&gt;

&lt;span class="c"&gt;# 4. Check what actually landed where&lt;/span&gt;
docker logs ollama 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"offloaded|layers"&lt;/span&gt;
nvidia-smi &lt;span class="nt"&gt;--query-gpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,memory.used,memory.free,utilization.gpu &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run each curl a handful of times to flush warm-up effects, then average &lt;code&gt;eval_ms&lt;/code&gt; and &lt;code&gt;total_ms&lt;/code&gt;. The interesting number is the &lt;strong&gt;ratio&lt;/strong&gt;, not the absolute timings — they'll vary with your CPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 GB VRAM is enough to be useful&lt;/strong&gt;, even on a model that "should" need more. Just don't expect 10×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid inference is gated by the slower device.&lt;/strong&gt; If one critical layer stays on CPU, that's your floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the load logs, not &lt;code&gt;ollama ps&lt;/code&gt;.&lt;/strong&gt; The pretty CPU/GPU percentage is a memory split, not a layer count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2.5× is the difference between a UX that feels broken and one that doesn't.&lt;/strong&gt; That's enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop optimising once you're inside budget.&lt;/strong&gt; "Fast enough" beats "fastest" every time.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;📖 Full write-up with all the load-log spelunking on my blog: &lt;a href="https://vasyl.blog/2026/05/12/i-put-ollama-on-a-4-gb-mobile-gpu-and-got-2-5x-heres-the-vram-math/" rel="noopener noreferrer"&gt;vasyl.blog — I put Ollama on a 4 GB mobile GPU and got 2.5×&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;⭐ The reader app this powers is open-source (AGPL-3.0): &lt;a href="https://github.com/mrviduus/textstack" rel="noopener noreferrer"&gt;github.com/mrviduus/textstack&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with &lt;code&gt;gemma4:e2b&lt;/code&gt; for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge&lt;/a&gt;. If you're entering too, drop a link in the comments — happy to read yours.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>ollama</category>
    </item>
  </channel>
</rss>
