<?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: Kartik N V J K</title>
    <description>The latest articles on DEV Community by Kartik N V J K (@kartik-nvjk).</description>
    <link>https://dev.to/kartik-nvjk</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%2F3977738%2F99b8e069-3afe-49cf-a409-0f52b82c22b6.jpg</url>
      <title>DEV Community: Kartik N V J K</title>
      <link>https://dev.to/kartik-nvjk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kartik-nvjk"/>
    <language>en</language>
    <item>
      <title>How I generate LLM test cases that actually catch bugs</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:32:42 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/how-i-generate-llm-test-cases-that-actually-catch-bugs-o4n</link>
      <guid>https://dev.to/kartik-nvjk/how-i-generate-llm-test-cases-that-actually-catch-bugs-o4n</guid>
      <description>&lt;p&gt;I got tired of hand-writing test cases for our agent, so I did the obvious 2026 thing: I pointed an LLM at our docs and asked it to generate them. In an afternoon I had a few thousand. I felt incredibly productive.&lt;/p&gt;

&lt;p&gt;They passed CI on every release. Production kept breaking anyway.&lt;/p&gt;

&lt;p&gt;When I finally looked closely, maybe a third of my generated tests were wrong, near-duplicates, or things no real user would ever ask. The set was too noisy to catch a real regression, so a green check meant nothing. The generating was the easy part. The part I had skipped, throwing most of them away, was the entire job.&lt;/p&gt;

&lt;p&gt;Here is what I learned making auto-generated tests that actually catch things. The one line I would tattoo on this: generate ten times what you need, and keep only the tenth that survives a hard filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI-generated test sets are usually useless
&lt;/h2&gt;

&lt;p&gt;Two things go wrong, and I hit both.&lt;/p&gt;

&lt;p&gt;The first is no real filter. You generate thousands, run a quick "is this well-formed" check, and ship. A big chunk are subtly wrong or duplicates, and the set quietly stops testing anything. It passes every release because it is too mushy to fail.&lt;/p&gt;

&lt;p&gt;The second is sneakier, and it took me months to see. The generator has a house style, and everything it writes drifts toward that style. So your test set is really a test of how well your model imitates the model that wrote the tests, not how well it does the actual job. And if you ever fine-tune on that set, your model just learns to sound like the generator. The scores go up while nothing real gets better.&lt;/p&gt;

&lt;p&gt;Both come from the same mistake: treating generation as a one-shot prompt instead of a generate, judge, and keep loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate along three axes, not one
&lt;/h2&gt;

&lt;p&gt;If you generate one flavour of test, you get one flavour of blind spot. I now generate along three axes on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Personas.&lt;/strong&gt; Same request, different people. A rushed user on their phone and a careful expert on a laptop phrase the identical question in completely different ways, and your model has to handle both. One warning: build personas from real user data, not the model's imagination, or you get cartoons ("frustrated user" becomes a stereotype).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenarios.&lt;/strong&gt; Not one-line questions, but whole conversations with tool calls, dead ends, and recovery. This is the only way to catch a step that drops context on turn three or a tool that returns the wrong shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial versions.&lt;/strong&gt; Take a normal case and make it harder: add a step, make it ambiguous, or phrase it like someone trying to slip past your rules. Real traffic surfaces these too slowly to wait for, so you manufacture them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run one axis alone and it collapses onto the others. Run all three and you get coverage you can actually point at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter is the whole game
&lt;/h2&gt;

&lt;p&gt;This is the part everyone skips and the part that matters. After generating, I run everything through a few stages and expect to throw away most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drop duplicates by meaning, not by text.&lt;/strong&gt; Two questions can be worded differently and still be the same test. Matching on meaning catches the paraphrases that exact-text matching misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge every candidate and reject the ones that fail.&lt;/strong&gt; Is this something a real user would ask? Is the expected answer actually recoverable? Is it just a reworded copy of the seed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for that house-style collapse before you ship.&lt;/strong&gt; If your synthetic set is far less varied than real traffic, that is a regenerate signal, not a green light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-check a small sample.&lt;/strong&gt; Label five percent yourself. If the quality there is bad, throw the batch out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first time I saw that this rejected 80 percent of my candidates, I flinched. Then it caught a batch that was a third wrong before it could poison the set, and I stopped flinching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a different model family to judge than to generate
&lt;/h2&gt;

&lt;p&gt;This is the single most useful thing I picked up, so it gets its own section.&lt;/p&gt;

&lt;p&gt;If the same model both writes and grades the tests, it is blind to its own bad habits. It shares its own vocabulary quirks, its own stereotyped personas, its own shortcut reasoning, so it happily rubber-stamps exactly the junk it produced. A judge from a different family does not share those blind spots, and it agrees with human reviewers far more often.&lt;/p&gt;

&lt;p&gt;So I generate with one model and judge with another, or rotate which one grades each batch. It is a small change and it is the difference between a filter you can trust and a filter that quietly approves its own mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cover the space on purpose, or you will oversample the easy stuff
&lt;/h2&gt;

&lt;p&gt;Even with three axes, unconstrained generation piles up on the easy, obvious cases. So I lay out a simple grid, something like intent by difficulty by mood by dialect, and set a small quota per cell.&lt;/p&gt;

&lt;p&gt;The reason is dumb but important: an empty cell in a grid is visible, so you can see the gap and fill it. A gap in a random pile is invisible until it shows up as a production incident. I drop the cells that make no sense for my product and fill the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the synthetic set against real data before you trust it
&lt;/h2&gt;

&lt;p&gt;Synthetic tests are only worth anything if they resemble reality, so before I trust a set I run three quick checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the synthetic set actually sit near real traffic, or is it off in its own corner of the space?&lt;/li&gt;
&lt;li&gt;If the model wrote the labels too, I re-label a small sample by hand to make sure the labels are not carrying the generator's bias.&lt;/li&gt;
&lt;li&gt;If the set is for fine-tuning, I train on synthetic and then test on real. If it only looks good on synthetic, the generator invented a world my model is now optimising for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Different jobs need different filter strength
&lt;/h2&gt;

&lt;p&gt;The same loop ships in three shapes, and the only thing that really changes is how hard you filter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An eval set&lt;/strong&gt; gets the strictest filter. It has to be right or your CI gate is theatre. A few hundred great cases beat thousands of noisy ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fine-tuning set&lt;/strong&gt; can take more volume and a slightly looser filter, because training averages over noise. But keep the filter, or your model inherits the generator's accent forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A red-team set&lt;/strong&gt; flips the filter around. Now "too hard" is the goal. You keep the nasty ones and only reject the attacks that are not realistic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same generator, same judge. You are just turning the strictness dial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that quietly wreck an auto-generated test set
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Letting one model both write and grade the tests.&lt;/li&gt;
&lt;li&gt;Never checking for style collapse, so it only shows up months later.&lt;/li&gt;
&lt;li&gt;Trusting one overall score that hides a weak high-risk slice.&lt;/li&gt;
&lt;li&gt;Not tracking where each test came from, so you cannot re-check it when your docs change.&lt;/li&gt;
&lt;li&gt;Treating generation as a replacement for human judgement. It is volume, not judgement. The high-stakes cases still need a human.&lt;/li&gt;
&lt;li&gt;Running it once. Real traffic drifts, and a set you generated in spring is stale by autumn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing I keep coming back to is that generating the tests was never the hard part, and it is the part all the tooling loves to show off. The hard part is being willing to throw most of them away, and letting a different model be the one that decides.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on the filtering side, the judge rubric, the dedupe cutoffs, the checks that catch style collapse, &lt;a href="https://futureagi.com/blog/autoresearch-llm-test-generation-2026/?utm_source=fagiDevto&amp;amp;utm_medium=organic&amp;amp;utm_campaign=blogdistribution" rel="noopener noreferrer"&gt;this piece&lt;/a&gt; is a good one on it.&lt;/p&gt;

&lt;p&gt;If you have generated tests this way, I would love to know your keep rate. Mine settled around one in ten, and I am genuinely curious whether other people land higher, or whether everyone quietly throws most of theirs away too.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Evaluating an intent classifier: what I check beyond accuracy</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:18:03 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/evaluating-an-intent-classifier-what-i-check-beyond-accuracy-5f1f</link>
      <guid>https://dev.to/kartik-nvjk/evaluating-an-intent-classifier-what-i-check-beyond-accuracy-5f1f</guid>
      <description>&lt;p&gt;I built the intent router that sits at the front of our support agent. It reads what a user typed and decides which pipeline handles it. I tested it on a nice balanced set, got 92 percent accuracy, and shipped it feeling good.&lt;/p&gt;

&lt;p&gt;Two weeks later the on-call engineer noticed cancellation requests were landing in the billing queue. The fraud-report queue was empty while actual fraud complaints sat in product feedback. And a newer request type that did not exist when we launched was being silently filed as a billing question most of the time.&lt;/p&gt;

&lt;p&gt;The model had not broken. My test set had simply hidden the failures that actually happen in production. The 92 percent was real and useless at the same time.&lt;/p&gt;

&lt;p&gt;Here is what I learned pulling that apart, in plain terms, so you can skip the two weeks I spent finding out the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the headline accuracy number lied to me
&lt;/h2&gt;

&lt;p&gt;Real support traffic is lopsided. Five or six common intents (product questions, pricing, account access, shipping, refunds) make up most of the volume, and they are easy. The rare stuff (cancel subscription, fraud report, policy dispute, a brand-new request type) is a small slice of traffic and it is exactly where the money, the on-call pages, and the trust-and-safety incidents live.&lt;/p&gt;

&lt;p&gt;A balanced test set gives every intent equal weight. So the model learns to do fine on all of them evenly, and my one big accuracy number was dominated by the easy, high-volume classes. The rare classes could quietly collapse to 30 percent and the headline barely moved, because they were a rounding error in the average.&lt;/p&gt;

&lt;p&gt;And every one of those rare classes matters more than its volume suggests. Whatever the router picks decides which knowledge base, which tools, and which escalation path runs next. Get the routing wrong and everything downstream is confidently wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two test sets, not one
&lt;/h2&gt;

&lt;p&gt;The fix that changed everything for me was keeping two separate test sets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One that mirrors real traffic.&lt;/strong&gt; Same lopsided mix as production. This is the number I report, because it is what users actually experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One that oversamples every intent evenly&lt;/strong&gt;, a hundred or so examples per class no matter how rare. This is the one I debug against, because it is the only way to see whether the model can even do the rare classes at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap between those two is the warning sign. If the model is great on the real-traffic set but weak on a rare class in the even set, that class is one traffic shift away from becoming my next incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at each intent, not the average
&lt;/h2&gt;

&lt;p&gt;Once I stopped looking at one number and started looking at each intent separately, the problems were obvious. For every intent I now look at two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How often is it right when it fires?&lt;/strong&gt; If this is low, the model is over-using that intent as a dumping ground when it is unsure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How often does it catch the real cases?&lt;/strong&gt; If this is low, the model is quietly sending those cases to some neighbouring intent instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The single most useful view is the grid of what got confused with what. That grid told me plainly that "cancel" requests were mostly being read as "billing." Once you can see the exact pair that is colliding, the fix is usually two or three edits to how you describe those two intents to the model, not some big model change.&lt;/p&gt;

&lt;p&gt;When one intent is both rarely right and rarely caught, that is the danger zone: either the description is ambiguous, the label itself is badly defined, or the model genuinely cannot tell it apart from its neighbour. The grid tells you which.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confidence score means nothing until you calibrate it
&lt;/h2&gt;

&lt;p&gt;My router also spat out a confidence number, and I had wired the escalation logic to trust it: below some cutoff, hand off to a human. The problem is that the raw confidence was close to meaningless.&lt;/p&gt;

&lt;p&gt;Two things surprised me. The scores clustered at the extremes, either very high or very low, with almost nothing in the middle to set a cutoff against. And the same number meant different things for different intents. A 0.7 on a common product question was right almost every time, while a 0.7 on a fraud report was barely better than a coin flip.&lt;/p&gt;

&lt;p&gt;So one global cutoff was shipping a totally different level of caution per intent. What actually works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For irreversible actions like cancelling a subscription or flagging fraud, set a high bar and send anything below it to a clarifying question or a human.&lt;/li&gt;
&lt;li&gt;For catch-all safety intents, set a low bar so the system over-routes to the safe path instead of guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And treat a change to those cutoffs like a change to the model itself. Version them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the model a "none of these" option
&lt;/h2&gt;

&lt;p&gt;This was the one I had completely missed. If you do not give the model an explicit "none of these" class, it is forced to jam every input into some known label. Gibberish, off-topic questions, and outright jailbreak attempts all get filed as a real intent and routed into a real pipeline.&lt;/p&gt;

&lt;p&gt;So I added an explicit "does not fit any intent" class and trained it on the messy stuff: adversarial prompts, vague multi-topic messages, plain nonsense. Two rules made it work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If nothing clears its confidence bar, fall through to this class instead of picking the best guess.&lt;/li&gt;
&lt;li&gt;Never let this class dead-end. Route it to a "which of these did you mean" question or a human, not into a pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also watch this class's catch rate closely and never let it get worse. A drop there means real garbage is leaking into real flows again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The taxonomy you ship goes stale
&lt;/h2&gt;

&lt;p&gt;The list of intents I launched with was not the list production needed three months later. User language drifts, a product launch creates a brand-new request type, a policy change creates a new kind of complaint. That new request type being misfiled as billing was exactly this.&lt;/p&gt;

&lt;p&gt;Two things I watch for now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An existing intent slipping.&lt;/strong&gt; If its numbers move week over week, the model is fine but the incoming language shifted under it. Refresh its examples and re-baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new intent forming.&lt;/strong&gt; I group together the stuff that landed in the "none of these" bucket and the low-confidence misses, and look for a cluster that keeps showing up. If the same new pattern is there two weeks running, it has earned its own intent: label some examples, write a definition, add it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two things I wish I had known on day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Define intents by what the user wants, not what your system does.&lt;/strong&gt; "User wants a refund" is an intent. "Run the refund flow" is a tool. I had mixed the two and it muddied the labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that humans agree on the labels before blaming the model.&lt;/strong&gt; Have a few people label the same hundred examples. If they cannot agree on an intent, the model has no chance, and that is a taxonomy problem to fix first, not a model problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing I keep coming back to is that the intent router is the first decision the whole agent makes, and I had been grading it with the one number least likely to show me its real failures. The moment I looked per intent, on real traffic, with a "none of these" escape hatch, the bugs that had hidden for two weeks were sitting right there in the grid.&lt;/p&gt;

&lt;p&gt;If you run a router like this, I would love to hear which intent quietly fell apart on you. For me it will always be cancellations pretending to be billing questions.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>classification</category>
    </item>
    <item>
      <title>Evaluating LangGraph agents: the four things I score per step</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:11:30 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/evaluating-langgraph-agents-the-four-things-i-score-per-step-o84</link>
      <guid>https://dev.to/kartik-nvjk/evaluating-langgraph-agents-the-four-things-i-score-per-step-o84</guid>
      <description>&lt;p&gt;I had a LangGraph agent running in production and a regression set of fifty queries it passed comfortably. Task completion sat at 0.93. I spot-checked the answers and they looked right. I genuinely thought I had this covered.&lt;/p&gt;

&lt;p&gt;Then someone mentioned our cost per query had roughly doubled.&lt;/p&gt;

&lt;p&gt;I spent an afternoon in the traces and found three things wrong at once. The router was taking the expensive path on every query that happened to mention a date. A retry loop was firing three times before the search tool gave up, and at that point the model just invented an answer. And a step near the end was overwriting a constraint that an earlier step had carefully set.&lt;/p&gt;

&lt;p&gt;Here is the part that stung. Every single one of those still produced a correct-looking final answer. My evaluation only looked at final answers. So it saw nothing, and told me everything was fine, for a week.&lt;/p&gt;

&lt;p&gt;That was when I realised I had been evaluating the wrong thing the entire time. Took me a while to work out what to do instead, so here is the short version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the final answer hid all of it
&lt;/h2&gt;

&lt;p&gt;If your agent runs in a straight line, checking the output is mostly fine. But the reason you reach for LangGraph in the first place is that your agent is not a straight line.&lt;/p&gt;

&lt;p&gt;And everything that breaks lives in the parts a straight line does not have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Branches.&lt;/strong&gt; Something reads the state and picks a path. A right answer down the wrong path is still a bug, just an expensive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry loops.&lt;/strong&gt; A step can loop back on itself. Five retries and a correct answer costs five times one try and a correct answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared state.&lt;/strong&gt; Steps read and write to a shared bag of data. One step overwriting where it should have added is silent until it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nested graphs.&lt;/strong&gt; A step can be a whole graph itself. Flatten it when you look and you lose where the problem was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel work that merges.&lt;/strong&gt; Fan out, merge back, and a collision quietly drops data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory across turns.&lt;/strong&gt; A bug in how state gets saved might not show up until turn three.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final answer averages over all of that. It moves when things are catastrophically broken and sits perfectly still while the agent slowly rots. Which is exactly what mine did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things I score per step
&lt;/h2&gt;

&lt;p&gt;The change is easy to describe and took me a while to actually do. Stop treating the agent as a conversation with an output. Start treating it as a sequence of steps, and grade the steps.&lt;/p&gt;

&lt;p&gt;Four questions per step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Did it get what it needed?&lt;/strong&gt; The right data, the right shape, nothing missing. A planning step that runs before the user's intent is filled in is broken before it starts, and everything after inherits that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did it write back correctly?&lt;/strong&gt; Did it update the shared state the way it was meant to. This is where I finally caught the overwrite-instead-of-append bug that had cost me a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did it pick the right branch?&lt;/strong&gt; At a decision point, given the state right then, was that the correct path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it do the same thing twice?&lt;/strong&gt; Same starting state, same behaviour after. If not, something in there is quietly random.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First two catch correctness. Third catches expensive-but-correct, which is the one that got me. Fourth catches the bugs that become flaky incidents six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you only add one check, add the branch one
&lt;/h2&gt;

&lt;p&gt;This is the one I would go back and add first.&lt;/p&gt;

&lt;p&gt;A wrong branch still produces an answer, so nothing looks broken to anyone. But a five percent drop in how often one decision point chooses correctly shows up in your cost and latency weeks before quality metrics move at all. It is a real early warning, and I had nothing like it.&lt;/p&gt;

&lt;p&gt;It is also so much more actionable. Overall score drops a point and you are in for a two-day hunt. One decision point drops five points and you know exactly which instruction to go rewrite. On a Friday afternoon that difference matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grading everything gets expensive, so I stopped
&lt;/h2&gt;

&lt;p&gt;I tried scoring every step at first and the bill got silly fast. A graph of any real size does dozens of state changes per run.&lt;/p&gt;

&lt;p&gt;What works: pick the five to ten steps whose output lots of other steps depend on. Planners, routers, the merge points after parallel work. Score those every run and sample the rest. Most of the signal, a fraction of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checkpoint trick I had been ignoring
&lt;/h2&gt;

&lt;p&gt;This is my favourite thing I learned, and it was sitting in the framework the whole time.&lt;/p&gt;

&lt;p&gt;LangGraph can snapshot the agent's state at every step so a long job can pause and resume. That is what it is advertised for. But those snapshots are also the best debugging tool you have.&lt;/p&gt;

&lt;p&gt;Save one at every step and every production failure becomes a test you can rerun whenever you like. Grab the snapshot ID off the failing trace, rerun from that exact point, and watch what happens. Want to check a fix? Replay the same snapshot against the old version and the new one and compare.&lt;/p&gt;

&lt;p&gt;Before this I was guessing whether a bug would reproduce. After it I was running an actual experiment. On an incident call that is a completely different feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  My test set was testing my imagination
&lt;/h2&gt;

&lt;p&gt;My original fifty queries were fifty things I had thought of, which means they tested my assumptions rather than my agent.&lt;/p&gt;

&lt;p&gt;Now I pull the runs that scored badly, group the similar ones, and promote a representative snapshot from each group into the set with a note on what should have happened. Then replay all of them on every pull request.&lt;/p&gt;

&lt;p&gt;Every incident becomes a permanent test. The set gets stronger instead of going stale, which is the opposite of what my first one did.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is overkill
&lt;/h2&gt;

&lt;p&gt;Being honest: if your graph is five steps in a straight line with no branching, one overall score really is enough and all of this is a waste of your time.&lt;/p&gt;

&lt;p&gt;It starts paying off once you have real branching, retries, nested graphs, or state that survives across turns. In my experience that describes most agents by about month three, whether you planned for it or not.&lt;/p&gt;

&lt;p&gt;What I keep coming back to is that I had built a state machine and I was grading it like a chatbot. The moment I started scoring the steps, bugs that had hidden for a month were obvious in an afternoon.&lt;/p&gt;

&lt;p&gt;If you run agents like this, I would like to hear which check caught the most for you. For me it was the branch one, and it was not close.&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>ai</category>
      <category>agents</category>
      <category>testing</category>
    </item>
    <item>
      <title>I picked a coding agent off a leaderboard. It flopped on our codebase.</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:51:14 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/i-picked-a-coding-agent-off-a-leaderboard-it-flopped-on-our-codebase-4lf0</link>
      <guid>https://dev.to/kartik-nvjk/i-picked-a-coding-agent-off-a-leaderboard-it-flopped-on-our-codebase-4lf0</guid>
      <description>&lt;p&gt;Last year my team had to pick a coding agent, and I volunteered to run the evaluation. I felt good about it. I pulled up the public benchmark scores, lined up the contenders, took the one at the top, and told everyone we had a winner.&lt;/p&gt;

&lt;p&gt;Then we actually pointed it at our repo.&lt;/p&gt;

&lt;p&gt;It did not blow up dramatically. It just kept being slightly wrong in ways that ate our time. It wrote diffs our reviewers would not approve. It renamed a function and broke three files it had never opened. The tests it ran passed, and the repo was still broken. I had confidently recommended a tool based on a number that turned out to say almost nothing about our situation.&lt;/p&gt;

&lt;p&gt;That was embarrassing enough that I went and figured out why. It took a few weeks of reading and a couple more bad calls before I landed on something that works. This is that, written plainly, and I hope it saves you the meeting where you have to walk your recommendation back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the benchmark score lied to me
&lt;/h2&gt;

&lt;p&gt;The score was not fake. It was just measuring somebody else's code. Once I looked properly, four gaps explained the whole thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent might have already seen the answers.&lt;/strong&gt; The problems in these public benchmarks are old. Models were very likely trained on the actual fixes used to grade them. So the score partly measures memory, not problem-solving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The setup is nothing like real work.&lt;/strong&gt; A benchmark gives the agent a clean repo, one clear issue, and one command to run the tests. My engineers give it a half-open editor, a messy branch, a Slack thread, and a reviewer comment. Completely different job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our codebase has its own habits.&lt;/strong&gt; Our internal libraries, our wrappers, our test style, the imports we ban. No benchmark knows any of that, so an agent can write textbook-perfect code that our reviewers still reject on sight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bar for passing is way lower.&lt;/strong&gt; A benchmark passes a patch if the broken test now passes. My team passes a patch if it does that, &lt;em&gt;and&lt;/em&gt; does not break unrelated tests, does not reformat the whole file, does not add stray imports, and is small enough to actually review. Benchmarks grade the first thing and none of the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I stopped treating the leaderboard as a decision and started treating it as a sniff test. Fine for narrowing the field. Useless for the actual call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test set was in our git history all along
&lt;/h2&gt;

&lt;p&gt;Here is the bit I wish I had thought of on day one. The best possible test data for a coding agent is the work we had already merged.&lt;/p&gt;

&lt;p&gt;Our last fifty merged pull requests came with everything a benchmark cannot fake: the real intent, the reviewer's comments, a passing test suite, and a known-good final diff. So I replayed them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Take the last fifty merges, skip version bumps and generated files, keep the mix close to what we really ship.&lt;/li&gt;
&lt;li&gt;For each one, note the commit it started from and the issue text.&lt;/li&gt;
&lt;li&gt;Roll the repo back to that starting point, hand the agent the issue, let it work in a sandbox.&lt;/li&gt;
&lt;li&gt;Compare what it produced against what we actually merged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I got this wrong on my first attempt, so learn from it: I scored on how closely the text matched. That is both unfair and useless. A human reviewer would happily merge a differently-worded solution that behaves the same way. The question is not "does this look like our diff." It is "would this have passed the same review our diff passed."&lt;/p&gt;

&lt;p&gt;I also swap in a freshly merged PR every week and drop the oldest, otherwise the set slowly stops resembling what we ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things I ended up scoring
&lt;/h2&gt;

&lt;p&gt;After running enough replays I stopped guessing and settled on five things. Each one catches something a benchmark never sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would the diff pass our review?&lt;/strong&gt; Behavior, not text. I mark it down for reformatting files nobody asked it to touch, for breaking a test the merged version passed, and for taking twice the diff to reach the same outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did it use its tools in a sane order?&lt;/strong&gt; A coding agent is a loop, not a text generator. It reads, edits, runs tests, reacts, commits. I check four things: did it read a file before editing it, did the edit land where it said it would, did it actually run the tests afterward or just commit blind, and did it commit only what it changed. Editing a file it never opened is just guessing with confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did it update everything the change touched?&lt;/strong&gt; This is the one that burned us. The agent renames a helper in one file and leaves broken call sites scattered across others. The tests in the file it touched pass. The repo is wrecked. So now I follow the imports outward from every changed file and check that the downstream files still line up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did the plan survive the work?&lt;/strong&gt; Modern agents write a plan before touching anything, and that plan is worth grading. Two questions: did the plan match what was actually asked, and did the final diff still match the plan. Agents drift halfway through and quietly contradict the original request while the tests keep passing. This turned out to be the best predictor of which agent our reviewers ended up trusting, which I did not expect at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it know when to give up on a bad idea?&lt;/strong&gt; This was the sleeper, and it is now the one I care about most. When tests fail after an edit, a disciplined agent reverts and tries another approach. An undisciplined one piles more code on top of the failure until the suite is in pieces. You can see it plainly in the trace: how many edits does it make per failed test run, and does it ever actually revert. An agent averaging several edits per failure with no reverts will ship regressions. Count on it.&lt;/p&gt;

&lt;p&gt;The first three catch correctness. The fourth catches intent. The fifth catches whether you can trust it unsupervised.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more check I added after a scare
&lt;/h2&gt;

&lt;p&gt;Coding agents invent credentials and echo risky shell patterns more often than I expected. So now every generated diff gets scanned for made-up secrets, dangerous constructs like raw &lt;code&gt;eval&lt;/code&gt; or shell interpolation, and whatever patterns our own repo has banned. It takes milliseconds and it has already caught things. Cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;The agent that won our replay was not the one at the top of the leaderboard. It was the one whose plans stayed honest and that knew when to back out of a bad edit. Neither of those shows up in a public score, and both were exactly what our reviewers cared about.&lt;/p&gt;

&lt;p&gt;The line I settled on: the vendor owns the model and the prompt. I own the evaluation and the decision. And my evaluation is the only one that knows what our code actually looks like.&lt;/p&gt;

&lt;p&gt;If you have run something like this on your own repo, I would love to hear which dimension caught you off guard. For me it was rollback. It never occurred to me to measure whether an agent knows how to abandon a bad idea, and it turned out to matter more than almost anything else.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>codingagents</category>
      <category>testing</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I built an LLM eval framework from scratch. Here is what I wish I had bought instead.</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:50:54 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/i-built-an-llm-eval-framework-from-scratch-here-is-what-i-wish-i-had-bought-instead-2dk1</link>
      <guid>https://dev.to/kartik-nvjk/i-built-an-llm-eval-framework-from-scratch-here-is-what-i-wish-i-had-bought-instead-2dk1</guid>
      <description>&lt;p&gt;One weekend I wrote an LLM eval framework in about two hundred lines of Python. It demoed beautifully. I felt clever.&lt;/p&gt;

&lt;p&gt;Six months later that same framework was a mess. Three different judge models with three different parsing hacks. A test dataset nobody had touched since November. A CI gate that kept failing because a vendor nudged their model, not because anyone broke a prompt. And the second engineer on rotation asking me, fairly, "how does this even work?"&lt;/p&gt;

&lt;p&gt;The framework did not fail. The eighty percent of the work the weekend tutorial skipped is what failed. That gap is the whole story, and this is what I would tell myself before starting again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line I wish someone had told me: build the rubric, buy the runner
&lt;/h2&gt;

&lt;p&gt;Here is the split that took me six months to see.&lt;/p&gt;

&lt;p&gt;Some parts of an eval setup are yours and only yours. The rubric that decides what "good" means for your product. The dataset built from your real failures. The rules for when a change is bad enough to block a release. Nobody else can write these, because they encode your domain.&lt;/p&gt;

&lt;p&gt;The rest is the same at every company. The thing that calls the judge model, parses its answer, retries, and caches. The machinery to run thousands of checks in parallel. The plumbing that scores live traffic. The system that groups failing calls together. Every team rebuilds these, hits the same bugs, and gains nothing by writing them twice.&lt;/p&gt;

&lt;p&gt;So build the first list. Do not hand-build the second. I rebuilt the second, and it cost me most of a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions I now ask about every single piece
&lt;/h2&gt;

&lt;p&gt;Before writing any part of this, I ask two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is it specific to me, or generic?&lt;/strong&gt; A rubric for my domain is specific and worth owning. A retry-and-cache loop around a model call is generic. Everyone writes the same one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it compound, or does it rot?&lt;/strong&gt; A dataset that grows from real production failures compounds. A year in, it is a regression suite no competitor can copy. A hand-built tracing layer rots. The moment a vendor changes something, you are behind and patching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build the pieces that are specific and compound. Reuse everything else. That single filter would have saved me the whole mess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually worth building yourself
&lt;/h2&gt;

&lt;p&gt;Four things earned their place, and all four live in the same git repo as the agent code, reviewed in the same pull request as the prompt they score:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rubric.&lt;/strong&gt; Keep it in git, not a database, so changes show up in diffs. And make it specific. "Rate the quality" is noise. "Score 5 if every claim is supported by the source, 1 if it is largely made up" is a signal you can act on. Broken-down rubrics like that agree with human reviewers noticeably more often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dataset.&lt;/strong&gt; A hundred real cases from production beat two hundred you invented. Pull the hardest failures in, because most of the signal lives in the worst ten percent of calls. Tag each case by area so a change to one feature does not rerun everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gating rules.&lt;/strong&gt; The exact thresholds and policy for blocking a release are yours. The math behind them is not, but the decision of where the bar sits is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your labels.&lt;/strong&gt; The human judgement about what a correct score looks like. Only your team can produce that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I should not have built
&lt;/h2&gt;

&lt;p&gt;The judge engine is the one that hurt the most, so start there.&lt;/p&gt;

&lt;p&gt;Every judge model hands back its score in a slightly different shape. One wraps it in tags, another writes "Score: 4," another buries the number after a paragraph of rambling. My parser caught almost all of it on day one. On day ninety a vendor shipped an update that formatted numbers differently, my parser quietly fell back to a middle score on the cases it missed, and my dashboard showed evals gently trending up. They were not. Middle scores just drag everything toward the average.&lt;/p&gt;

&lt;p&gt;On top of that came drift, where a judge scores the same answer differently this month than last after a model update, so the gate starts failing on prompts nobody touched. And cost, where running a top-tier judge across a big nightly set becomes a real budget line. Getting all of that right is months of work for an output that looks identical at every company.&lt;/p&gt;

&lt;p&gt;The same goes for running checks at scale, scoring live traffic, and grouping failing calls into themes. All generic, all a long slog, none of it your edge. I would reuse an existing runner for every one of these now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate on the change, not the average
&lt;/h2&gt;

&lt;p&gt;This was my most expensive small mistake. I set the gate to "fail if the average score drops below a line."&lt;/p&gt;

&lt;p&gt;The trouble is that a small dataset is noisy. With only thirty test cases, the average wobbles by a few points on its own, just from which cases you picked. So my gate failed on random noise and passed on real regressions, and within a month everyone had learned to ignore it, which is worse than having no gate at all.&lt;/p&gt;

&lt;p&gt;What actually works is two checks together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hard floor that catches a catastrophic drop.&lt;/li&gt;
&lt;li&gt;A comparison against a rolling recent baseline that only fires when the drop is bigger than the normal wobble.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is the difference between a gate people trust and a gate people mute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the same check in CI and in production
&lt;/h2&gt;

&lt;p&gt;I kept my checks in CI only, and production drifted past my dataset within a quarter.&lt;/p&gt;

&lt;p&gt;The fix is to run the exact same rubric in both places: against your test set before release, and against live traffic after. When they disagree, that disagreement is useful. If CI is green but production is failing, your dataset has stopped looking like reality. If CI fails on something production never shows, your dataset is carrying dead weight. Either way you learn something, but only if it is the same check on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost nobody warned me about
&lt;/h2&gt;

&lt;p&gt;The weekend version is genuinely a weekend. The real version is not. Once you are shipping a couple of products, keeping all of this alive is close to one to one and a half full-time engineers.&lt;/p&gt;

&lt;p&gt;And the cost never shows up when you expect it. It shows up the week a judge model updates and your parser breaks. The week a vendor changes their tracing format. The month your dataset quietly stops covering your product's top failure. None of that is on the weekend roadmap, and all of it is the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  When building the whole thing yourself is actually right
&lt;/h2&gt;

&lt;p&gt;To be fair, sometimes it is the correct call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are a research lab and the eval method itself is the product.&lt;/li&gt;
&lt;li&gt;You have a strict isolation requirement where every dependency needs a long security review.&lt;/li&gt;
&lt;li&gt;Your domain is so narrow that a general setup is just dead weight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a few signs you have crossed the line and should stop hand-building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A second engineer joined and the framework now lives in one person's head.&lt;/li&gt;
&lt;li&gt;You are maintaining more than one parser for more than one judge.&lt;/li&gt;
&lt;li&gt;The eval bill has become a quarterly conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The mistakes I would warn a friend about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treating the eval setup as a side project instead of code that ships with the prompt.&lt;/li&gt;
&lt;li&gt;Keeping rubric prompts in a database where changes never show up in a diff.&lt;/li&gt;
&lt;li&gt;Letting the judge model float instead of pinning a version.&lt;/li&gt;
&lt;li&gt;Gating on the average of a tiny dataset.&lt;/li&gt;
&lt;li&gt;Running the full expensive judge on every single pull request.&lt;/li&gt;
&lt;li&gt;Freezing the dataset at launch, so a two-year-old test set is grading a brand-new product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I keep coming back to is that the hard, valuable part was never the code. It was the rubric and the dataset, the two things only my team could write, and I spent my first six months building everything except those.&lt;/p&gt;

&lt;p&gt;If you have built one of these, I would love to hear which piece quietly ate the most of your time. For me it will always be that parser.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>testing</category>
      <category>cicd</category>
    </item>
    <item>
      <title>How I caught the voice-agent failures my eval dashboard kept missing</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Tue, 30 Jun 2026 06:59:33 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/how-i-caught-the-voice-agent-failures-my-eval-dashboard-kept-missing-jf0</link>
      <guid>https://dev.to/kartik-nvjk/how-i-caught-the-voice-agent-failures-my-eval-dashboard-kept-missing-jf0</guid>
      <description>&lt;p&gt;I was working on a retail support voice agent, and on paper it looked great. Transcription quality, green. Conversation coherence, green. Task completion, green. Every standard metric I had was happy.&lt;/p&gt;

&lt;p&gt;The problem was that it kept getting order numbers wrong. It would confidently read back the wrong number, the call would still "complete," and my dashboard would still show green. The checks I had simply did not know what an order number was, or why getting it wrong was the whole ballgame.&lt;/p&gt;

&lt;p&gt;That is the gap I want to write about. The default metrics catch the failures every voice agent shares. They cannot catch the failures that belong to your specific domain. Here is what I learned about writing checks that do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built-in metrics catch the universal failures, not yours
&lt;/h2&gt;

&lt;p&gt;Every voice agent, in any business, can fail in the same handful of ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did it transcribe what the user said correctly?&lt;/li&gt;
&lt;li&gt;Did the conversation stay coherent across turns?&lt;/li&gt;
&lt;li&gt;Did it actually finish the task by the end of the call?&lt;/li&gt;
&lt;li&gt;Did it stay safe, no leaked personal data, no obvious prompt injection?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Off-the-shelf checks handle all of that well. What they cannot know is your taxonomy. A retail agent lives or dies on order numbers. A clinical scribe lives or dies on drug names. An insurance agent lives or dies on coverage codes. None of that is in a generic metric, so none of it shows up on a generic dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know when to reach for a custom check
&lt;/h2&gt;

&lt;p&gt;The rule I settled on is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the failure is universal, use a built-in check.&lt;/li&gt;
&lt;li&gt;If the failure is specific to your domain, write a custom one.&lt;/li&gt;
&lt;li&gt;In production, run both on every call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last point matters more than it sounds. The two layers catch completely different kinds of errors. A call can pass the universal checks and still fail the domain one, which is exactly what was happening to me. You want both signals, not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a custom check actually looks like
&lt;/h2&gt;

&lt;p&gt;It helps to see a few real ones, in plain terms, no code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drug names for a medical scribe.&lt;/strong&gt; Not just "was the word spelled right," but did the drug, the dose, the frequency, and the route all survive together. Getting three of four right is still a dangerous note.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand voice for a marketing line.&lt;/strong&gt; Did it open with the approved greeting, avoid the banned phrases, and keep the right tone. A coherent call can still sound nothing like the brand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote correctness for an insurance claim.&lt;/strong&gt; Check each field, the deductible, the limit, the exclusions, against the actual policy, and flag any mismatch for a human to look at.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case the check encodes something only my team knows. That is the whole point of writing your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the shape of the score before you write it
&lt;/h2&gt;

&lt;p&gt;Before writing the check, decide what kind of answer it gives back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes or no&lt;/strong&gt;, for clean pass-or-fail rules ("did it read the right order number").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A category label&lt;/strong&gt;, when you want to sort calls into buckets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A score from 0 to 1&lt;/strong&gt;, when quality is a gradient, like how closely the brand voice matched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the one that matches what you will do with it. A pass-rate dashboard wants yes or no. A trend line wants the gradient. Choosing the shape first saves you from rewriting the check later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to actually write one
&lt;/h2&gt;

&lt;p&gt;There are two honest paths, and they suit different people.&lt;/p&gt;

&lt;p&gt;The first is to write the rule yourself, in plain English: spell out exactly what counts as a pass, what counts as a fail, and where the grey area is. Best when you, the engineer, already know the rule cold.&lt;/p&gt;

&lt;p&gt;The second is to let a tool propose one. Some tooling reads your failed calls, groups the similar ones together, and drafts a candidate rule with example pass and fail cases pulled from real calls. You then edit and accept it. This is faster when the person reviewing is a domain expert rather than a coder.&lt;/p&gt;

&lt;p&gt;Whichever path you take, keep one rule: a human approves the check before it goes live. Do not let anything auto-promote its own scoring into production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually makes it work: calibrate against humans
&lt;/h2&gt;

&lt;p&gt;This is the step I underestimated. Your first version of a check will score some calls wrong. Calibration is how you close that gap.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build a small set of calls and have a domain expert label each one with the "true" score.&lt;/li&gt;
&lt;li&gt;Run your check against that set and see where it disagrees with the expert.&lt;/li&gt;
&lt;li&gt;Fix the wording or the examples, run it again, and repeat until it mostly agrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plan for a few rounds, not one. And keep a human approving every change, with a record of who approved what. In anything regulated, that paper trail is the thing that makes the whole setup usable at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pair the judge with a hard check whenever you can
&lt;/h2&gt;

&lt;p&gt;A check that leans on a language model to judge will drift, and it quietly lets false passes pile up over time. So wherever a real, verifiable test exists, run it alongside the judge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a number that has to fall in a valid range,&lt;/li&gt;
&lt;li&gt;a format or schema that has to validate,&lt;/li&gt;
&lt;li&gt;an exact entity that has to match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The judge handles the fuzzy part, the hard check handles the part that has a right answer, and together they catch what either one alone would miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-run new versions on old calls
&lt;/h2&gt;

&lt;p&gt;Every time I tweaked a check, I asked one question: how does the new version score the last month of calls compared to the old version? Re-running a check over your call history gives you that before-and-after diff directly. It is the easiest way to catch a "small" wording change that secretly moved everyone's scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tradeoffs I made on purpose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calibration stays manual.&lt;/strong&gt; A human signs off on every change to a check. It is slower, but it is the only version of this I actually trusted in a regulated workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I only sample a slice of calls&lt;/strong&gt; for the heavier scoring, to keep cost and privacy in check. Rare failures still surface, because grouping failed calls together brings the uncommon ones up even when you are not scoring everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The metric that finally caught my order-number problem was never going to be on a default dashboard. I had to write it, and then spend more time calibrating it against a human than writing it in the first place. That ratio surprised me, but it is also the reason the check ended up trustworthy.&lt;/p&gt;

&lt;p&gt;If you run voice agents, I would love to hear which domain failure your standard metrics never caught. For me it will always be that confidently wrong order number.&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
    </item>
    <item>
      <title>How I A/B test LLM prompts without fooling myself</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Tue, 23 Jun 2026 19:22:48 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/how-i-ab-test-llm-prompts-without-fooling-myself-528f</link>
      <guid>https://dev.to/kartik-nvjk/how-i-ab-test-llm-prompts-without-fooling-myself-528f</guid>
      <description>&lt;p&gt;A while back I was building a support assistant and hit a simple-sounding question: is this new version of the prompt actually better than the old one? So I did the obvious thing. I wrote thirty test cases, ran both prompts, saw the new one score a little higher, and shipped it.&lt;/p&gt;

&lt;p&gt;I felt great about it for half a day, until the support queue filled with complaints and I was rolling the change back from a Slack thread that evening.&lt;/p&gt;

&lt;p&gt;The bump in the score was never real. The test was far too small to tell a tiny genuine improvement apart from luck, so the number was noise the whole time. That one lesson is behind almost everything below. This is the guide I swear by now, in plain language, and I hope it saves you the same evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, figure out how small a change your test can even see
&lt;/h2&gt;

&lt;p&gt;A small test can only catch large differences. If the real improvement is small, thirty examples cannot tell it apart from the random wobble of which examples you happened to pick. Run it again and it often flips.&lt;/p&gt;

&lt;p&gt;So before testing anything, I now answer two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is the smallest improvement that would actually be worth shipping?&lt;/li&gt;
&lt;li&gt;Do I have enough examples to see an improvement that small?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch is how fast the numbers grow. To catch an improvement half as big, you need about four times as many examples. In practice, thirty examples can barely see anything under a ten-point swing. A four-point improvement took me a few hundred examples. A two-point one took over a thousand. Below a hundred, you are measuring your scorer's mood, not your prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test both versions on the very same inputs
&lt;/h2&gt;

&lt;p&gt;My next mistake looked harmless: I gave version A one batch of questions and version B a different batch, then compared averages. But some questions are just harder. If B drew the easier batch, it looks better even when it is worse.&lt;/p&gt;

&lt;p&gt;The fix is simple. Run both versions through the exact same questions and compare them one question at a time. The difficulty cancels out, because both faced the same hard ones, and all that is left is the real gap between the prompts.&lt;/p&gt;

&lt;p&gt;This let me trust results from a few hundred examples that would otherwise have needed four times as many. The only cost is running every question twice, which is far cheaper than collecting four times the questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a range on the improvement, not a single average
&lt;/h2&gt;

&lt;p&gt;A single average never told me enough, even with a "statistically significant" stamp. That stamp only says the difference probably is not exactly zero. It says nothing about how big it is, and the size is what decides whether I ship.&lt;/p&gt;

&lt;p&gt;So I report a range: the smallest and largest the true improvement is likely to be.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the range still includes zero, I do not have a real win, however nice the average looks.&lt;/li&gt;
&lt;li&gt;If the whole range sits above the smallest improvement worth shipping, I ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One easy mistake to avoid: build the range from the per-question differences, keeping each question's two scores paired together. If you instead compare the two overall averages as if they were unrelated, you throw away the benefit of testing on the same inputs and the range balloons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score each answer alone, or judge the two side by side
&lt;/h2&gt;

&lt;p&gt;There are two ways to score, and they answer different questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Score each answer on its own&lt;/strong&gt; against a checklist (did it stay grounded, did it fully answer). Use this when you need an absolute number to hold against a bar you set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put both answers side by side&lt;/strong&gt; and ask which is better. Use this when the quality is fuzzy, like tone or helpfulness, or when your scorer drifts run to run. Comparing two things directly is steadier than scoring each in a vacuum.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simple rule: if the question is "is B better than A," judge side by side. If it is "does B clear the bar," score each one alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you cannot wait for a full test, let the traffic pick the winner
&lt;/h2&gt;

&lt;p&gt;Once I had three versions to compare, on a low-traffic feature, a normal test would have taken weeks. And the whole time, two of the three were probably worse and still going out to real people. Waiting a month while users got worse answers did not sit right.&lt;/p&gt;

&lt;p&gt;So I used something with an odd name: a bandit. A normal test gives every version an equal share of traffic and waits until the end to call a winner, so half your users keep getting the weaker prompt the whole time. A bandit does not wait. It watches results as they come in and quietly sends more people to whatever is pulling ahead, and fewer to the rest. The longer it runs, the fewer users ever see the weak versions.&lt;/p&gt;

&lt;p&gt;The name is just a leftover from gambling. A slot machine was a one-armed bandit, and this is the old puzzle of picking between machines when you do not yet know which pays out best. Swap machines for prompt versions and that was my situation.&lt;/p&gt;

&lt;p&gt;The catch is that it gives up a clean number. Because it starves the weaker versions of traffic on purpose, you cannot say at the end that B beat A by exactly this much. So I only reach for a bandit when I have three or more versions, when even the weak ones are still usable, and when waiting on a full test costs more than a fuzzy final verdict. When I need a result I can defend to leadership, or it is just two versions head to head, I go back to the normal test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sometimes it is better to generate the variants, not hand-write one
&lt;/h2&gt;

&lt;p&gt;For a long time my loop was: think hard, write one new version, test it, keep it if it won. The blind spot is that one hand-written version is a tiny slice of all the ways you could phrase the thing.&lt;/p&gt;

&lt;p&gt;Once I had a scorer I trusted and labeled examples, I let a tool do the exploring. It generates many variations and searches for the ones that score well, far more than I would write by hand. Then I take its best candidate and run the same careful comparison against what is already in production. The tool covers ground I could not, and the A/B still makes the final call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that quietly ruin these tests
&lt;/h2&gt;

&lt;p&gt;A few traps I fell into, so you do not have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An average with no range.&lt;/strong&gt; A small bump on thirty examples is noise wearing a result's clothes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A different batch of questions per version.&lt;/strong&gt; You cannot tell a better prompt from an easier batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swapping the scorer mid-test.&lt;/strong&gt; That is a different test, not the same one. Lock it down first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peeking and stopping the moment it looks good.&lt;/strong&gt; That manufactures wins that were never there. Decide when you will look before you run it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watching five "main" numbers at once.&lt;/strong&gt; With five, there is about a one in four chance one looks like a win by pure luck. Pick one number and one bar up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the scorer before checking the scorer.&lt;/strong&gt; If your automatic judge does not match careful human judgement, it can flip the result on its own noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole guide, and none of it is clever, which is sort of the point. The hard part was never running the test. It was learning when the result is real, and that comes down almost every time to whether I had enough examples to see what I was looking for. I hope having it in one place saves you an evening or two. If you have run these comparisons yourself, I would love to hear where your offline results and your live numbers parted ways. For me it is almost always how often the new prompt starts refusing things it should have answered.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>prompts</category>
      <category>testing</category>
    </item>
    <item>
      <title>How I set up RAG evals in CI/CD so they actually catch regressions</title>
      <dc:creator>Kartik N V J K</dc:creator>
      <pubDate>Mon, 15 Jun 2026 17:40:15 +0000</pubDate>
      <link>https://dev.to/kartik-nvjk/how-i-set-up-rag-evals-in-cicd-so-they-actually-catch-regressions-46hb</link>
      <guid>https://dev.to/kartik-nvjk/how-i-set-up-rag-evals-in-cicd-so-they-actually-catch-regressions-46hb</guid>
      <description>&lt;p&gt;I have hit this a few times.A PR lands late in the day, the RAG eval runs in under a minute, green check, merge.Twelve hours later support tickets start coming in.&lt;/p&gt;

&lt;p&gt;The trace shows the retriever switched its top-1 chunk on a class of queries the 30-example dataset never covered. Suite Groundedness stayed at 0.91. Production Groundedness on the affected traffic was 0.62.&lt;/p&gt;

&lt;p&gt;The gate passed because it was not checking the right thing.Most CI eval gates I have seen for RAG are smoke tests. Small dataset, mean compared against a fixed floor, pass unless something is badly broken.&lt;/p&gt;

&lt;p&gt;The dataset is not representative, the floor is not tied to baseline variance, and the threshold does not separate a real regression from judge noise.So a green check does not tell you much.&lt;/p&gt;

&lt;p&gt;The way I think about it now: a gate wants three things at once -cheap, fast, and statistically significant and you usually get two.&lt;br&gt;
A 30-example dataset at 12 cents per PR fails on significance.A 2,000-example sweep at 9 dollars per PR fails on cost and speed.&lt;br&gt;
The work is holding all three.&lt;/p&gt;
&lt;h2&gt;
  
  
  I split the gate into three tiers
&lt;/h2&gt;

&lt;p&gt;Running the full LLM-judge sweep on every push was my first mistake.That belongs on nightly main, not an in-progress branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every push:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cheap classifier rubrics (NLI faithfulness, claim_support) plus deterministic floors (citation validity, schema, latency).&lt;/li&gt;
&lt;li&gt;Under three minutes against 100 to 200 examples.&lt;/li&gt;
&lt;li&gt;Blocks the merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Nightly main:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The full LLM-judge stack against the versioned dataset.&lt;/li&gt;
&lt;li&gt;15 to 30 minutes.&lt;/li&gt;
&lt;li&gt;Blocks the next promotion to canary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Canary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same rubrics scoring 5 to 10 percent of live traffic.&lt;/li&gt;
&lt;li&gt;Alarms on rolling-mean drift.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The dataset is the gate's worldview
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A 2,000-example set built from my own head loses to a 200-example set sampled from production.If it misses the failure modes that show up at 2 am, so does the gate.&lt;/li&gt;
&lt;li&gt;Below 100 examples per route the variance drowns the signal.&lt;/li&gt;
&lt;li&gt;Above 500 the per-PR judge bill grows faster than detection improves.My PR range is 100 to 200 per route, covering happy paths, edge cases, refusals, and the hardest 10 percent of past incidents.&lt;/li&gt;
&lt;li&gt;The field most people skip is &lt;code&gt;expected_chunks&lt;/code&gt;, the ground-truth doc IDs.&lt;/li&gt;
&lt;li&gt;Without it you can score generation but not retrieval, and the bisect takes a day instead of an hour.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Five rubrics cover most regressions
&lt;/h2&gt;

&lt;p&gt;The five rubrics that gate most RAG regressions in CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Groundedness&lt;/li&gt;
&lt;li&gt;Context Relevance&lt;/li&gt;
&lt;li&gt;Answer Relevance&lt;/li&gt;
&lt;li&gt;Citation Validity&lt;/li&gt;
&lt;li&gt;Retrieval Recall&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I split them by layer so the bisect is easy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ContextRelevance drops while Groundedness holds → retriever regressed.&lt;/li&gt;
&lt;li&gt;Groundedness drops while ContextRelevance holds → generator regressed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Citation validity is just a string match, so I run it on 100 percent of responses and keep the judge bill on the rubrics that need semantic scoring.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gating on the delta, not the mean
&lt;/h2&gt;

&lt;p&gt;This is the part most setups skip and the one that matters.&lt;/p&gt;

&lt;p&gt;A green check should mean the PR did not introduce a statistically significant regression, not that the mean sat above some floor.&lt;/p&gt;

&lt;p&gt;I use two thresholds.&lt;/p&gt;

&lt;p&gt;An absolute floor per rubric catches the obvious break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Groundedness ≥ 0.85&lt;/li&gt;
&lt;li&gt;ContextRelevance ≥ 0.80&lt;/li&gt;
&lt;li&gt;Citation validity ≥ 0.99&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A delta gate against the trailing 7-day rolling baseline catches the slow drift, using Welch's t-test on per-example scores.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;regression_gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fail only if the mean dropped, the change is significant, and the effect is big enough.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&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="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no regression (delta=+&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ttest_ind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;equal_var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, p=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (not significant)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; below effect floor &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;min_effect&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regression: delta=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, p=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 30-example dataset gives a confidence interval of about ±0.07 on a 1–5 mean, so a 2-point drop sits inside the noise.&lt;/p&gt;

&lt;p&gt;Gate on that and you are wrong half the time, and after two false alarms people stop trusting the gate.&lt;/p&gt;

&lt;p&gt;For long-tail failures that hide in averages, gate on percentiles instead.&lt;/p&gt;

&lt;p&gt;The one rule that keeps this honest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The baseline is a rolling production window, not a frozen number.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The GitHub Actions wiring
&lt;/h2&gt;

&lt;p&gt;The workflow itself is path-scoped triggers, a cache, parallel pytest, and the cheap-rubric assertions.&lt;/p&gt;

&lt;p&gt;This is roughly what I run on PRs.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RAG Evals&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rag/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evals/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fi-evaluation.yaml"&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rag-evals-${{ github.head_ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;pr-gate&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;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.eval_cache&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;evals-${{ hashFiles('evals/rubrics/**', 'evals/datasets/**') }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install -r requirements.txt&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;routes&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "routes=$(python evals/affected_routes.py)" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cheap-rubric gate&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.routes.outputs.routes != '[]'&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;FI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;FI_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FI_SECRET_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fi run --check --strict --parallel 16 -c evals/fi-evaluation.yaml&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Statistical delta gate&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.routes.outputs.routes != '[]'&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;FI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;FI_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FI_SECRET_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;BASELINE_WINDOW_DAYS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7"&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest evals/test_rag.py -n auto --routes='${{ steps.routes.outputs.routes }}'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eval-report-${{ github.sha }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eval-report.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate nightly cron workflow runs the full sweep across all routes and posts the daily baseline back to the observer.&lt;/p&gt;

&lt;p&gt;The same shape works on GitLab CI, Buildkite, Jenkins, or CircleCI, since it is just pytest, a CLI, and a cache action.&lt;/p&gt;

&lt;p&gt;Three things paid back in the first week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Path-scoped triggers&lt;/li&gt;
&lt;li&gt;Concurrency cancel-in-progress&lt;/li&gt;
&lt;li&gt;Eval report artifacts with failing examples&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those turned borderline regressions into short conversations instead of arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the same rubric in production
&lt;/h2&gt;

&lt;p&gt;Offline CI catches the regressions I can think of.Production catches the rest.So I run the same rubric definition in both places.The CI version lives in code.&lt;/p&gt;

&lt;p&gt;The production version runs against live OTel spans as a score attached to the trace.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fi_instrumentation&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fi_instrumentation.fi_types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;EvalTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EvalTagType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EvalSpanKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EvalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ProjectType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;eval_tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;EvalTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;eval_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GROUNDEDNESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalTagType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBSERVATION_SPAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalSpanKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
        &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retrieval.documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.value&lt;/span&gt;&lt;span class="sh"&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;span class="nc"&gt;EvalTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;eval_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONTEXT_RELEVANCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalTagType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBSERVATION_SPAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalSpanKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRIEVER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
        &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input.value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retrieval.documents&lt;/span&gt;&lt;span class="sh"&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;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;project_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ProjectType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBSERVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;project_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legal-rag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;eval_tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;eval_tags&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 score lands as a span attribute, so a failing trace shows up with its rubric score next to latency and chunk IDs.&lt;/p&gt;

&lt;p&gt;I sample 5 to 10 percent of production traffic for the LLM-judge rubrics and run the cheap ones on 100 percent.&lt;/p&gt;

&lt;p&gt;Then I alarm on a sustained 2 to 5 point drop in a rubric's rolling mean over a 15 to 60 minute window.&lt;/p&gt;

&lt;p&gt;The gap between the CI baseline and the production rolling mean is its own signal.&lt;/p&gt;

&lt;p&gt;When it widens, the dataset has stopped being representative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop so the dataset keeps up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A dataset stops being a regression suite the moment production drifts past it.&lt;/li&gt;
&lt;li&gt;The loop is what keeps the gate honest.&lt;/li&gt;
&lt;li&gt;Failing production traces get clustered into named issues.&lt;/li&gt;
&lt;li&gt;An analysis pass writes up the root cause and a fix for each one.&lt;/li&gt;
&lt;li&gt;The worst representative traces get promoted into the eval set with rubric labels.&lt;/li&gt;
&lt;li&gt;The next PR touching that path either clears the new entries or fails on them.&lt;/li&gt;
&lt;li&gt;Over a few weeks the gate gets stronger instead of decaying because it learns the failures that actually happened rather than the ones I guessed at during setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pitfalls I watch for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Scoring only Groundedness
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Catches hallucinations.&lt;/li&gt;
&lt;li&gt;Misses retrieval regressions.&lt;/li&gt;
&lt;li&gt;Run all five core rubrics.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  No expected_chunks in the dataset
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You cannot score retrieval recall without ground truth.&lt;/li&gt;
&lt;li&gt;The labeling pays back the first time retrieval breaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A floating judge model
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Scores drift across runs of the same eval.&lt;/li&gt;
&lt;li&gt;Pin and version the judge alongside the rubric.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Floors with no delta gate
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Slow regressions stay under the floor for months.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The delta gate is what catches them.&lt;/p&gt;
&lt;h3&gt;
  
  
  A 30-example dataset with a mean gate
&lt;/h3&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Variance wider than the regressions you are chasing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Grow the set or gate on percentiles.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The full LLM-judge sweep on every PR
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Too slow and too expensive.&lt;/li&gt;
&lt;li&gt;Cheap rubrics on PRs.&lt;/li&gt;
&lt;li&gt;Heavy sweeps nightly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A dataset frozen at launch
&lt;/h3&gt;

&lt;p&gt;A 2024 set scoring a 2026 product is a benchmark, not a regression suite.&lt;/p&gt;

&lt;h3&gt;
  
  
  No cache
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Cost climbs and flaky network calls block PRs.&lt;/li&gt;
&lt;li&gt;Cache verdicts.&lt;/li&gt;
&lt;li&gt;Invalidate on rubric or judge version change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A production observer using a different rubric than CI
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Then people argue about which number is real instead of fixing the bug.&lt;/li&gt;
&lt;li&gt;One definition.&lt;/li&gt;
&lt;li&gt;Run it in both places.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tradeoffs I made on purpose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Statistical gating slows the first week
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Building the baseline window takes a few nightly runs before the delta gate means anything.&lt;/li&gt;
&lt;li&gt;The payoff is that a red PR starts meaning a real regression, which is the only reason people leave the gate on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sharded, path-scoped runs cost orchestration
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Route-aware test selection and a distributed runner are extra wiring.&lt;/li&gt;
&lt;li&gt;The return is the difference between a four-minute and a thirty-second PR gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The classifier cascade needs rubric discipline
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Cascades work where the classifier has a clean target.&lt;/li&gt;
&lt;li&gt;They fall apart on subjective axes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - Deciding which rubrics cascade is a one-time design call you have to actually make.
&lt;/h2&gt;

&lt;p&gt;If you have built a gate like this, I am curious which of the three corners gave you the most trouble.&lt;/p&gt;

&lt;p&gt;For me it was significance, every time.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>cicd</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
