<?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 Uptime Monitor Was Set to Every 5 Minutes. In July It Ran 15 Times a Day.</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:30:20 +0000</pubDate>
      <link>https://dev.to/mrviduus/my-uptime-monitor-was-set-to-every-5-minutes-in-july-it-ran-15-times-a-day-3if8</link>
      <guid>https://dev.to/mrviduus/my-uptime-monitor-was-set-to-every-5-minutes-in-july-it-ran-15-times-a-day-3if8</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;🔨 #bugsmash, week by week: &lt;a href="https://dev.to/mrviduus/nothing-crashed-nothing-errored-my-cpu-sat-at-390-for-an-hour-335a"&gt;a 390% CPU hour nobody noticed&lt;/a&gt;, &lt;a href="https://dev.to/mrviduus/stuck-at-preparing-00-forever-my-state-machine-had-no-exit-1pmb"&gt;a state machine with no exit&lt;/a&gt;, &lt;a href="https://dev.to/mrviduus/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter-3ldd"&gt;a backup that leaked 156 GB&lt;/a&gt;, &lt;a href="https://dev.to/mrviduus/nobody-alerts-on-silence-wiring-sentry-into-an-llm-pipeline-12lo"&gt;a monitor that caught its own lies&lt;/a&gt;. Week five: the monitor that was quietly not running at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had not opened the TextStack repo properly in a while. I have been working through Claude Code for weeks, task by task, and each task looked small. Yesterday I opened the whole thing and looked at it as one project. It has grown into something I cannot hold in my head anymore. That is a separate problem and I will get to it at the end.&lt;/p&gt;

&lt;p&gt;While I was looking around I opened &lt;code&gt;.github/workflows/health-check.yml&lt;/code&gt;. It is the file that tells me my site is alive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*/5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 5 minutes. It curls &lt;code&gt;/health&lt;/code&gt; on textstack.app and textstack.dev, then does two smoke checks, book listing and search. If any of them fails, the run goes red and I get a notification.&lt;/p&gt;

&lt;p&gt;288 runs a day. That is what the file says.&lt;/p&gt;

&lt;p&gt;Then I counted the actual runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;The workflow was created on 6 January 2026. Here is what it did, month by month.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month&lt;/th&gt;
&lt;th&gt;Runs per day&lt;/th&gt;
&lt;th&gt;Percent of 288&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;January (from the 6th)&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;February&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August (first 11 days)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It went down every single month from January to July.&lt;/p&gt;

&lt;p&gt;In July my health check ran 15 times a day. I had asked for 288. That is one check every 96 minutes instead of every 5.&lt;/p&gt;

&lt;p&gt;And it never started at 100 percent either. Even in the first month it was already at about a third.&lt;/p&gt;

&lt;p&gt;I did not notice any of this for seven months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What made me look
&lt;/h2&gt;

&lt;p&gt;On 6 August GitHub Actions went down. The incident opened at 15:22 UTC and was fully resolved at 02:04 UTC the next day, so almost 11 hours. At the peak, 71 percent of workflow runs were failing with infrastructure errors. My own work stopped for about four hours that afternoon, which is why I remembered the date.&lt;/p&gt;

&lt;p&gt;So I checked what my health check did in that window. Four runs. Two of them failed. The largest gap that day was 5 hours 25 minutes, from 18:33 to 23:59, and that gap sits inside the incident.&lt;/p&gt;

&lt;p&gt;At first I thought I had a clean story. GitHub went down, my monitor went down with it, blind for five hours.&lt;/p&gt;

&lt;p&gt;Then I looked at the same day before the incident started. There was already a gap of 3 hours 22 minutes, from 00:08 to 03:30, hours before anything broke. And on 3, 4 and 5 August the workflow ran 12, 13 and 13 times.&lt;/p&gt;

&lt;p&gt;So no. The outage did not blind me. The outage was just the day I happened to look. It had been like this since spring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;This is documented. It is in the GitHub docs, in the part about the &lt;code&gt;schedule&lt;/code&gt; event.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scheduled events can be delayed during periods of high load of GitHub Actions workflow runs, and if the load is high enough, some queued jobs may be dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Dropped. Not delayed. Dropped.&lt;/p&gt;

&lt;p&gt;I knew that sentence existed. I read it as "sometimes it runs a bit late." It does not mean that. It means the run never happens and nothing tells you.&lt;/p&gt;

&lt;p&gt;There is a second half to this, and I want to be careful about how strongly I put it. GitHub itself has been under a lot of load this year. Commits went from about 1 billion in all of 2025 to 275 million per week by April 2026. Actions compute hit 2.1 billion minutes in a single week. Pull requests opened by AI agents went from about 4 million in September 2025 to more than 17 million in March 2026.&lt;/p&gt;

&lt;p&gt;My curve goes down while their curve goes up. That fits, and the mechanism is documented. But I only have my one repo, so I cannot prove the two curves are connected. I can only say the shape matches and GitHub says dropping is what happens under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;Same repository, same seven months, same &lt;code&gt;schedule&lt;/code&gt; trigger. My daily database backup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;224 runs since January. Zero missing days. Not one. Every month it ran on exactly as many days as the month has, and in May and June it ran a couple of extra times because I triggered it by hand.&lt;/p&gt;

&lt;p&gt;So in the same repo one scheduled workflow degraded to 5 percent and the other stayed at 100 percent.&lt;/p&gt;

&lt;p&gt;I want to say the difference is &lt;code&gt;runs-on: self-hosted&lt;/code&gt;, because that is the obvious difference and it is the one I like. But the two workflows also differ in how often they ask for something. One asks 288 times a day, the other asks once. High frequency schedules are exactly what a queue sheds first. I cannot separate those two causes with the data I have, so I am not going to pretend I can.&lt;/p&gt;

&lt;p&gt;I did get one thing wrong before checking, and it is worth saying. I assumed self-hosted would not protect me on 6 August, because the root cause that day was invalid job assignments to runners, and job assignment is GitHub's side, not mine. That sounded right. The backup ran normally on 6 and 7 August anyway. My reasoning was fine and the answer was still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, all built by me, none of them checking anything
&lt;/h2&gt;

&lt;p&gt;Once I started pulling on this I found it was not one problem.&lt;/p&gt;

&lt;p&gt;My README says the project has uptime monitoring with UptimeRobot probes. There is a whole file for it, &lt;code&gt;docs/03-ops/uptime-monitoring.md&lt;/code&gt;. It is a good document. It has a table of three monitors with URLs, expected responses and intervals. It has numbered setup steps. It has an alert-response runbook with the exact commands to run for each kind of alert, down to which containers to check logs for.&lt;/p&gt;

&lt;p&gt;I do not have an UptimeRobot account. I never signed up. I wrote the whole document for the setup I was going to build, and then did not build it.&lt;/p&gt;

&lt;p&gt;The last section of that same file is the part that got me. It describes the GitHub Actions health check as internal belt-and-braces, useful when UptimeRobot itself has an outage, but redundant otherwise.&lt;/p&gt;

&lt;p&gt;Redundant. My documentation called it the backup to the real monitoring. It was the only monitoring. And it was running at 5 percent.&lt;/p&gt;

&lt;p&gt;Second layer: the cron says every 5 minutes. It runs every 96 minutes.&lt;/p&gt;

&lt;p&gt;Third layer: the Actions tab is green. A green tab means every run that happened passed. It looks exactly the same when almost no runs happened at all. There is no colour for "did not run."&lt;/p&gt;

&lt;p&gt;Three sources of confidence. All three mine. None of them lying on purpose, and none of them checking anything.&lt;/p&gt;

&lt;p&gt;This is the same shape as &lt;a href="https://dev.to/mrviduus/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter-3ldd"&gt;a bug I wrote about a few weeks ago&lt;/a&gt;, where my backup verifier filled the disk of the machine it was verifying. Someone in the comments gave me the sentence for it: a verifier that shares a resource pool with the thing it verifies eventually becomes the largest consumer of it. This is one level up. A monitor that shares a platform with the thing it monitors inherits that platform's bad days, and stays silent about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not going to do
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to sign up for an external monitoring service. Free tier, five minute checks, runs on somebody else's machines, done in twenty minutes.&lt;/p&gt;

&lt;p&gt;I am not going to do that, and I want to explain why, because the reason is the thing I actually learned this week.&lt;/p&gt;

&lt;p&gt;I opened this project yesterday and could not hold it in my head. It is one person's side project and it has a backend, a web app, an admin app, a mobile app, a worker, an MCP server, a browser extension, an SSG pipeline, and now eight workflows. Every one of those was a reasonable decision on the day I made it. Together they are more than I can carry.&lt;/p&gt;

&lt;p&gt;Every new service is not just the service. It is another account, another set of credentials, another page in the docs, another thing that is quietly broken in six months while I believe it is running. I know that last one is real now, because that is exactly what my UptimeRobot documentation was: a doc for a thing that did not exist.&lt;/p&gt;

&lt;p&gt;Adding a monitoring service to fix bad monitoring is how the pile got this big.&lt;/p&gt;

&lt;p&gt;So the rule I am taking out of this is not "add a monitor." It is narrower. Do not add a second thing where the first thing was never checked. And when you do keep something, keep it where you can see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am leaving it exactly where it is
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to move the health check to &lt;code&gt;runs-on: self-hosted&lt;/code&gt;, next to the backup that has not missed a day in seven months. One line changed. 100 percent instead of 5 percent. I spent a day thinking that was the answer.&lt;/p&gt;

&lt;p&gt;It is the wrong answer, and working out why is the part of this I am actually glad about.&lt;/p&gt;

&lt;p&gt;My self-hosted runner is a machine in my flat. If that machine dies, a monitor running on it dies at the same moment and tells me nothing. I would find out on my next login, and I log in maybe once a week.&lt;/p&gt;

&lt;p&gt;GitHub is the opposite. When GitHub breaks, somebody whose job it is fixes it, usually within hours, without me. The 6 August outage lasted 11 hours and I did nothing about it.&lt;/p&gt;

&lt;p&gt;So the two options are not "5 percent reliable" versus "100 percent reliable." They are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A check that runs every 96 minutes and is not on my machine.&lt;/li&gt;
&lt;li&gt;A check that runs every 5 minutes and dies silently with my machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one has perfect uptime for every failure except the one failure I have no other way of seeing. My server dying is the thing I am most exposed to, because I am not looking at it. A monitor that shares its fate is not a monitor. It is a second thing that will be dead at the same time.&lt;/p&gt;

&lt;p&gt;So the degraded one is the one worth keeping. Not because 15 runs a day is good, but because it runs somewhere I do not control and do not have to maintain, and that is the whole property I need from it.&lt;/p&gt;

&lt;p&gt;What I give up is resolution. If the site goes down I might hear about it 96 minutes later instead of 5. For a project with my traffic that is fine, and I would rather know late than not know.&lt;/p&gt;

&lt;p&gt;The mistake I made for seven months was not putting the check on GitHub. That was right. The mistake was reading &lt;code&gt;*/5 * * * *&lt;/code&gt; as a promise and never checking whether it was kept. I optimised the number in the file and never looked at the number in reality.&lt;/p&gt;

&lt;p&gt;A monitor has two properties. How often it runs, and whether it can survive what it is watching. I spent all my attention on the first one. The second one is the one that decides whether it is a monitor at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade used to be easier
&lt;/h2&gt;

&lt;p&gt;I want to be honest that I am less comfortable with this than I would have been a year ago.&lt;/p&gt;

&lt;p&gt;Leaning on GitHub used to be a decision you made once and forgot. It was background. It was there. That is what made "just put it on Actions" the obvious call for a solo project.&lt;/p&gt;

&lt;p&gt;It is not that obvious now, and the numbers say why. My own schedule went from 37 percent of what I asked for in January to 5 percent in July, and nothing about my repo changed in that time. GitHub had nine service-degrading incidents in May alone. On 6 August, Actions was broken for almost 11 hours with 71 percent of runs failing at the peak. In June, Microsoft started renting capacity from AWS to keep GitHub standing up, which is not a thing a company does when the situation is comfortable.&lt;/p&gt;

&lt;p&gt;The reason underneath is not a mystery either. Commits went from roughly 1 billion in all of 2025 to 275 million per week. Actions compute hit 2.1 billion minutes in one week. Pull requests opened by AI agents went from about 4 million to over 17 million in six months. The platform is absorbing a change in how code gets written, and the parts that get squeezed first are the low priority background ones. A 5 minute cron on a small public repo is exactly that.&lt;/p&gt;

&lt;p&gt;So I am keeping the trade, but I am not calling it free anymore. It has a cost that is going up, and the cost is paid in silence, which is the worst currency for a monitor.&lt;/p&gt;

&lt;p&gt;What I will do is check the number again in three months. If 5 percent has become 2 percent, then the check has stopped being a check and I will have to pay for something. I would rather find that out by counting than by an outage.&lt;/p&gt;

&lt;p&gt;The one thing I am changing is the documentation. &lt;code&gt;docs/03-ops/uptime-monitoring.md&lt;/code&gt; still describes an UptimeRobot setup I never built, and calls the GitHub check redundant. It is not redundant. It is the whole thing. That file now says what actually exists, roughly how often it really runs, and what it does not catch.&lt;/p&gt;

&lt;p&gt;That edit does not improve my uptime by one second. It just means that in six months I will not believe something that is not true.&lt;/p&gt;

&lt;p&gt;What I keep from all this is the number. 15 runs a day out of 288, for a month, without noticing. And the thing that finally told me was not any of my monitoring. It was me opening the repo and counting by hand.&lt;/p&gt;

&lt;p&gt;If you have a scheduled workflow you rely on, go count its runs. Not the failures. The runs. It takes two minutes and I would like to know whether my curve is mine alone.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build TextStack, an open-source reader for technical books, in .NET. The full write-up lives on &lt;a href="https://vasyl.blog/2026/08/11/uptime-monitor-every-5-minutes-ran-15-times-a-day/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;. &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>github</category>
      <category>devops</category>
    </item>
    <item>
      <title>Nobody Alerts on Silence: Wiring Sentry Into an LLM Pipeline</title>
      <dc:creator>Vasyl</dc:creator>
      <pubDate>Tue, 11 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mrviduus/nobody-alerts-on-silence-wiring-sentry-into-an-llm-pipeline-12lo</link>
      <guid>https://dev.to/mrviduus/nobody-alerts-on-silence-wiring-sentry-into-an-llm-pipeline-12lo</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;blockquote&gt;
&lt;p&gt;🔨 #bugsmash, week by week: &lt;a href="https://dev.to/mrviduus/nothing-crashed-nothing-errored-my-cpu-sat-at-390-for-an-hour-335a"&gt;a 390% CPU hour nobody noticed&lt;/a&gt;, &lt;a href="https://dev.to/mrviduus/stuck-at-preparing-00-forever-my-state-machine-had-no-exit-1pmb"&gt;a state machine with no exit&lt;/a&gt;, &lt;a href="https://dev.to/mrviduus/my-backup-leaked-156-gb-filled-the-disk-and-broke-itself-the-fix-was-one-letter-3ldd"&gt;a backup that leaked 156 GB&lt;/a&gt;. Week four is the finale: I wired monitoring into the pipeline that produced all three — and its best catch was itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;TextStack is an open-source reader for technical books, built in .NET: an ASP.NET Core API, a background Worker, PostgreSQL + pgvector, React on top. The LLM pipeline does translation, word explanations, "Ask this book" RAG, and three production agents (Enrichment, Librarian, Tutor), routed between a local Ollama and OpenAI by a config-driven router. 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;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;Three weeks ago a user's PDF fell through my LLM router onto a CPU-only Ollama container instead of GPT-4.1, and my CPU sat at 390% for an hour. Zero exceptions. Zero error logs. Zero alerts. And when I went to see what my existing observability had recorded, the answer was &lt;em&gt;nothing at all&lt;/em&gt;: the OTLP exporter pointed at an Aspire dashboard container that is profile-gated and doesn't run in production. Every span my services had ever produced in prod had been fired into a closed socket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability you never read is indistinguishable from observability you never installed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one-line config fix was submission #1. This submission is the fix for the &lt;em&gt;class&lt;/em&gt; of bug — a system that has no way to make a sound when it does the wrong thing successfully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the router now records &lt;strong&gt;why&lt;/strong&gt; it picked a provider, not just which one;&lt;/li&gt;
&lt;li&gt;expensive tasks landing on the default provider fire a throttled Sentry alert;&lt;/li&gt;
&lt;li&gt;provider failures the client deliberately swallows now report before returning their empty response;&lt;/li&gt;
&lt;li&gt;the Worker probes provider reachability at startup and a circuit breaker stops a dead provider from eating 50 × 90 s of wall-clock per start;&lt;/li&gt;
&lt;li&gt;and an environment-tag fix so a laptop can never masquerade as production again (that story is below — it earned its own PR).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Four PRs, all merged to main; 1,363 unit tests, full CI green:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/mrviduus/textstack/pull/445" rel="noopener noreferrer"&gt;#445 — the Sentry integration&lt;/a&gt;: SDK for API + Worker, route-reason spans, agent transactions, allowlist scrubber&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mrviduus/textstack/pull/446" rel="noopener noreferrer"&gt;#446 — the first leak it found in itself&lt;/a&gt;: EF Core SQL riding in breadcrumb &lt;em&gt;messages&lt;/em&gt;, past a green-tested scrubber&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mrviduus/textstack/pull/447" rel="noopener noreferrer"&gt;#447 — the reader-facing race it found in me&lt;/a&gt;: &lt;code&gt;23505&lt;/code&gt; on reading-progress upserts, real users losing their place in books&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mrviduus/textstack/pull/448" rel="noopener noreferrer"&gt;#448 — the readiness probe, the circuit breaker, and the environment-tag fix&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The router now says why.&lt;/strong&gt; Route resolution was a &lt;code&gt;??&lt;/code&gt; chain that produced a string — &lt;em&gt;identical&lt;/em&gt; whether an operator deliberately routed a task or it fell off the end onto the default. That chain doesn't just fail to record intent; it destroys it. So it returns two things now:&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="n"&gt;RouteDecision&lt;/span&gt; &lt;span class="nf"&gt;ResolveRoute&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;featureTag&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;matched&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;RegistryKey&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="p"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;ConfigRouteKey&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&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;RouteDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RouteReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RouteMatched&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;RouteDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Ai:DefaultProvider"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;RouteReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultFallback&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;Every LLM call tags its span with &lt;code&gt;ai.task&lt;/code&gt;, &lt;code&gt;ai.provider.resolved&lt;/code&gt;, and &lt;code&gt;ai.provider.reason&lt;/code&gt; = &lt;code&gt;route_matched | default_fallback&lt;/code&gt;. "Which model answered this, and did anyone choose it on purpose?" is now a trace query instead of a CPU graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert arithmetic.&lt;/strong&gt; &lt;code&gt;pdf.parse&lt;/code&gt; resolves a route once per &lt;em&gt;page&lt;/em&gt; with parallelism six — my first version would have turned the original incident into 106 identical Sentry events. Every alarm goes through a throttle keyed on &lt;code&gt;(task, provider, reason)&lt;/code&gt;: first hit fires immediately, then one event per hour per distinct problem. The unit test literally counts to 106 and asserts one claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No silent fallback, ever — in either direction.&lt;/strong&gt; When the breaker finds Ollama dead, tasks are skipped and stay queued; nothing auto-switches to a paid provider, because that converts an outage into unbounded spend. Provider choice stays 100% config-driven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the first live run found a hole in my own fix.&lt;/strong&gt; The startup probe opens the circuit on a one-minute backoff; the backfill worker wakes after a two-minute start delay — by then the circuit is legitimately half-open, and my single up-front gate waved the whole batch through. A per-book re-check turned 38 calls into one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metadata backfill: enriching 38 user books
Metadata backfill: aborting after 0 enriched / 1 failed — provider 'ollama'
  is unavailable; the remaining candidates stay queued
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests check what you imagined; a live run checks what's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Error Monitoring — what the first 24 hours in production caught:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The OpenAI account was out of credits.&lt;/strong&gt; &lt;code&gt;HTTP 429 (insufficient_quota)&lt;/code&gt; on &lt;code&gt;/translate&lt;/code&gt; and &lt;code&gt;/explain&lt;/code&gt; — the entire paid surface had been failing for readers for twelve hours. No version of my logs would have surfaced that before a user complained.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readers were losing their place in books.&lt;/strong&gt; &lt;code&gt;PUT /me/progress&lt;/code&gt; threw &lt;code&gt;23505: duplicate key value violates unique constraint&lt;/code&gt; ten times in four hours: a textbook read-then-insert race (session heartbeat + &lt;code&gt;sendBeacon&lt;/code&gt; on unload + second device), milliseconds wide, invisible in tests. Fixed in &lt;a href="https://github.com/mrviduus/textstack/pull/447" rel="noopener noreferrer"&gt;#447&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Custom tags&lt;/strong&gt; (&lt;code&gt;ai.task&lt;/code&gt;, &lt;code&gt;ai.provider&lt;/code&gt;, &lt;code&gt;ai.failure&lt;/code&gt;, &lt;code&gt;agent.name&lt;/code&gt;, &lt;code&gt;agent.outcome&lt;/code&gt;) go through an &lt;strong&gt;allowlist scrubber&lt;/strong&gt; — every tag not explicitly blessed dies at the edge, so a future &lt;code&gt;SetTag("prompt", userText)&lt;/code&gt; can never leak. A Sentry issue answers "which feature, on which model, is broken?" without opening a trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing&lt;/strong&gt; covers agent runs and RAG indexing at 100% sampling (they're the reason I installed this), HTTP at 20%, health checks at 0%. I rejected the deprecated OTel bridge &lt;em&gt;specifically because&lt;/em&gt; spans leaving through the OpenTelemetry SDK bypass &lt;code&gt;BeforeSend&lt;/code&gt; — my OTel pipeline carries raw client IPs and full SQL text that must never leave the box. A tiny &lt;code&gt;TraceScope&lt;/code&gt; dual-writes an &lt;code&gt;Activity&lt;/code&gt; and a Sentry span instead, so everything Sentry receives passes my scrubber.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breadcrumbs caught my scrubber lying — twice.&lt;/strong&gt; A live event's breadcrumb trail contained SQL: EF Core interpolates the query into the breadcrumb &lt;em&gt;message&lt;/em&gt;, not the structured &lt;code&gt;data&lt;/code&gt; bag my scrubber nulled (and my unit tests were green the whole time, asserting exactly the wrong thing). Fixed by dropping EF command breadcrumbs outright — then production found the &lt;em&gt;same&lt;/em&gt; leak in a second channel: EF logs a failed command at &lt;code&gt;Error&lt;/code&gt; level and Sentry's &lt;code&gt;ILogger&lt;/code&gt; integration promotes it to an event, SQL in the message again. A scrubber written against one egress path will be bypassed by the next one. Both doors are closed in &lt;a href="https://github.com/mrviduus/textstack/pull/446" rel="noopener noreferrer"&gt;#446&lt;/a&gt;, and dropping loses no signal — the exception middleware already reports the same failure with the SQLSTATE and constraint name, no SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release + environment tags as forensics.&lt;/strong&gt; The most interesting issue of the first day showed a dead Ollama starving a metadata pipeline: thirty events, tagged &lt;code&gt;environment: Production&lt;/code&gt;. I read it as an outage and started writing the fix. It was my laptop — a dev &lt;code&gt;.env&lt;/code&gt; with &lt;code&gt;ASPNETCORE_ENVIRONMENT=Production&lt;/code&gt; plus the production DSN I'd pasted in to verify the integration. What broke the spell was Sentry's own metadata: &lt;code&gt;linux-arm64&lt;/code&gt; runtime on an x86_64 prod, and a &lt;code&gt;release&lt;/code&gt; tag pointing at a commit that had never been deployed. An environment tag is a claim a process makes about itself, not a fact. Now &lt;code&gt;SENTRY_RELEASE&lt;/code&gt; comes from the &lt;code&gt;GIT_SHA&lt;/code&gt; build arg — every CI-built image has one, no &lt;code&gt;dotnet run&lt;/code&gt; ever does — and a Production claim without a release gets renamed &lt;code&gt;production-unverified&lt;/code&gt; (&lt;a href="https://github.com/mrviduus/textstack/pull/448" rel="noopener noreferrer"&gt;#448&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;And the meta-lesson that justified the whole exercise: I verified the integration by sending real events at the real DSN and &lt;em&gt;reading the captured payloads in the UI&lt;/em&gt; — that's how the leaks, the inferred-geo surprise, and the middleware capture path all surfaced. A monitoring system whose first act is to indict itself is one you can start trusting.&lt;/p&gt;

&lt;p&gt;What's the most embarrassing thing your monitoring has ever caught — and was it in the code, or in you?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build TextStack, an open-source reader for technical books, in .NET. The full write-up lives on &lt;a href="https://vasyl.blog/2026/08/08/nobody-alerts-on-silence-wiring-sentry-into-an-llm-pipeline/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;. &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>sentry</category>
    </item>
    <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>
  </channel>
</rss>
