<?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 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>
