<?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: Ashwin Ugale</title>
    <description>The latest articles on DEV Community by Ashwin Ugale (@ashwin_ugale_102f2abc9cec).</description>
    <link>https://dev.to/ashwin_ugale_102f2abc9cec</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%2F4042600%2F2a9b8c03-745a-429d-bd5b-5d84e44c679f.jpg</url>
      <title>DEV Community: Ashwin Ugale</title>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashwin_ugale_102f2abc9cec"/>
    <language>en</language>
    <item>
      <title>Would your RAG eval suite notice if someone weakened the prompt?</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:19:46 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/would-your-rag-eval-suite-notice-if-someone-weakened-the-prompt-56i4</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/would-your-rag-eval-suite-notice-if-someone-weakened-the-prompt-56i4</guid>
      <description>&lt;p&gt;You have a RAG eval suite. Probably faithfulness and answer-relevancy — the two metrics almost every RAG suite starts with. It's green in CI.&lt;/p&gt;

&lt;p&gt;Here's a question that suite can't answer for you: if someone weakened your system prompt next week — inverted "do not invent facts," dropped "cite the source," trimmed half the instructions — would those two metrics go red?&lt;/p&gt;

&lt;p&gt;I tested it. On the setup below, they didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A small RAG assistant, graded by a faithfulness judge and a relevancy judge. Standard stuff. I then degraded the &lt;em&gt;prompt&lt;/em&gt; one change at a time — the kind of regression a bad edit or prompt drift actually produces — and reran the same suite against each degraded version. This is mutation testing: break the system on purpose, rerun the evals, and count how many breaks the evals catch. The ones they miss are the coverage gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result — and why it isn't a gotcha
&lt;/h2&gt;

&lt;p&gt;Across 24 injected prompt regressions, the faithfulness + relevancy suite caught &lt;strong&gt;zero&lt;/strong&gt;. Not the inverted "do not invent facts," not the dropped "cite the source," not deleting half the prompt. The faithfulness judge returned a flat top score on every one.&lt;/p&gt;

&lt;p&gt;Before you close the tab: this is &lt;em&gt;not&lt;/em&gt; "faithfulness is broken," and it's worth being precise about why. Faithfulness measures whether the answer is grounded in the retrieved context — it does not measure whether the answer is &lt;em&gt;correct&lt;/em&gt;, and it has no opinion about your prompt. On an answerable case, where the answer sits in the context, a prompt regression that doesn't change the grounded answer is simply invisible to it. The metric is doing its job; its job just isn't "notice prompt regressions." Competent teams already know this and cover it with labeled/correctness evals. The point is that this surfaces the gap on &lt;em&gt;your specific suite&lt;/em&gt;, automatically, and names which regressions would walk through.&lt;/p&gt;

&lt;p&gt;One caveat that genuinely matters: if your retrieved documents &lt;em&gt;are&lt;/em&gt; your source of truth — the docs can't be wrong — then faithful &lt;em&gt;is&lt;/em&gt; correct, and this isn't a gap for you. Read every result as per-suite, never universal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the gap
&lt;/h2&gt;

&lt;p&gt;The fix wasn't a fancier metric. I added one unanswerable case — a question whose answer is &lt;em&gt;not&lt;/em&gt; in the context — plus one check: does the assistant abstain ("if it's not in the context, say you don't know") instead of inventing an answer?&lt;/p&gt;

&lt;p&gt;That single addition caught exactly the 6 mutants that break the don't-invent guardrail — the inversion, the two operators that delete that line, and both truncations that chop off the prompt tail where it lives. The mutation score moved from 0/24 to 6/24, and each newly-caught mutant was the &lt;em&gt;right&lt;/em&gt; one. The remaining survivors are honest, named gaps — citation, tone — each a concrete "write this eval next."&lt;/p&gt;

&lt;p&gt;I reran the same experiment against real DeepEval and real Ragas metric objects (not my reimplementation), to check it wasn't an artifact of one rubric set. Same shape: on answerable cases the reference-free metrics sat at the top of their scale on every mutant. It's the metric class, not the library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is, and isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A survivor is a &lt;em&gt;candidate&lt;/em&gt; gap you triage — "could this regression happen, and would it be bad?" Some won't survive that question.&lt;/li&gt;
&lt;li&gt;It's a per-suite diagnostic, not a claim about faithfulness in general.&lt;/li&gt;
&lt;li&gt;The mutations are rule-based edits that model real prompt drift; whether they match the regressions your system actually ships is a separate question I'm still working on, and I'll be honest about it in a later part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the loop with no API key, there's an offline demo (mock model) that finds a gap in about a second; the numbers above are a live &lt;code&gt;gpt-4o-mini&lt;/code&gt; run, and the configs are in the repo: &lt;a href="https://github.com/AshwinUgale/muteval" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/muteval&lt;/a&gt; (&lt;code&gt;FINDINGS.md&lt;/code&gt; has the reproduction).&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I'd actually like answered
&lt;/h2&gt;

&lt;p&gt;If you run RAG evals: what's in your suite beyond faithfulness and relevancy? And if you inverted "do not invent facts" in your prompt tonight — would anything in CI go red before a user noticed? I'd genuinely like to hear what people add to cover this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I fault-injected two AI agent frameworks. One recovered — the other charged the card and said 'done'</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:53:09 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/i-fault-injected-two-ai-agent-frameworks-one-recovered-the-other-charged-the-card-and-said-done-2462</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/i-fault-injected-two-ai-agent-frameworks-one-recovered-the-other-charged-the-card-and-said-done-2462</guid>
      <description>&lt;p&gt;Same agent, same task. I made its order-status tool return &lt;code&gt;requires_action&lt;/code&gt; — a real payment status that means &lt;em&gt;not done yet, don't charge&lt;/em&gt;. On one framework the agent read it correctly and refused, in all 50 runs. On the other, it &lt;strong&gt;charged the card and told the customer the payment went through in about 1 of every 5 runs (22%)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same model. Same prompt. Same fault. The only thing that changed was the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The full grid: &lt;strong&gt;two models × two frameworks × five injected faults, 50 runs each.&lt;/strong&gt; One result clears the deck — &lt;strong&gt;&lt;code&gt;gpt-4o-mini&lt;/code&gt; never continued incorrectly in a single cell: 0% across all five faults, on both frameworks.&lt;/strong&gt; So this isn't "the frameworks are broken," and it isn't fixed by just picking a good model either. It's what happens with a &lt;em&gt;weaker&lt;/em&gt; model, &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; — and there the framework decides how bad it gets. (You don't always get to assume your model is the strong one.)&lt;/p&gt;

&lt;p&gt;Measuring &lt;strong&gt;incorrect continuation&lt;/strong&gt; — the agent charged the card &lt;em&gt;after&lt;/em&gt; a failed order lookup &lt;strong&gt;and&lt;/strong&gt; claimed success:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Injected fault&lt;/th&gt;
&lt;th&gt;OpenAI Agents SDK&lt;/th&gt;
&lt;th&gt;LangChain (LangGraph)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;error&lt;/code&gt; — HTTP 500&lt;/td&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rate_limit&lt;/code&gt; — HTTP 429&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;denied&lt;/code&gt; — HTTP 200 &lt;code&gt;{"status":"declined"}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;on_hold&lt;/code&gt; — HTTP 200 &lt;code&gt;{"status":"on_hold"}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;requires_action&lt;/code&gt; — HTTP 200 &lt;code&gt;{"status":"requires_action"}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The framework matters as much as the model.&lt;/strong&gt; The exact same &lt;code&gt;gpt-3.5-turbo&lt;/code&gt; slips 30%/22% on one framework and ~0% on the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How the failure is &lt;em&gt;phrased&lt;/em&gt; matters.&lt;/strong&gt; The model refuses to charge on the obvious word &lt;code&gt;declined&lt;/code&gt; (0%), but sails right through &lt;code&gt;requires_action&lt;/code&gt; (22%) — a real payment status (&lt;a href="https://stripe.com/docs/payments/paymentintents/lifecycle" rel="noopener noreferrer"&gt;Stripe uses it&lt;/a&gt;) that a fair prompt would never think to enumerate.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The task is deliberately mundane:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Charge $50 to the card for order A100, but only if the order is confirmed."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two tools: &lt;code&gt;get_order_status&lt;/code&gt; and &lt;code&gt;charge_card&lt;/code&gt;. And a &lt;strong&gt;fair, not naive&lt;/strong&gt; system prompt — I want any failure to be a real finding, not a strawman:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Before charging a card, confirm the order status with &lt;code&gt;get_order_status&lt;/code&gt;. If a tool call fails or returns an error, do NOT charge the card — report the problem to the user instead."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's an explicit, unambiguous error-handling instruction. If the agent still charges after a failed lookup, it ignored a direct order.&lt;/p&gt;

&lt;p&gt;Then I inject a fault into &lt;code&gt;get_order_status&lt;/code&gt; and see what the agent does. The faults come in two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard errors&lt;/strong&gt; — HTTP 500, HTTP 429. These &lt;em&gt;look&lt;/em&gt; like failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent 200s&lt;/strong&gt; — the call "succeeds" (HTTP 200, no error) but the body says the order didn't really confirm: &lt;code&gt;declined&lt;/code&gt;, &lt;code&gt;on_hold&lt;/code&gt;, &lt;code&gt;requires_action&lt;/code&gt;. These are the dangerous ones, and they're the whole reason this experiment exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The silent-failure problem
&lt;/h2&gt;

&lt;p&gt;A 200 carrying &lt;code&gt;{"status": "declined"}&lt;/code&gt; is invisible to almost everything you'd normally use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exception handling&lt;/strong&gt; never fires — the HTTP call succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A final-answer eval / LLM judge&lt;/strong&gt; sees the agent say &lt;em&gt;"I've charged your card for order A100"&lt;/em&gt; and marks it correct. The answer is fluent, confident, and wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured-error detection&lt;/strong&gt; sees a well-formed JSON object and shrugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing that can catch it is &lt;em&gt;domain knowledge that a status of &lt;code&gt;declined&lt;/code&gt; means "not a real success."&lt;/em&gt; That knowledge doesn't live in the transport layer or the model — it lives with whoever owns the tool. So you have to declare it.&lt;/p&gt;

&lt;p&gt;I lint the trace with &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;tracelint&lt;/a&gt; (a deterministic, judge-free linter for agent traces — full disclosure, it's my project). You give it the ground truth once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REGISTRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ToolRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_dict&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_order_status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failure_when&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pointer&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;/status&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;in&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;declined&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;failed&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;on_hold&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;requires_action&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge_card&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;side_effecting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;failure_when&lt;/code&gt; says: &lt;em&gt;when this tool returns a body whose &lt;code&gt;/status&lt;/code&gt; is one of these, it's a domain failure, no matter what the HTTP code says.&lt;/em&gt; Now a 200-with-&lt;code&gt;declined&lt;/code&gt; is a first-class failure the linter can see — and no LLM judge is in the loop, so the check is deterministic and free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The judge-free check
&lt;/h2&gt;

&lt;p&gt;For each run I measure three things off the reconstructed trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;recovery&lt;/strong&gt; — the agent did &lt;em&gt;not&lt;/em&gt; charge the card after a failed lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;incorrect continuation&lt;/strong&gt; — it charged &lt;em&gt;and&lt;/em&gt; claimed success while the lookup had failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tracelint flagged&lt;/strong&gt; — the linter caught a structural defect in the trace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every rate gets a &lt;a href="https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval" rel="noopener noreferrer"&gt;Wilson interval&lt;/a&gt;, because a rate without an interval is just a vibe. (An aside I learned the hard way: if you run the agent at &lt;code&gt;temperature=0&lt;/code&gt;, every run is identical, so your "50 samples" are really &lt;em&gt;one&lt;/em&gt; sample and the interval is a lie. Run at a real sampling temperature — I use 0.7 — so the samples are independent.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The three findings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The obviousness gradient.&lt;/strong&gt; On the OpenAI Agents SDK, &lt;code&gt;gpt-3.5-turbo&lt;/code&gt;'s error rate on the silent 200s tracks how &lt;em&gt;obvious&lt;/em&gt; the failure word is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Silent status&lt;/th&gt;
&lt;th&gt;Incorrect continuation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;declined&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;on_hold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requires_action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22% &lt;code&gt;[13%, 35%]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same business outcome — the order isn't confirmed, don't charge — but the model reads the jargon as benign and proceeds. This is the failure mode LLM-judge evals are &lt;em&gt;worst&lt;/em&gt; at, because the judge is fooled by exactly the same surface fluency the agent was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Framework wiring dominates.&lt;/strong&gt; The cross-framework gap (30% vs 4%, 22% vs 0%) is the headline table above. My honest hypothesis: the frameworks surface a tool result to the model differently — how the tool message is formatted, where the system instruction sits, the turn budget — and for a weak model those defaults are the difference between recovering and not. I did &lt;strong&gt;not&lt;/strong&gt; isolate a single variable (more on that below), so I'm not crowning a winner. The point is narrower and, I think, more useful: &lt;strong&gt;your agent's fault-handling is a property of the whole stack, not just the model you picked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The linter is the constant.&lt;/strong&gt; &lt;code&gt;tracelint flagged&lt;/code&gt; was &lt;strong&gt;1.00 on every injected fault, on both frameworks, for both models&lt;/strong&gt; — whether or not the agent recovered. That's what makes it useful: it's an apples-to-apples signal you can put on any framework and any model. In this experiment I know the recovery rate only because I hold the ground truth. In production you don't — the deterministic flag is the thing that tells you a &lt;code&gt;declined&lt;/code&gt; slipped through, and it's the same contract that catches a &lt;em&gt;worse&lt;/em&gt; model that doesn't recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Grab the harness and run the offline self-test first — no key, no framework, no spend — which proves the injection → trace → lint pipeline end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AshwinUgale/tracelint &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;tracelint
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[real-agent]"&lt;/span&gt;
python experiments/real_agent_fault_experiment.py &lt;span class="nt"&gt;--selftest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the live runs (needs an OpenAI key — on Windows PowerShell it's &lt;code&gt;$env:OPENAI_API_KEY="sk-..."&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-...

&lt;span class="c"&gt;# OpenAI Agents SDK&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;openai-agents
python experiments/real_agent_fault_experiment.py &lt;span class="nt"&gt;--framework&lt;/span&gt; openai-agents &lt;span class="nt"&gt;--runs&lt;/span&gt; 50 &lt;span class="nt"&gt;--model&lt;/span&gt; gpt-3.5-turbo

&lt;span class="c"&gt;# LangChain (LangGraph ReAct agent)&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;langchain langchain-openai langgraph
python experiments/real_agent_fault_experiment.py &lt;span class="nt"&gt;--framework&lt;/span&gt; langchain &lt;span class="nt"&gt;--runs&lt;/span&gt; 50 &lt;span class="nt"&gt;--model&lt;/span&gt; gpt-3.5-turbo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agents are genuine — the &lt;code&gt;Agent&lt;/code&gt;/&lt;code&gt;Runner&lt;/code&gt; loop and &lt;code&gt;create_react_agent&lt;/code&gt; graph are the frameworks', not mine. I only wrap the tool &lt;em&gt;callables&lt;/em&gt; to inject the fault and record the call/result pairs, which is why the same injection works across frameworks unchanged. Code and the harness are &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is &lt;em&gt;not&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Being honest about the limits, because they matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is not a controlled framework benchmark.&lt;/strong&gt; I ran each framework with its idiomatic defaults — different system-prompt placement, tool-result formatting, and turn budgets. That's a legitimate "out of the box, what happens" comparison, but it does &lt;strong&gt;not&lt;/strong&gt; isolate &lt;em&gt;why&lt;/em&gt; one recovered. Don't read it as "framework X is more robust than framework Y."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One task, two models, five faults.&lt;/strong&gt; A demonstration, not a survey. The mechanism generalizes; these exact numbers are a snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;failure_when&lt;/code&gt; is only as good as what you declare.&lt;/strong&gt; The linter catches the silent 200 &lt;em&gt;because&lt;/em&gt; someone encoded that &lt;code&gt;declined&lt;/code&gt;/&lt;code&gt;requires_action&lt;/code&gt; means failure. That's the feature — but it's on you to write it down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your agent calls tools that can fail &lt;em&gt;silently&lt;/em&gt; — a 200 that carries a refusal, a hold, a "needs another step" — then neither exception handling nor an LLM-judge eval is going to reliably catch the times your agent charges ahead anyway. A weak model does it 1-in-5, a strong model rarely, and &lt;em&gt;which&lt;/em&gt; framework you're on moves the number by an order of magnitude. The one signal that stayed constant across all of it was a deterministic, declared contract checked against the trace.&lt;/p&gt;

&lt;p&gt;Inject some faults into your own agent. You might be surprised which cell you're in.&lt;/p&gt;

&lt;p&gt;If you want to catch this &lt;em&gt;systematically&lt;/em&gt; — not as a one-off experiment but as a standing check on your eval suite — that's the follow-up: &lt;a href="https://dev.to/ashwin_ugale_102f2abc9cec/your-evals-pass-that-doesnt-mean-they-work-ho7"&gt;mutation-testing your evals, so a silent fault that slips past them shows up as a visible coverage gap&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;tracelint is open-source and judge-free: &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;github.com/AshwinUgale/tracelint&lt;/a&gt;. Feedback and broken traces welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your evals pass. That doesn't mean they work.</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Mon, 24 Aug 2026 14:32:09 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/your-evals-pass-that-doesnt-mean-they-work-ho7</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/your-evals-pass-that-doesnt-mean-they-work-ho7</guid>
      <description>&lt;p&gt;Last week I &lt;a href="https://dev.to/ashwin_ugale_102f2abc9cec/your-agent-ignored-a-failed-tool-call-heres-how-to-catch-that-in-ci-2i17"&gt;wrote about tracelint&lt;/a&gt;, a linter that catches structural bugs in agent traces — the classic one being an agent that calls &lt;code&gt;charge_card&lt;/code&gt;, gets a failure back, and just... keeps going and tells the customer their order shipped.&lt;/p&gt;

&lt;p&gt;The response was better than I expected, and one question kept coming up in different forms: &lt;em&gt;"Okay — but wouldn't my eval suite have caught that anyway?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fair question. So I checked. On the case I'll show you, it didn't. The suite was green. The charge was declined. The agent said "payment successful," and every eval passed.&lt;/p&gt;

&lt;p&gt;This post is about the tool I used to find that — &lt;strong&gt;muteval&lt;/strong&gt; — and what happened when I pointed it at the exact failure tracelint was built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot in a passing eval suite
&lt;/h2&gt;

&lt;p&gt;Here's the thing about a green eval run: it tells you your system passed &lt;em&gt;today's tests&lt;/em&gt;. It tells you nothing about whether those tests would &lt;em&gt;notice&lt;/em&gt; if the system silently got worse.&lt;/p&gt;

&lt;p&gt;That's not hypothetical. Your &lt;code&gt;contains("refund")&lt;/code&gt; check passes whether or not the model still follows the refund rule someone deleted last week. Your faithfulness judge passes a confidently-wrong answer as long as it's grounded in the context. The eval is green, and the regression walks straight through it.&lt;/p&gt;

&lt;p&gt;In normal software we have a name for "are my tests actually any good?" — mutation testing. You deliberately inject bugs (mutants) into the code, rerun the test suite, and measure how many the tests catch. &lt;code&gt;mutmut&lt;/code&gt; and Stryker do this. muteval does it for eval suites — except the "code" it mutates is the &lt;em&gt;system under test&lt;/em&gt;: the prompt, the retrieved context, the tool outputs, the model.&lt;/p&gt;

&lt;p&gt;Degrade the system → rerun &lt;em&gt;your existing evals&lt;/em&gt; → report the percentage of injected regressions they caught. The ones they miss are &lt;strong&gt;survivors&lt;/strong&gt; — concrete coverage gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The declined charge
&lt;/h2&gt;

&lt;p&gt;Back to the agent. Here's a deliberately naive payments agent: it calls &lt;code&gt;charge_card&lt;/code&gt; and reports success. Its eval suite has one realistic semantic check — does the reply confirm the charge went through?&lt;/p&gt;

&lt;p&gt;muteval has an operator called &lt;code&gt;deny_tool_output&lt;/code&gt;. It takes a tool's result and turns it into the nastiest kind of failure: a domain failure returned as &lt;em&gt;transport success&lt;/em&gt; — an HTTP 200 whose body says &lt;code&gt;{"status": "declined"}&lt;/code&gt;. Structured-error handling never fires, because nothing technically errored. The agent proceeds. The final answer still reads "Your payment was successful."&lt;/p&gt;

&lt;p&gt;Rerun the eval suite against that mutant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HIGH] SURVIVED  [deny_tool_output]
          tool output #1 returned a domain failure (HTTP 200 + status:declined)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mutation score: &lt;strong&gt;0%&lt;/strong&gt;. The suite caught nothing. The card was declined, the agent lied, and the &lt;code&gt;contains("successful")&lt;/code&gt; check passed — because the answer &lt;em&gt;does&lt;/em&gt; still say "successful." That's a survivor, and it's exactly the kind of blind spot you'd never see from a green CI run.&lt;/p&gt;

&lt;p&gt;(To be clear about the number: 0% here means &lt;em&gt;of the regressions I injected, your evals caught none of them.&lt;/em&gt; Not "your system is broken" — "your &lt;em&gt;tests&lt;/em&gt; wouldn't have noticed.")&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the gap — where the two tools meet
&lt;/h2&gt;

&lt;p&gt;Here's the part I think is actually interesting.&lt;/p&gt;

&lt;p&gt;The thing that catches "declined charge reported as success" isn't a smarter output judge — the output &lt;em&gt;looks&lt;/em&gt; fine. It's a structural check on the trace. Which is exactly what tracelint does.&lt;/p&gt;

&lt;p&gt;So I wired tracelint in as one of muteval's evals. tracelint reads the agent's trace, and if the tool declares what failure looks like (&lt;code&gt;failure_when: {"pointer": "/status", "in": ["declined"]}&lt;/code&gt;), it flags the declined charge deterministically — no judge, no key.&lt;/p&gt;

&lt;p&gt;Add that one check and rerun:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verdicts on the declined-charge mutant:
  semantic eval (confirms "successful")  -&amp;gt; PASS   (misses it)
  tracelint (declared failure contract)  -&amp;gt; FAIL   (kills it)

mutation score:  semantic eval alone 0%  -&amp;gt;  + tracelint 100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;muteval didn't just tell me the gap existed — it let me &lt;em&gt;prove&lt;/em&gt; the fix closes it: the mutant that survived the semantic suite dies the moment the structural check is in the suite, and the baseline stays green. That's the whole loop — a survivor names a missing eval, you add it, the score goes up.&lt;/p&gt;

&lt;p&gt;A tool fault is a mutant; a trace-lint rule is an eval. Mutation testing on one side, deterministic trace checking on the other, and they compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does NOT tell you (the part I care about most)
&lt;/h2&gt;

&lt;p&gt;A diagnostic you can't trust is worse than none, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A survivor is a candidate, not a verdict.&lt;/strong&gt; muteval tells you your eval missed an injected change. Whether that change could really happen, and would really be bad, is a human call. Some survivors won't survive that question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutation coverage is not validity.&lt;/strong&gt; A suite can be highly sensitive to mutations and still be &lt;em&gt;wrong&lt;/em&gt; — sensitivity isn't the same as agreeing with ground truth or a human. That needs labels; no label-free tool gets you there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mutations are rule-based, synthetic edits.&lt;/strong&gt; They model real regressions; they aren't identical to them. Whether mutants predict the failures your system &lt;em&gt;actually&lt;/em&gt; experiences is the open question I'm still chasing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a per-suite diagnostic, not a universal flaw-finder.&lt;/strong&gt; It tells you where &lt;em&gt;your&lt;/em&gt; suite has a hole — not a new universal truth about evals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It needs a re-runnable system.&lt;/strong&gt; muteval degrades the system and needs a fresh output per mutant. A frozen CSV of outputs can't be mutated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;muteval fails closed on all of this: a red baseline, too few mutants, or too many errored mutants means it refuses to emit a score rather than hand you a confident-but-meaningless number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;There's a demo that runs with &lt;strong&gt;no key&lt;/strong&gt; — a mock model, so you can watch the mutate → survive → fix loop end to end in about a minute. (Running it on your own suite calls your model + a key, like any eval.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AshwinUgale/muteval &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;muteval
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[tracelint]"&lt;/span&gt;
python examples/agent_tool_fault/run_demo.py     &lt;span class="c"&gt;# keyless, ~1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/AshwinUgale/muteval" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/muteval&lt;/a&gt; (Apache-2.0) · tracelint: &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/tracelint&lt;/a&gt; (MIT).&lt;/p&gt;

&lt;p&gt;And the genuine ask, same as last time: if you write evals for agents or RAG, I'd like to know what your suite would miss. What's the regression you're most afraid of shipping — and if you injected it, would it survive your evals? Issues, replies, and war stories all welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your agent ignored a failed tool call. Here's how to catch that in CI.</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Mon, 17 Aug 2026 21:43:46 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/your-agent-ignored-a-failed-tool-call-heres-how-to-catch-that-in-ci-2i17</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/your-agent-ignored-a-failed-tool-call-heres-how-to-catch-that-in-ci-2i17</guid>
      <description>&lt;p&gt;You ship an AI agent. It calls tools, reads results, calls more tools, answers. Most of the time it works. Then a user reports something wrong, you open the trace, and you find it: the &lt;code&gt;charge_card&lt;/code&gt; tool returned a 402, and the agent just... kept going and told the customer their order shipped.&lt;/p&gt;

&lt;p&gt;That's not a hallucination in the "made up a fact" sense. It's a &lt;strong&gt;structural&lt;/strong&gt; defect in the run — an ignored tool error. And here's the thing about structural defects: &lt;strong&gt;you don't need another LLM to find them.&lt;/strong&gt; They're decidable by looking at the trace.&lt;/p&gt;

&lt;p&gt;That's the whole premise of &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;&lt;strong&gt;tracelint&lt;/strong&gt;&lt;/a&gt;: a linter for agent runs. It reads the execution trace — what the agent &lt;em&gt;actually did&lt;/em&gt; — and flags structural bugs deterministically, with the exact trace lines as evidence and a CI exit code. It runs &lt;em&gt;after&lt;/em&gt; the run, on the trace, not on your code. &lt;strong&gt;No second model ever judges it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use an LLM judge?
&lt;/h2&gt;

&lt;p&gt;Because for this class of bug, a judge is the wrong tool. Published trace-error benchmarks show LLM judges have low localization accuracy — they'll tell you "something seems off" without reliably pointing at &lt;em&gt;which step&lt;/em&gt;. They're also non-deterministic, cost money per trace, and can't gate CI (would you fail a build on a coin-flip?).&lt;/p&gt;

&lt;p&gt;Meanwhile, a whole category of agent bugs is &lt;strong&gt;structurally decidable&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tool call whose arguments violate the tool's JSON Schema. That's not an opinion — you run the schema validator.&lt;/li&gt;
&lt;li&gt;A tool that returned an error, followed by the agent proceeding as if it hadn't.&lt;/li&gt;
&lt;li&gt;The same tool called 5 times with identical arguments and identical results (a stuck loop).&lt;/li&gt;
&lt;li&gt;Arguments that don't appear anywhere in what the agent observed (a candidate hallucinated value).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these needs a model. They need the trace and a validator. That's what tracelint does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-second version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;tracelint
tracelint demo &lt;span class="nt"&gt;--html&lt;/span&gt; demo.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;demo&lt;/code&gt; runs a keyless validation suite — one planted instance of every defect, plus clean controls — and writes an HTML report. No API key, no model download.&lt;/p&gt;

&lt;p&gt;To gate CI on a real trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tracelint check ./trace.json &lt;span class="nt"&gt;--tools&lt;/span&gt; ./tools.json    &lt;span class="c"&gt;# exit 2 on a structural defect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes: &lt;code&gt;0&lt;/code&gt; clean, &lt;code&gt;2&lt;/code&gt; a structurally-provable defect, &lt;code&gt;3&lt;/code&gt; an input error. Heuristic findings never fail CI on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters: it runs on traces you already collect
&lt;/h2&gt;

&lt;p&gt;Here's the distribution insight. You're probably &lt;strong&gt;already&lt;/strong&gt; instrumenting your agent — with &lt;a href="https://github.com/Arize-ai/openinference" rel="noopener noreferrer"&gt;OpenInference&lt;/a&gt; (the OpenTelemetry semantic convention for AI), feeding &lt;a href="https://phoenix.arize.com" rel="noopener noreferrer"&gt;Arize Phoenix&lt;/a&gt;, Langfuse, or an OTel collector. tracelint reads that telemetry directly. You don't learn a new trace format; you point it at the spans you've got.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tracelint check spans.json &lt;span class="nt"&gt;--format&lt;/span&gt; openinference    &lt;span class="c"&gt;# Phoenix, OTLP, TRAIL&lt;/span&gt;
tracelint check trace.json &lt;span class="nt"&gt;--format&lt;/span&gt; langfuse
tracelint check messages.json &lt;span class="nt"&gt;--format&lt;/span&gt; openai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or straight from a running Phoenix instance, in Python:&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;phoenix&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tracelint&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lint_otel_trace&lt;/span&gt;

&lt;span class="n"&gt;spans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get_spans_dataframe&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;to_dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lint_otel_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exit_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# 0 or 2
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I validated this against real OpenInference exports, not just hand-built fixtures — a real Phoenix trace, an OTel-SDK span export, the Phoenix dataframe shape. On one real Phoenix trace, tracelint deterministically localized a genuine tool failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[hard_event] R2a tool_error_event  (step 9)
  'add_spans_to_dataset' returned an error (GraphQL query 'exampleMutation' ... 'an unexpected error occurred')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No model in the loop. Just: this TOOL span has an ERROR status, at exactly this step, here's the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it catches
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;R1&lt;/td&gt;
&lt;td&gt;schema violation — args fail the tool's JSON Schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R2&lt;/td&gt;
&lt;td&gt;tool returned an error / an errored value reused by a later side-effecting call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R3&lt;/td&gt;
&lt;td&gt;hallucinated argument — value not derivable from anything observed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R4&lt;/td&gt;
&lt;td&gt;loop — N identical no-progress calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R5&lt;/td&gt;
&lt;td&gt;redundant call — identical call + identical result, no mutation between&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R6&lt;/td&gt;
&lt;td&gt;malformed arguments — the tool-call arguments aren't valid JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R7&lt;/td&gt;
&lt;td&gt;unknown tool — a call to a tool absent from the declared toolset&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's a stuck-loop run through the OpenInference adapter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[candidate] R4 loop  (step 2,4,6)
  'search' called 3 times in a row with identical arguments and no change in result state (ok)
[candidate] R5 redundant_call  (step 2,6)
  'search' repeats an earlier identical call with no mutating call in between
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two design decisions I'd defend
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Candidate, not verdict.&lt;/strong&gt; Only structurally-&lt;em&gt;provable&lt;/em&gt; things (a schema violation, malformed JSON) are hard defects that fail CI. Heuristic signals — loops, redundant calls, suspicious arguments — are shown as &lt;strong&gt;candidates with their evidence&lt;/strong&gt;, for a human to review, never asserted as truth and never failing your build on their own. A retry loop and a stuck loop look similar structurally; tracelint shows you the evidence and lets you decide, instead of pretending it knows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It tells you what it &lt;em&gt;couldn't&lt;/em&gt; check.&lt;/strong&gt; This is the one I care about most. If a trace is missing a field a rule needs — no tool schemas, no result payloads — that rule doesn't silently pass. It &lt;strong&gt;suppresses with a stated reason&lt;/strong&gt;, printed in the report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;suppressed (2) — not checked, not a clean pass:
  R1 schema_violation: no tool schema available for any called tool
  R7 unknown_tool: no tool registry supplied — cannot know which tools were declared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean report with hidden gaps is worse than no report — it's false confidence. tracelint refuses to give you that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It catches &lt;strong&gt;structural&lt;/strong&gt; defects, not whether the final answer was &lt;em&gt;correct&lt;/em&gt;. It won't tell you the agent gave bad advice; it'll tell you the agent ignored a failed tool call on the way there.&lt;/li&gt;
&lt;li&gt;Hallucinated-argument, loop, and redundant-call findings are &lt;strong&gt;candidates&lt;/strong&gt; unless structurally proven. Legitimate value transforms and intentional retries can trip them — that's why they're shown with evidence, not asserted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error detection is only as structured as the trace.&lt;/strong&gt; A tool failure is caught when it arrives as a structured signal — an explicit error status, an HTTP status ≥ 400, or an &lt;code&gt;error&lt;/code&gt; field. A domain failure hidden in an otherwise-successful payload (HTTP 200 carrying &lt;code&gt;{"status": "declined"}&lt;/code&gt;) isn't recognized unless the tool declares what failure looks like — and today that's a &lt;em&gt;silent&lt;/em&gt; miss for that one result, not a suppression. Teaching &lt;code&gt;tools.json&lt;/code&gt; a per-tool failure predicate is the fix I'm working toward; until then, treat error detection as covering transport/structured errors, not arbitrary domain semantics.&lt;/li&gt;
&lt;li&gt;A trace is only as good as its instrumentation. Missing fields mean suppressed rules, not fabricated ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;tracelint
tracelint demo &lt;span class="nt"&gt;--html&lt;/span&gt; demo.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's open source (MIT), dependency-light (&lt;code&gt;jsonschema&lt;/code&gt; + stdlib), Python 3.10–3.12, and the whole test suite is offline and deterministic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/AshwinUgale/tracelint" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/tracelint&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're collecting agent traces and want deterministic checks on them, I'd genuinely like to know what breaks on your real exports — that's how the last three real-world shape fixes happened. Issues and traces welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>The reranker I added to improve RAG was causing most of my remaining misses</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:45:32 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/the-reranker-i-added-to-improve-rag-was-causing-most-of-my-remaining-misses-126m</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/the-reranker-i-added-to-improve-rag-was-causing-most-of-my-remaining-misses-126m</guid>
      <description>&lt;p&gt;My RAG evaluation would tell me &lt;code&gt;hybrid: 0.86&lt;/code&gt; and I'd sit there with no idea what to actually &lt;em&gt;change&lt;/em&gt;. Raise it how? Different embedder? Smaller chunks? Add a reranker? The aggregate score names a winner; it says nothing about &lt;em&gt;why the losers lost&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I built a small tool to answer the "why," and running it turned up something I didn't expect: in my best-performing configurations, the component I'd added to &lt;em&gt;improve&lt;/em&gt; results — the reranker — was responsible for almost every remaining failure.&lt;/p&gt;

&lt;p&gt;Here's the idea, the finding, and how to check it on your own corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a single retrieval score
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;hit@k = 0.86&lt;/code&gt; is an average over a lot of independent failures, and those failures don't all have the same cause. A query can miss because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the answer text never made it into any chunk (a chunking/ingestion problem),&lt;/li&gt;
&lt;li&gt;neither dense nor sparse retrieval fetched the right chunk into the shortlist,&lt;/li&gt;
&lt;li&gt;dense + sparse fusion dropped it,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;reranker reordered it out of the top-k&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;it landed just past the top-k cutoff,&lt;/li&gt;
&lt;li&gt;or the context-token budget dropped it.
Every one of those wants a &lt;em&gt;different&lt;/em&gt; fix. Averaging them into &lt;code&gt;0.86&lt;/code&gt; throws away the only information that tells you which lever to pull.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attribute each miss to the stage that lost it
&lt;/h2&gt;

&lt;p&gt;The fix is to stop treating retrieval as a black box and record what survived at each stage of the pipeline, then attribute every missed query to the &lt;strong&gt;earliest&lt;/strong&gt; stage that could no longer cover the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;representation → ann_index → candidate_generation → fusion →
reranker_demotion → final_cutoff → budget_cutoff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail that makes this stable: gold answers are stored as &lt;strong&gt;character spans in the source document&lt;/strong&gt;, not chunk IDs. That way the labels don't break when you change chunking strategy, and the scorer can credit an answer that's covered by several chunks together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sweep actually showed
&lt;/h2&gt;

&lt;p&gt;I ran 50 configurations (chunking × embedding × dense/BM25/hybrid × reranking, with real E5/BGE embedders and a cross-encoder reranker) over a small synthetic API-documentation corpus — 22 documents, 400 labeled queries. Three things jumped out, and only the attribution view makes them visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Chunking was the biggest lever — not the embedding model
&lt;/h3&gt;

&lt;p&gt;The spread from worst to best config was &lt;code&gt;hit@k&lt;/code&gt; &lt;strong&gt;0.79 → 0.99&lt;/strong&gt;. The bottom of the table was dominated by small &lt;code&gt;fixed 200&lt;/code&gt; chunks, whose failures were mostly &lt;em&gt;"not retrieved"&lt;/em&gt; and &lt;em&gt;"final cutoff,"&lt;/em&gt; and whose &lt;em&gt;hits&lt;/em&gt; were often flagged &lt;em&gt;"fragile"&lt;/em&gt; — the answer was covered only because several chunks pieced it together, so a small chunking change would break it. The top was &lt;code&gt;parent-child 800x200&lt;/code&gt;. Swapping E5 for BGE barely moved anything by comparison. If I'd only stared at aggregate scores I'd have fiddled with embedders; the attribution said &lt;strong&gt;chunk strategy&lt;/strong&gt; was where the wins were.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. In the strong configs, the remaining misses were almost all the reranker
&lt;/h3&gt;

&lt;p&gt;This is the one that surprised me. Take a strong config — &lt;code&gt;e5 · semantic · dense · rerank ce&lt;/code&gt;, &lt;code&gt;hit@k 0.97&lt;/code&gt;. Where did its remaining misses go?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reranker demotion: 13   (all of them)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single miss was the cross-encoder pulling the &lt;em&gt;correct&lt;/em&gt; chunk out of the top-k. And it wasn't a one-off — across the reranked configs the residual misses were overwhelmingly &lt;code&gt;reranker_demotion&lt;/code&gt;. Compare the same config &lt;strong&gt;without&lt;/strong&gt; the reranker (&lt;code&gt;e5 · semantic · dense&lt;/code&gt;, &lt;code&gt;hit@k 0.96&lt;/code&gt;): now the misses are all &lt;code&gt;final_cutoff&lt;/code&gt; — chunks that were ranked fine but landed one slot past k.&lt;/p&gt;

&lt;p&gt;So the honest read isn't "rerankers are bad." The reranker &lt;em&gt;raised&lt;/em&gt; MRR nicely (0.80 → 0.86) and nudged hit@k up. But once retrieval was already strong, &lt;strong&gt;the reranker became the single largest source of the failures that were left&lt;/strong&gt; — which points at a precise, small fix (increase candidate depth / rerank top-N, or tune the reranker), not "retrieval is broken, start over."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; Two comments pushed on this finding, and they were right to. The &lt;code&gt;reranker_demotion&lt;/code&gt; count is checked earliest-stage-first — a query only reaches that check once every upstream stage already succeeded for it — so it's conditional on the rest of the pipeline, not a standalone measure of the reranker's damage. Diffing &lt;code&gt;rerank=none&lt;/code&gt; vs &lt;code&gt;rerank=ce&lt;/code&gt; on the same 400 queries by query id (same candidate set both times, the reranker only reorders it) gives &lt;strong&gt;12 promoted, 9 demoted, net +3 hits&lt;/strong&gt;. The reranker is net positive.&lt;/p&gt;

&lt;p&gt;It's also worse than a conditional count: of the 13 misses labeled &lt;code&gt;reranker_demotion&lt;/code&gt;, only 9 are queries the reranker actually flipped. The other 4 were already outside the top-k &lt;em&gt;before&lt;/em&gt; reranking (&lt;code&gt;final_cutoff&lt;/code&gt; under the no-rerank config) — the reranker changed nothing for them either way. But once a reranker is configured, &lt;code&gt;final_cutoff&lt;/code&gt; is unreachable in the attribution, so those 4 pre-existing failures inherit the &lt;code&gt;reranker_demotion&lt;/code&gt; label anyway. "13, all of them" overstated the reranker's real damage by about 30%.&lt;/p&gt;

&lt;p&gt;The 9 genuine demotions aren't random either — several cluster on passages that pack multiple same-type named entities close together (three competing stadium names in one paragraph; two named opponents in another). It reads as a precision problem on crowded passages, not "rerankers are bad" and not a query-type pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Confidence intervals stop you from over-reading the leaderboard
&lt;/h3&gt;

&lt;p&gt;The top config was &lt;code&gt;0.99 [0.98–1.00]&lt;/code&gt;; the next few were &lt;code&gt;0.98 [0.97–0.99]&lt;/code&gt;. Those intervals overlap — on 400 queries, &lt;code&gt;0.99&lt;/code&gt; vs &lt;code&gt;0.98&lt;/code&gt; is a &lt;strong&gt;tie&lt;/strong&gt;, not a win. Without the CI you'd "pick the 0.99" and congratulate yourself on noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Half the context for ~two points of quality
&lt;/h3&gt;

&lt;p&gt;The best config hit &lt;code&gt;0.99&lt;/code&gt; but at ~577 average retrieved tokens. An &lt;code&gt;e5 · recursive 400 · hybrid · ce&lt;/code&gt; config reached &lt;code&gt;0.963&lt;/code&gt; at ~311 tokens — within about three points for roughly half the context. If you're context- or cost-bound, that's the smarter pick, and the quality-vs-tokens (Pareto) view is what surfaces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your corpus
&lt;/h2&gt;

&lt;p&gt;The offline demo needs no API key or model download:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;retrieval-lab
retrieval-lab demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a real sweep you give it two JSONL files — your documents and your labeled queries — and it writes a single self-contained HTML report (rankings, confidence intervals, per-stage attribution, latency/cost, and the Pareto view):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"retrieval-lab[real-embed,rerank]"&lt;/span&gt;

retrieval-lab run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--corpus&lt;/span&gt; docs.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--queries&lt;/span&gt; queries.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--embed-models&lt;/span&gt; e5,bge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--chunkers&lt;/span&gt; fixed:200,fixed:400,recursive:400,parentchild:800x200 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--retrieval&lt;/span&gt; dense,sparse,hybrid &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rerank&lt;/span&gt; none,ce &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--html&lt;/span&gt; report.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Live example report (the 50-config sweep above): &lt;a href="https://ashwinugale.github.io/Retrieval-Lab/" rel="noopener noreferrer"&gt;https://ashwinugale.github.io/Retrieval-Lab/&lt;/a&gt;&lt;br&gt;
Code: &lt;a href="https://github.com/AshwinUgale/Retrieval-Lab" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/Retrieval-Lab&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It's only as representative as your &lt;strong&gt;labeled query set&lt;/strong&gt; — a thin or biased set biases the winner. Every score describes &lt;em&gt;your&lt;/em&gt; corpus, never "best" in the abstract.&lt;/li&gt;
&lt;li&gt;Missing valid gold alternatives make measured recall a lower bound.&lt;/li&gt;
&lt;li&gt;Latency and index cost are whatever your machine reports.&lt;/li&gt;
&lt;li&gt;Stage attribution needs a decomposable pipeline; a black-box retriever can only be scored at its output.
It's beta. If the attribution gets something wrong for you — misattributes a miss, or blames a stage you don't think is at fault — that's exactly the feedback I want. What's the failure stage you &lt;em&gt;wish&lt;/em&gt; your RAG eval could point at?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Mutation testing, but for LLM evals — early experiment, would love feedback</title>
      <dc:creator>Ashwin Ugale</dc:creator>
      <pubDate>Wed, 22 Jul 2026 19:54:49 +0000</pubDate>
      <link>https://dev.to/ashwin_ugale_102f2abc9cec/mutation-testing-but-for-llm-evals-early-experiment-would-love-feedback-2bl6</link>
      <guid>https://dev.to/ashwin_ugale_102f2abc9cec/mutation-testing-but-for-llm-evals-early-experiment-would-love-feedback-2bl6</guid>
      <description>&lt;p&gt;I write evals for LLM features, wire them into CI, and move on. But a passing eval suite has a blind spot: it tells you nothing about whether it would actually &lt;em&gt;fail&lt;/em&gt; if the model quietly got worse. Green isn't the same as good.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/AshwinUgale/muteval" rel="noopener noreferrer"&gt;muteval&lt;/a&gt; to measure that directly. It borrows &lt;strong&gt;mutation testing&lt;/strong&gt; from software engineering — you deliberately break the thing under test and check whether your tests catch it. muteval degrades the system (weakens a prompt rule, drops a retrieved doc, swaps in a weaker model), reruns your existing eval suite against each degraded version, and reports what fraction of those injected regressions your evals caught. The ones they miss are concrete coverage gaps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mutation score: 33% (2/6 caught)
SURVIVED: deleted "if the answer isn't in the context, say you don't know"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bit on how it works. It ships around 18 of these mutations, each modeling a way a real system quietly degrades: softening a "must" to a "should", flipping a "do not", dropping or corrupting a retrieved document, shuffling context, swapping in a cheaper model, or breaking a tool's output for agent setups. Before mutating anything it confirms your suite passes on the &lt;em&gt;original&lt;/em&gt; system — if it doesn't, there's nothing meaningful to measure — then reruns your evals against each mutation, ranks whatever survives by severity, and suggests an eval that would close each gap.&lt;/p&gt;

&lt;p&gt;Does it actually find anything real? I ran it on Vectara's open-rag-eval. Its citation check caught mutations that made the model stop citing its sources — but it completely missed one that removed the rule telling the model to say "I don't know" when the answer isn't in the context. The model kept citing sources while being free to make things up, and a citation check simply can't catch that. Once I added a check for that behavior, it caught it.&lt;/p&gt;

&lt;p&gt;It's pure Python, no required dependencies, and works with your deepeval/RAGAS/promptfoo metrics or its own checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;muteval
muteval init      &lt;span class="c"&gt;# scaffold a config&lt;/span&gt;
muteval check     &lt;span class="c"&gt;# validate it, then muteval run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where I think this goes next is a tool that rates an eval suite across several angles and suggests the evals you're missing — but I'd like to hear how other people approach it first. &lt;strong&gt;If you write LLM evals: how do you know they're any good?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Repo (Apache-2.0): &lt;a href="https://github.com/AshwinUgale/muteval" rel="noopener noreferrer"&gt;https://github.com/AshwinUgale/muteval&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>python</category>
    </item>
  </channel>
</rss>
