<?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: Erik Hill</title>
    <description>The latest articles on DEV Community by Erik Hill (@agentdev9).</description>
    <link>https://dev.to/agentdev9</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%2F4029397%2F4f06ed5b-bef3-4e78-b122-092528b3df4f.png</url>
      <title>DEV Community: Erik Hill</title>
      <link>https://dev.to/agentdev9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agentdev9"/>
    <language>en</language>
    <item>
      <title>My AI gate tests were green theater. The fix was to stub the wire — and nothing above it.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Tue, 04 Aug 2026 18:38:30 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-ai-gate-tests-were-green-theater-the-fix-was-to-stub-the-wire-and-nothing-above-it-338m</link>
      <guid>https://dev.to/agentdev9/my-ai-gate-tests-were-green-theater-the-fix-was-to-stub-the-wire-and-nothing-above-it-338m</guid>
      <description>&lt;p&gt;In a private multi-agent project, agent proposals go through a human approval gate. The Playwright tests for that gate were all green, and had been for a while.&lt;/p&gt;

&lt;p&gt;They were green because they never once ran the thing they claimed to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Green theater
&lt;/h2&gt;

&lt;p&gt;The gate tests staged model output straight into application state: write a pending proposal card, click approve, assert the card flips to approved. Clean, fast, deterministic — and a lie by construction. Those tests proved the &lt;em&gt;consumer&lt;/em&gt; of a pending proposal works. They never exercised the path that &lt;em&gt;produces&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;So when a refactor changed the producer to write proposals pre-approved — quietly dropping the approval guard on the way — the whole suite stayed green. The staged state still looked exactly like the state the tests expected, because the tests were the ones staging it.&lt;/p&gt;

&lt;p&gt;That's the failure mode I now call green theater: a suite that manufactures the evidence it then inspects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stub the bytes, keep the SDK real
&lt;/h2&gt;

&lt;p&gt;The honest fix was to move the fake down to the lowest layer that can hold it: the wire. That's what I extracted into &lt;a href="https://github.com/egnaro9/llm-wire-stub" rel="noopener noreferrer"&gt;llm-wire-stub&lt;/a&gt; — a scripted Anthropic Messages API at Playwright's network boundary.&lt;/p&gt;

&lt;p&gt;The app under test runs unmodified: a real &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; client, a real &lt;code&gt;MessageStream&lt;/code&gt;, a real SSE decode, a real tool loop. The stub intercepts &lt;code&gt;api.anthropic.com&lt;/code&gt; with &lt;code&gt;context.route&lt;/code&gt; and answers with the documented streaming envelope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message_start → (content_block_start → …delta… → content_block_stop)* → message_delta → message_stop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One property matters more than any other here: &lt;strong&gt;a wrong shape must fail loudly.&lt;/strong&gt; A stub that emits a sloppy envelope which the app half-tolerates is just green theater one layer down. The SDK's own accumulator is the enforcer — it throws on the out-of-order stream if &lt;code&gt;message_start&lt;/code&gt; is missing (the current SDK's message reads &lt;code&gt;"Unexpected event order"&lt;/code&gt;), and &lt;code&gt;finalMessage()&lt;/code&gt; rejects if &lt;code&gt;message_stop&lt;/code&gt; never arrives. That claim is demonstrated, not asserted: &lt;code&gt;tests/envelope.test.ts&lt;/code&gt; feeds the stub's bytes through the real SDK and shows exact reconstruction, then feeds it deliberately broken streams and shows the SDK throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request bodies are evidence
&lt;/h2&gt;

&lt;p&gt;The part of this that changed how I test: the stub records what the app &lt;strong&gt;sent&lt;/strong&gt;, not just what it was shown. Every intercepted call becomes a &lt;code&gt;RecordedRequest&lt;/code&gt; — model, system prompt, messages (including &lt;code&gt;tool_result&lt;/code&gt; blocks), tool names, api key.&lt;/p&gt;

&lt;p&gt;The response side of a test says "the app can render what it was given." The request side says "the app asked the right question." Two bugs from the private suite's history made me care — both invisible to any response-side assertion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The price lie.&lt;/strong&gt; A UI chip displayed a cost figure that disagreed with what was actually going over the wire. Every response-side test passed, because the responses were fine; the lie was in the outbound traffic nobody was reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The context leak.&lt;/strong&gt; One agent's output was supposed to reach the next agent's prompt, and silently didn't. A node that never saw upstream output is provable in one assertion — a missing message in &lt;code&gt;requests[n].messages&lt;/code&gt; — and in no other way I know of that doesn't involve staring at logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two anecdotes are private history; you can't reproduce them from the public repo. What you &lt;em&gt;can&lt;/em&gt; check is the mechanism: the e2e spec &lt;code&gt;a tool turn makes the SDK loop take a second request&lt;/code&gt; asserts on the second request body and shows the &lt;code&gt;tool_result&lt;/code&gt; the app produced riding back up the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixture expressiveness IS coverage
&lt;/h2&gt;

&lt;p&gt;This one cost a release, so it gets its own section.&lt;/p&gt;

&lt;p&gt;The private suite had a test asserting that the number of model requests matched the price quoted to the user — call it "requests == quoted." Correct assertion, sound idea. It passed for a full release cycle while the metering was wrong.&lt;/p&gt;

&lt;p&gt;Why? The stub at the time could only produce plain text turns. It could not script a &lt;code&gt;tool_use&lt;/code&gt; block, so the SDK's tool loop never fired, so no test run ever took a second request. Both sides of "requests == quoted" were trivially 1. The assertion was right and the fixture made it vacuous.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;your assertions can only be as strong as what your fixture can express.&lt;/strong&gt; A fixture that cannot produce a second turn silently converts every multi-turn assertion into a tautology — no failure, no warning, nothing to review.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ScriptedTurn&lt;/code&gt; grew a &lt;code&gt;tool&lt;/code&gt; field that streams &lt;code&gt;input_json_delta&lt;/code&gt; fragments the way the real API does, and — same lesson, other direction — an &lt;code&gt;error&lt;/code&gt; field. A stub that can only succeed makes every consumer's error path green theater by omission. An error turn answers with the documented Anthropic error JSON, and the real SDK surfaces it as a catchable &lt;code&gt;RateLimitError&lt;/code&gt;, exactly as in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stubAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rate_limit_error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rate limited.&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// …drive the UI; assert the app shows its rate-limit state, not a crash…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(One caveat that costs an afternoon if you skip it: the real SDK retries 429s and&lt;br&gt;
5xxs by default, so an error-turn script either scripts the retries too or runs&lt;br&gt;
the client with &lt;code&gt;maxRetries: 0&lt;/code&gt;. Both in-repo demonstrations do the latter.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Quickstart
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; llm-wire-stub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The registry tarball ships &lt;code&gt;dist/&lt;/code&gt; prebuilt, so it needs no install scripts and no flags — which matters as of npm v12, where git dependencies and lifecycle scripts are off by default. (A &lt;code&gt;github:egnaro9/llm-wire-stub&lt;/code&gt; install also works via the &lt;code&gt;prepare&lt;/code&gt; hook, but on npm 12 it needs the new &lt;code&gt;--allow-git&lt;/code&gt; and script allowances — the registry install is the clean path.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stubAnthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;llm-wire-stub&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the agent answers from the scripted wire&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stubAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;First scripted answer.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filing a card now.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create_card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prove the loop&lt;/span&gt;&lt;span class="dl"&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Card filed. Done.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// your app, unmodified&lt;/span&gt;
  &lt;span class="c1"&gt;// …drive the UI; the app's real SDK client hits the stub…&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// what the app actually sent&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// no unscripted model calls&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;overflow&lt;/code&gt; counts requests that ran past the end of the script — an unexpected extra model call shows up in an assertion instead of hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  hold() / release(): concurrency observed, not assumed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stubAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alpha&lt;/span&gt;&lt;span class="dl"&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;for alpha&lt;/span&gt;&lt;span class="dl"&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;for beta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                 &lt;span class="c1"&gt;// responses now block&lt;/span&gt;
&lt;span class="c1"&gt;// …trigger two sends in the UI…&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// both IN FLIGHT&lt;/span&gt;
&lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// both complete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the producer serialized its calls, the second request could never reach the wire while the first is still pending — so two recorded requests under hold is proof of concurrency, not a timing accident. Note the function-form script: an array keys answers to arrival order, which is a lottery under concurrency; a function keys them to who asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic envelope only.&lt;/strong&gt; One provider, tested end to end, over multi-provider support with one tested path. No OpenAI or Gemini framing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright-oriented.&lt;/strong&gt; &lt;code&gt;stubAnthropic&lt;/code&gt; wants a Playwright &lt;code&gt;BrowserContext&lt;/code&gt;. (&lt;code&gt;sseBody&lt;/code&gt; and &lt;code&gt;errorBody&lt;/code&gt; are framework-free; the vitest suite uses them with a plain custom &lt;code&gt;fetch&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messages API v1 streaming only.&lt;/strong&gt; No batch API, no extended thinking, no citations, no server tool use. Success turns are text and/or one &lt;code&gt;tool_use&lt;/code&gt; block; failures are the &lt;code&gt;error&lt;/code&gt; variant. Nothing else is expressible, on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scripted, not simulated.&lt;/strong&gt; The stub never invents behavior; if your script runs out, the overflow counter says so loudly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/egnaro9/llm-wire-stub" rel="noopener noreferrer"&gt;github.com/egnaro9/llm-wire-stub&lt;/a&gt; — MIT, 9 vitest tests (envelope through the real SDK, including the fails-loudly demonstrations) and 10 Playwright tests (a browser fixture driving the real SDK's tool loop against the stubbed wire). The private-product bugs above are provenance; everything the stub is claimed to &lt;em&gt;do&lt;/em&gt; is demonstrated by a test you can run.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>playwright</category>
      <category>typescript</category>
    </item>
    <item>
      <title>My determinism test passed for months while the two builds played different games</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 01 Aug 2026 15:23:41 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-determinism-test-passed-for-months-while-the-two-builds-played-different-games-1if4</link>
      <guid>https://dev.to/agentdev9/my-determinism-test-passed-for-months-while-the-two-builds-played-different-games-1if4</guid>
      <description>&lt;p&gt;I compiled the rules engine of a shipped Android game to the browser. Same Java, two compilers. Then I checked whether the two agreed.&lt;/p&gt;

&lt;p&gt;They did not — and the test I already had for exactly this had been green the whole time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls1skjzowq3w8grhfrf2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls1skjzowq3w8grhfrf2.gif" alt="The check passing, then the same command run against the recording of the build from before the fix: 69 differences" width="720" height="790"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same command twice: green against the current engine, then against the committed recording of the broken build. &lt;a href="https://asciinema.org/a/6vDCRqLuJ0FqzuHO" rel="noopener noreferrer"&gt;Play it as a terminal session&lt;/a&gt; if you want to select the text.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The rules live in one module with no Android on its classpath, which is what let me compile them a second time with &lt;a href="https://teavm.org" rel="noopener noreferrer"&gt;TeaVM&lt;/a&gt; and run the same logic on a canvas in a browser tab.&lt;/p&gt;

&lt;p&gt;A seeded run should be reproducible. Give the engine seed 42 and a fixed sequence of inputs, and you should get the same game every time — that is what makes a run replayable and two builds comparable.&lt;/p&gt;

&lt;p&gt;Here is what I actually got, same seed, same inputs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;JVM&lt;/th&gt;
&lt;th&gt;browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first obstacle x, frame 60&lt;/td&gt;
&lt;td&gt;405.426&lt;/td&gt;
&lt;td&gt;304.426&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;still alive at frame 360&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;final score&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not a rounding difference. A different game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause is boring. The test failure is not.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GameEngine&lt;/code&gt; used &lt;code&gt;java.util.Random&lt;/code&gt;. Its algorithm is specified down to the constants — you can read the exact linear congruential generator in the Javadoc. So a seed ought to name exactly one sequence.&lt;/p&gt;

&lt;p&gt;But my code was not running that algorithm. It was running &lt;em&gt;whichever implementation the runtime supplied&lt;/em&gt;, and TeaVM's is not the JVM's. The specification describes what &lt;code&gt;java.util.Random&lt;/code&gt; does; it does not force a foreign runtime's reimplementation to match.&lt;/p&gt;

&lt;p&gt;The fix took ten minutes: write the LCG out longhand so both builds execute the same arithmetic instead of trusting that they will.&lt;/p&gt;

&lt;p&gt;The interesting part is the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that could not have caught it
&lt;/h2&gt;

&lt;p&gt;I had a test called &lt;code&gt;theSameSeedProducesTheSameRun&lt;/code&gt;. It ran the engine twice, with the same seed, and asserted the results matched. It passed on every commit, including every commit during which the browser build was playing a different game.&lt;/p&gt;

&lt;p&gt;It had to pass. It runs the engine twice &lt;strong&gt;in the same runtime&lt;/strong&gt;. A test shaped like that cannot observe a disagreement &lt;em&gt;between&lt;/em&gt; runtimes — not a subtle one, not a 712-pixel one. The assertion was true and useless at the same time.&lt;/p&gt;

&lt;p&gt;This is the part I keep coming back to: the test was not weak, or flaky, or under-specified. It was &lt;strong&gt;structurally incapable&lt;/strong&gt; of failing for this reason. No amount of making it stricter would have helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A golden file would not have saved me either
&lt;/h2&gt;

&lt;p&gt;The obvious next move is to pin the output: record a known-good trace, commit it, assert against it forever.&lt;/p&gt;

&lt;p&gt;I did that too. Then I reverted the fix to see what would happen.&lt;/p&gt;

&lt;p&gt;The golden-file assertion &lt;strong&gt;passed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It had to. The JVM's &lt;code&gt;java.util.Random&lt;/code&gt; produces exactly what my hand-written LCG produces — that is the whole point of writing out the documented algorithm. So the JVM's trace never moved. Only the browser's did, and the golden file had nothing to say about the browser.&lt;/p&gt;

&lt;p&gt;Pinning one runtime's output is blind in precisely the same way as comparing one runtime to itself. Both feel like determinism tests. Neither can see across the boundary they claim to hold across.&lt;/p&gt;

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

&lt;p&gt;The only thing that finds this is a diff between the two runtimes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A JVM test drives a scripted 600-frame game — fixed seed, fixed frame cadence, ten scripted drags — and records score, streak, run state, the player and every obstacle at 11 checkpoints.&lt;/li&gt;
&lt;li&gt;It writes the &lt;strong&gt;input plan&lt;/strong&gt; to disk, and a Node script reads &lt;em&gt;that file&lt;/em&gt; to drive its run. The drags are defined once. If both sides hardcoded them, a typo in one would look like an engine disagreement, and I would be debugging the test instead of the code.&lt;/li&gt;
&lt;li&gt;The Node script imports the artifact the web build actually ships — not a reimplementation of it — and a comparator diffs the traces. Score, streak, run state and obstacle counts must match exactly. Floats get 0.01px, which is 5x the largest rounding difference the two compilers actually produce and four orders of magnitude below the bug it exists for.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the JVM and browser builds agree across 11 checkpoints
(largest float difference 0.0020, tolerance 0.01)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I falsified it before trusting it. Revert &lt;code&gt;Rng&lt;/code&gt;, run it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the two builds disagree (69 differences):
  frame 90  obstacle 1 x: jvm=438.177 browser=74.177  (off by 364.0000)
  frame 250 obstacle 3 x: jvm=879.507 browser=167.507 (off by 712.0000)
  frame 360 score:        jvm=8       browser=6
  frame 360 running:      jvm=true    browser=false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That recording is committed, so the failure reproduces without anyone having to break anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things fell out of it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The test I wrote to cover the fix was itself vacuous at first.&lt;/strong&gt; Its precondition needed a run that survived long enough to earn a continue, and a parked player never gets there — so it silently took a branch that asserted nothing. It passed with the fix removed. I only noticed because I make a habit of breaking the code to confirm the test goes red. Now it plays the run with a dodging strategy, and removing the fix fails it for the right reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recording px but not py hid a real change.&lt;/strong&gt; I altered a positioning constant by one pixel to test something unrelated, and the entire suite stayed green — because the trace captured the player's horizontal position and not its vertical one. A trace only protects what it records.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing worth taking away
&lt;/h2&gt;

&lt;p&gt;If you have a test whose name contains "deterministic," "reproducible," or "same seed," ask it one question: &lt;em&gt;which runtimes does it consult?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If the answer is one, it cannot tell you the thing its name implies. Neither can a golden file of that one runtime's output. Only the diff between them can.&lt;/p&gt;




&lt;p&gt;The engine is open source: &lt;a href="https://github.com/egnaro9/tapdodge-engine" rel="noopener noreferrer"&gt;github.com/egnaro9/tapdodge-engine&lt;/a&gt; — the check is in &lt;code&gt;tools/compare_trace.mjs&lt;/code&gt;, and the README has the numbers.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://egnaro9.github.io/seraphlight-studios/tap-dodge-rush/play/" rel="noopener noreferrer"&gt;play the browser build&lt;/a&gt;, which is the artifact the test drives.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>java</category>
      <category>android</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built an AI dev harness that isn't allowed to trust itself. Then I checked the part doing the not-trusting.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Mon, 27 Jul 2026 05:24:39 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-built-an-ai-dev-harness-that-isnt-allowed-to-trust-itself-then-i-checked-the-part-doing-the-298a</link>
      <guid>https://dev.to/agentdev9/i-built-an-ai-dev-harness-that-isnt-allowed-to-trust-itself-then-i-checked-the-part-doing-the-298a</guid>
      <description>&lt;p&gt;&lt;em&gt;A follow-up to &lt;a href="https://dev.to/agentdev9/i-built-an-ai-dev-harness-that-isnt-allowed-to-trust-itself-53mh"&gt;I built an AI dev harness that isn't allowed to trust itself&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The harness I wrote about isn't allowed to trust itself. That was the whole point, and it rested on one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;nothing an agent produces closes without machine-checkable proof, and no irreversible action happens without a human.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I still believe it. But it contains an assumption I didn't notice I was making: that the dangerous thing is unchecked work.&lt;/p&gt;

&lt;p&gt;Over one night I rebuilt both halves of that sentence on &lt;a href="https://pi.dev" rel="noopener noreferrer"&gt;pi&lt;/a&gt; — an open, npm-distributed, forkable agent runtime — mostly to find out how much of my harness was discipline and how much was one host's shape. Two public repos came out of it: &lt;a href="https://github.com/egnaro9/pi-eval" rel="noopener noreferrer"&gt;pi-eval&lt;/a&gt; for the proof half, and &lt;a href="https://github.com/egnaro9/pi-gates" rel="noopener noreferrer"&gt;pi-gates&lt;/a&gt; for the irreversibility half.&lt;/p&gt;

&lt;p&gt;Both halves failed first. They failed in the same shape, and it isn't the shape I'd been defending against. In each case the check &lt;strong&gt;ran&lt;/strong&gt;, &lt;strong&gt;passed&lt;/strong&gt;, and was proving something other than what it appeared to prove.&lt;/p&gt;

&lt;p&gt;That's a correction to my own thesis, so here it is with the receipts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gate that was a model being polite
&lt;/h2&gt;

&lt;p&gt;The irreversibility half is a gate that denies &lt;code&gt;git commit&lt;/code&gt; and &lt;code&gt;git push&lt;/code&gt; unless I've typed an approval phrase at the keyboard that turn. Everything else an agent does in a repo is recoverable by reading a diff. A push isn't.&lt;/p&gt;

&lt;p&gt;I wrote it, tested the pure logic, installed it, and asked my session to commit something without the phrase.&lt;/p&gt;

&lt;p&gt;It refused. Then it told me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the gate is functioning as intended — it's blocking the commit&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No bash command ran. No &lt;code&gt;tool_call&lt;/code&gt; event fired. The gate was never invoked. The model had read about the rule earlier in the conversation and was enforcing it &lt;em&gt;itself&lt;/em&gt; — and describing itself, accurately from its point of view, as the mechanism.&lt;/p&gt;

&lt;p&gt;I nearly reported the gate verified off that transcript.&lt;/p&gt;

&lt;p&gt;When I pushed back and said &lt;em&gt;do not refuse, I need to see whether the gate blocks&lt;/em&gt;, it classified that as an attempted bypass and refused again.&lt;/p&gt;

&lt;p&gt;The compounding detail is the part I'd want another engineer to take away. The model knew about the rule because &lt;strong&gt;my gate had told it.&lt;/strong&gt; My first "armed" banner used &lt;code&gt;ctx.ui.notify&lt;/code&gt;, which lands in the conversation. The gate manufactured the cooperation that made it unverifiable.&lt;/p&gt;

&lt;p&gt;Two fixes, both transferable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Announce out of context.&lt;/strong&gt; The banner moved to &lt;code&gt;ctx.ui.setStatus&lt;/code&gt;, which writes the TUI footer. I traced it: &lt;code&gt;setStatus&lt;/code&gt; → &lt;code&gt;setExtensionStatus&lt;/code&gt; in &lt;code&gt;interactive-mode.js&lt;/code&gt;, with no path into the model's context. A gate the model believes it owns is a gate it can be argued out of — and worse, one that &lt;em&gt;sounds&lt;/em&gt; like a gate while being nothing of the kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with no model in the loop.&lt;/strong&gt; Gate correctness is now proven by firing synthetic events at the handler and reading the return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;git add -A &amp;amp;&amp;amp; git commit -m x&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file is the only artifact that proves the gate fires. When I finally ran the real thing in a clean session, the difference was unmistakable — the model &lt;em&gt;ran&lt;/em&gt; the command and got denied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/gitgate-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add note.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"gate test 2"&lt;/span&gt;
BLOCKED — git commit/push needs an operator approval typed this turn.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same outcome as the refusal. Completely different fact.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gate that verified the predicate and not the answer key
&lt;/h2&gt;

&lt;p&gt;The proof half is a deterministic grader — fixed predicates, no LLM judge — so a score change means the output moved rather than the judge having a bad day.&lt;/p&gt;

&lt;p&gt;Every task has to survive two machine checks before it enters a suite: a &lt;strong&gt;correct&lt;/strong&gt; answer must PASS its predicate, and a &lt;strong&gt;plausible-but-wrong&lt;/strong&gt; answer must FAIL it. That second one matters more than it looks, because the comparison is a paired sign test where ties are discarded. A predicate that can't fail a wrong answer turns a real difference into a tie. A lax grader doesn't mismeasure your suite — it blinds it.&lt;/p&gt;

&lt;p&gt;The gate worked. Of 48 authored tasks it rejected 7, six of them for one cause: the &lt;code&gt;number&lt;/code&gt; grader takes the &lt;em&gt;first&lt;/em&gt; number in a reply, and a worked solution's first number is an operand. Those predicates graded &lt;code&gt;3&lt;/code&gt; against an expected &lt;code&gt;78&lt;/code&gt; and failed the correct answer.&lt;/p&gt;

&lt;p&gt;I was pleased with it. Then it admitted a task with &lt;code&gt;expected = 52.34&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;48.45 × 1.08 = 52.326&lt;/code&gt;, which rounds to &lt;strong&gt;52.33&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Both models I tested answered 52.33 and were scored wrong. It's git-provable in both directions — commit &lt;code&gt;25d3466&lt;/code&gt; adds &lt;code&gt;"expected": 52.34&lt;/code&gt;, and HEAD has &lt;code&gt;52.33&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's why the gate couldn't catch it. I wrote the answer key. I also wrote the known-good answer used to verify the predicate. The same arithmetic slip was in both. Two independent-&lt;em&gt;looking&lt;/em&gt; checks, one shared error, zero detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate verifies the predicate. It cannot verify the answer key.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is a third gate: a separate agent derives every answer from the &lt;strong&gt;prompt alone&lt;/strong&gt;, never shown my value, and disagreement kills the task. The predicate then runs against the &lt;em&gt;independently derived&lt;/em&gt; answer — grading mine would be circular, since mine may have been reverse-engineered from the predicate.&lt;/p&gt;




&lt;h2&gt;
  
  
  A right number about the wrong question
&lt;/h2&gt;

&lt;p&gt;Then the escalation, and it's the one that generalises furthest.&lt;/p&gt;

&lt;p&gt;I compared &lt;code&gt;thinking=medium&lt;/code&gt; against &lt;code&gt;thinking=off&lt;/code&gt; across 100 tasks with three repetitions each. The result was clean: near-total ties, zero informative tasks, a cost ratio of essentially 1. Tidy, plausible, publishable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--thinking&lt;/code&gt; was inert. Pi's resolver takes the level from a &lt;code&gt;model:level&lt;/code&gt; &lt;em&gt;pattern&lt;/em&gt;, not from the argument I was passing; it returned &lt;code&gt;undefined&lt;/code&gt; for every level, so the session fell back to the default. I had compared one config &lt;strong&gt;against itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"No measurable difference" was completely true and completely worthless.&lt;/p&gt;

&lt;p&gt;What caught it was token counting I'd added an hour earlier for an unrelated reason: both sides reported ~116k &lt;em&gt;reasoning&lt;/em&gt; tokens, and a &lt;code&gt;thinking=off&lt;/code&gt; run cannot do that.&lt;/p&gt;

&lt;p&gt;Notice the shape. This check didn't pass for the wrong reason. It passed &lt;strong&gt;correctly, about the wrong question&lt;/strong&gt; — and nothing downstream can detect that, because every artifact looks exactly like a successful measurement. So the fix isn't a better check, it's a refusal to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;preflight: asked for thinking=off but the session resolved to medium.
Refusing to run — a run labelled with a config it did not use is worse than no run.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is now my favourite line in either repo, and I'd put it in a category with the other refusals: a missing answer is an error and not a zero; a response truncated at the token cap is not an answer; two runs of different suites are not comparable and the tool says so rather than reporting the delta.&lt;/p&gt;




&lt;h2&gt;
  
  
  The correction belongs in the grader, not in my judgement
&lt;/h2&gt;

&lt;p&gt;With the config actually applied, &lt;code&gt;thinking=high&lt;/code&gt; beat &lt;code&gt;thinking=off&lt;/code&gt; &lt;strong&gt;8–0, p=0.008.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two of those wins weren't real.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;thinking=off&lt;/code&gt; answers in prose instead of thinking first. On one task it worked through the arithmetic, concluded "Friday" — correct — and an exact-match grader scored the whole reply. On another it listed matches at positions 0, 2, 4, 6, answered "4" — correct — and the extractor took the 0. &lt;strong&gt;The model was right both times.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Verbosity is not accuracy. But a config change that alters verbosity moves every position-sensitive grader in the same direction at once, so it doesn't look like noise. It looks like a finding.&lt;/p&gt;

&lt;p&gt;I could have published 8–0 and explained in prose which two results I'd decided to disbelieve. That's the version where you have to trust me.&lt;/p&gt;

&lt;p&gt;Instead the graders got a &lt;code&gt;scope="last_line"&lt;/code&gt; option, and re-scoring the same six runs produced &lt;strong&gt;6–0, p=0.031&lt;/strong&gt; — selecting the same six tasks I'd picked by hand, mechanically. The verdict is weaker and I trust it more, because nobody has to take my word for which results were artifacts.&lt;/p&gt;

&lt;p&gt;That's what "machine-checkable" is actually for. Not proving you're right. Removing your judgement from the places it can quietly do work.&lt;/p&gt;

&lt;p&gt;And the answer it produces is a decision rather than a score, because cost sits next to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thinking=high  won 6, lost 0, 84 tied, 10 unstable      p = 0.031
               $1.107  vs  $0.445                       2.49x cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;+6 tasks per 100 for two and a half times the money.&lt;/strong&gt; Someone can act on that. A score alone isn't a decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twice the data, less power
&lt;/h2&gt;

&lt;p&gt;The discipline has a price, and I'd rather show it than sell around it.&lt;/p&gt;

&lt;p&gt;At 100 tasks the tool refused to rank two frontier models. Only 4 tasks separated them, and six is the floor at α=0.05 — no split of that data could have reached significance. So it says that, instead of reporting a tie:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only 4 tasks separated them. Even a clean sweep of 4 could not clear p&amp;lt;0.05, so this suite cannot decide between them — that is a limit of the suite, not a finding about the configs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;"Cannot tell"&lt;/em&gt; and &lt;em&gt;"they're the same"&lt;/em&gt; are different findings. Most tooling collapses them, and the collapse always favours having something to report.&lt;/p&gt;

&lt;p&gt;There's a second rule underneath: &lt;strong&gt;a task where a config disagrees with itself carries no direction, and is discarded exactly like a tie.&lt;/strong&gt; Necessary, because running one model three times against the same 100 tasks produces ~2.67 "informative" differences from within-model variance alone. Any real finding has to clear that.&lt;/p&gt;

&lt;p&gt;Then I wrote in a README that this cost was fixable "with more repetitions." I tested that claim an hour later. It's backwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reps 4-6    9-1   unstable 13   informative 10   p=0.0215   decisive
reps 1-6    7-1   unstable 17   informative  8   p=0.0703   not
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Twice the data, less power.&lt;/strong&gt; Every extra repetition is another chance to observe a within-config disagreement, and the strict rule discards the task when it does. In the limit it throws away every genuinely stochastic task — precisely the ones carrying the most information about a noisy config. A conservative rule isn't a free choice, and the cost is measurable.&lt;/p&gt;

&lt;p&gt;One more, because it's the part I'd most want checked if I were reading this. When the strict rule returned a non-significant result, I built a weaker rule that returned a significant one. The motivation was independent — I'd measured the discards-rise-with-reps property before checking whether a different rule moved any verdict — and that is also exactly what everyone who p-hacks believes about themselves.&lt;/p&gt;

&lt;p&gt;So I wrote down the prediction, the refutation conditions and the fixed parameters, committed them &lt;strong&gt;before&lt;/strong&gt; collecting fresh data, and committed the analysis script while the runs were still going. A pre-registration that leaves the analysis to be written afterwards only relocates the discretion.&lt;/p&gt;

&lt;p&gt;One of three predictions failed — the conservative rule reached significance on fresh data when I'd predicted it wouldn't. I reported it as a failed prediction, because the whole point of writing it down first is that you don't get to reinterpret it after.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the port deleted
&lt;/h2&gt;

&lt;p&gt;The open-runtime argument is usually ideological. Here it's concrete, and it's mostly about subtraction.&lt;/p&gt;

&lt;p&gt;On Claude Code, my model gate needed three files and two hooks to answer one question: &lt;em&gt;which model is running?&lt;/em&gt; A SessionStart hook wrote the id to &lt;code&gt;.runtime_model_&amp;lt;session_id&amp;gt;&lt;/code&gt;; the gate read it back; a second &lt;code&gt;.runtime_model_ppid_&amp;lt;PPID&amp;gt;&lt;/code&gt; file existed purely to cover the race where &lt;code&gt;/clear&lt;/code&gt; starts a new session before the startup hook writes the new file.&lt;/p&gt;

&lt;p&gt;Pi hands the handler &lt;code&gt;ctx.model&lt;/code&gt;. All of it is gone — not refactored, deleted.&lt;/p&gt;

&lt;p&gt;The git gate is a better version of the same story. The original kept its approval in a sentinel &lt;strong&gt;file&lt;/strong&gt;, so it also needed a forge guard denying any command that so much as mentioned the sentinel's name, with documented residuals around assembled paths like &lt;code&gt;P=.oae_approve; touch "${P}_pending"&lt;/code&gt;. In pi the approval is a variable in the extension's closure, with no path from a bash command to it. That guard isn't hardened. It's unnecessary, and its residuals don't exist.&lt;/p&gt;

&lt;p&gt;How much of a sophisticated system is design, and how much is scar tissue from its substrate? For these two gates: a lot of it was scar tissue.&lt;/p&gt;

&lt;p&gt;But one thing didn't survive the move, and it's worth stating plainly rather than approximating. My harness had a Stop hook that refused to let a turn finish if source had changed without validation running. Pi has no blocking turn-end event — &lt;code&gt;agent_end&lt;/code&gt;, &lt;code&gt;agent_settled&lt;/code&gt;, &lt;code&gt;turn_start&lt;/code&gt; and &lt;code&gt;turn_end&lt;/code&gt; are all declared with no result type, so a handler literally cannot return a decision. If I need that rule I'll move the enforcement point to &lt;code&gt;tool_call&lt;/code&gt; before commit, and I'll call it a different gate rather than pretend it's a port.&lt;/p&gt;

&lt;p&gt;And the trust root needed rewriting, in a way a careless port would miss. Pi's &lt;code&gt;InputSource&lt;/code&gt; is &lt;code&gt;"interactive" | "rpc" | "extension"&lt;/code&gt; — &lt;strong&gt;an extension can inject input.&lt;/strong&gt; A gate that honoured every input event could be opened by the thing it exists to gate. The approval check is &lt;code&gt;source === "interactive"&lt;/code&gt;, and that single comparison is the whole security property.&lt;/p&gt;




&lt;h2&gt;
  
  
  The amendment
&lt;/h2&gt;

&lt;p&gt;I'm not retracting the original rule. The original's own logic is what forces the amendment.&lt;/p&gt;

&lt;p&gt;The harness isn't allowed to trust itself. But I was trusting the part that does the not-trusting, and I had no mechanism that could tell me otherwise.&lt;/p&gt;

&lt;p&gt;If an agent's work is unverified until a gate proves it, then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate is also an agent's work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was written by someone under time pressure, it can pass for the wrong reason, and it can be verified by a subject who has read it and would like to be helpful. The check running is not evidence that the check fired. The commit not happening is not evidence that the gate blocked it.&lt;/p&gt;

&lt;p&gt;Everything above is in two public repos, and every number in this post is reproducible from the run artifacts committed alongside them. That's deliberate, and it's the only part of my method I'd defend without qualification: if you can't point at the file, don't publish the number.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/egnaro9/pi-eval" rel="noopener noreferrer"&gt;pi-eval&lt;/a&gt; · &lt;a href="https://github.com/egnaro9/pi-gates" rel="noopener noreferrer"&gt;pi-gates&lt;/a&gt; · &lt;a href="https://github.com/egnaro9/gradecore" rel="noopener noreferrer"&gt;gradecore&lt;/a&gt; — the same grading engine behind a &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;live drift board&lt;/a&gt; and a &lt;a href="https://crashkit.onrender.com" rel="noopener noreferrer"&gt;live crash test&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a tool to prove my multi-agent harness was worth it. It told me it wasn't.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:06:31 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-built-a-tool-to-prove-my-multi-agent-harness-was-worth-it-it-told-me-it-wasnt-do</link>
      <guid>https://dev.to/agentdev9/i-built-a-tool-to-prove-my-multi-agent-harness-was-worth-it-it-told-me-it-wasnt-do</guid>
      <description>&lt;p&gt;I spend most of my time on agentic systems, and I had absorbed the same idea everyone else has: a planner improves things, and a panel of drafters with a judge improves them further. It sounds obviously true. More thinking, more review, better answers.&lt;/p&gt;

&lt;p&gt;I never measured it. So I built something that could, pointed it at my own setup, and it disagreed with me.&lt;/p&gt;

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

&lt;p&gt;One sweep. Twenty coding tasks, three harness shapes, real models, $0.99.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;harness&lt;/th&gt;
&lt;th&gt;calls/task&lt;/th&gt;
&lt;th&gt;score&lt;/th&gt;
&lt;th&gt;cost&lt;/th&gt;
&lt;th&gt;latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one drafter&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;td&gt;2.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;planner → drafter&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;$0.264&lt;/td&gt;
&lt;td&gt;9.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;planner → two drafters → judge&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.692&lt;/td&gt;
&lt;td&gt;18.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding the scaffolding made it &lt;strong&gt;worse&lt;/strong&gt; and cost &lt;strong&gt;22× more&lt;/strong&gt;. The four-call panel beat the single drafter on &lt;strong&gt;zero&lt;/strong&gt; of twenty tasks and lost three. Nothing errored — 0% failure rate across all sixty runs. It just did worse work, slower, for twenty-two times the money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I care about more
&lt;/h2&gt;

&lt;p&gt;Here is what the tool actually said about that:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only 3 tasks separated them. Even a clean sweep of 3 could not clear p&amp;lt;0.05, so &lt;strong&gt;this suite cannot decide&lt;/strong&gt; between them — that is a limit of the suite, not a finding about the harnesses.&lt;/p&gt;

&lt;p&gt;The panel costs 22× more and the suite cannot decide between them — on this evidence the extra spend buys nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;95% versus 80% &lt;em&gt;looks&lt;/em&gt; like a decisive result. It isn't. Seventeen of the twenty tasks were ties, so only three carried any information, and three discordant tasks cannot reach significance even if one side sweeps all of them. A leaderboard would have printed the two numbers and let me conclude the panel is worse. That would have been a stronger claim than the data supports.&lt;/p&gt;

&lt;p&gt;So the honest reading is narrower and more useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is &lt;strong&gt;no evidence&lt;/strong&gt; the panel helps on this suite.&lt;/li&gt;
&lt;li&gt;It costs 22× more and takes 8.4× longer, which is measured, not inferred.&lt;/li&gt;
&lt;li&gt;Whether it is genuinely worse needs more tasks than twenty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are three different statements. Most eval tooling collapses them into a ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the suite size is the real constraint
&lt;/h2&gt;

&lt;p&gt;It isn't step size — the 15-point gap is three times the 5-point resolution. It's that the two shapes only &lt;em&gt;disagreed&lt;/em&gt; on three tasks. Everything else tied, and ties are exactly what a paired test throws away. Twenty tasks is simply too few to generate enough disagreements for any test to work with. The fix is not better statistics, it is more tasks — which is why the tool lets you bring your own suite and tells you, as you paste it, how many points each task is worth.&lt;/p&gt;

&lt;p&gt;This is the same lesson my drift board taught me &lt;a href="https://dev.to/agentdev9/my-llm-drift-tracker-flagged-four-regressions-this-week-all-four-were-wrong-2i6e"&gt;earlier this week&lt;/a&gt;, when four "regressions" turned out to be rate limits and single-question noise. Small suites produce confident nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A harness config is data — roles, models, prompts, and a topology graph. You draw the shape or paste the JSON; each is a view of the other. Declare the axes you want to vary and it runs the matrix.&lt;/p&gt;

&lt;p&gt;Scoring is deterministic: fixed predicates execute the generated code and return a verdict, and &lt;strong&gt;no model grades anything&lt;/strong&gt;. There is an assistant in the page, and it is allowed to read scores and explain them — never to produce one.&lt;/p&gt;

&lt;p&gt;Worth being precise about what that does and doesn't buy. The &lt;em&gt;grading&lt;/em&gt; is deterministic — the same output always scores the same. The &lt;em&gt;generation&lt;/em&gt; is not: I set no temperature and no seed, and this sweep used one run per config, so a 5-point move between two runs of the same shape sits inside sampling noise. That is an argument for more tasks and more runs, and it is a second reason the tool won't call a winner here.&lt;/p&gt;

&lt;p&gt;The comparison is per-task and paired, not two averages. Both shapes run the same twenty tasks, so the question is how many tasks one won, which has far more power at this sample size than comparing means. The test is an exact sign test: no normality assumption, no variance assumption, ties excluded because they carry no direction.&lt;/p&gt;

&lt;p&gt;Your key stays in the browser. The backend receives sanitized traces and refuses anything key-shaped at its boundary; the page shows you the exact bytes it posts and tells you to check your own Network tab rather than believe the panel.&lt;/p&gt;

&lt;p&gt;One vendor detail worth writing down, because it cost me an hour: &lt;code&gt;api.openai.com&lt;/code&gt; answers the CORS preflight with the right headers and then omits &lt;code&gt;access-control-allow-origin&lt;/code&gt; on the actual response, so a browser-direct call is discarded no matter how valid the key is. Anthropic opts in deliberately — that is what &lt;code&gt;anthropic-dangerous-direct-browser-access&lt;/code&gt; is for. Testing only the preflight with &lt;code&gt;curl -X OPTIONS&lt;/code&gt; shows success and is misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this sits (so I don't oversell it)
&lt;/h2&gt;

&lt;p&gt;Harness and prompt-comparison tooling is not a new category — promptfoo, LangSmith, Braintrust and others do model and prompt comparison, several with far more surface area than this. The narrow thing here is an intersection: browser-BYOK, plus deterministic no-LLM-judge grading, plus a comparison that reports when it cannot decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cost me
&lt;/h2&gt;

&lt;p&gt;$0.99 and about ten minutes — the sweep runs strictly sequentially, so 60 runs at those latencies is 592 seconds of model time before anything else — to find out that the architecture I had been assuming was better is, on this evidence, not better and definitely more expensive.&lt;/p&gt;

&lt;p&gt;One caveat on the 22×: that ratio is at Sonnet 5's introductory pricing, which runs through 2026-08-31. After that the gap gets wider, not narrower.&lt;/p&gt;

&lt;p&gt;I would rather know.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Run it yourself:&lt;/strong&gt; &lt;a href="https://egnaro9.github.io/never-touch-ai/sweep.html" rel="noopener noreferrer"&gt;https://egnaro9.github.io/never-touch-ai/sweep.html&lt;/a&gt; — draw a harness and sweep it. It runs free on mock substrates with no key at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/egnaro9/never-touch-ai" rel="noopener noreferrer"&gt;https://github.com/egnaro9/never-touch-ai&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Raw result:&lt;/strong&gt; &lt;a href="https://github.com/egnaro9/never-touch-ai/blob/main/results/sweep_2026-07-25.json" rel="noopener noreferrer"&gt;&lt;code&gt;results/sweep_2026-07-25.json&lt;/code&gt;&lt;/a&gt; — the numbers above are computed from it, so you can check them.&lt;br&gt;
&lt;strong&gt;Deeper write-up:&lt;/strong&gt; &lt;a href="https://github.com/egnaro9/never-touch-ai/blob/main/docs/field-note-first-result.md" rel="noopener noreferrer"&gt;the field note&lt;/a&gt; — graph execution model, the sign test, and the two bugs the live run surfaced.&lt;br&gt;
&lt;strong&gt;Built by&lt;/strong&gt; Erik Hill · &lt;a href="https://egnaro9.github.io" rel="noopener noreferrer"&gt;https://egnaro9.github.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My LLM drift tracker flagged four regressions this week. All four were wrong.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Fri, 24 Jul 2026 23:55:15 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-llm-drift-tracker-flagged-four-regressions-this-week-all-four-were-wrong-2i6e</link>
      <guid>https://dev.to/agentdev9/my-llm-drift-tracker-flagged-four-regressions-this-week-all-four-were-wrong-2i6e</guid>
      <description>&lt;p&gt;I run a public board that probes 16 LLMs on a frozen 35-task suite, once a day, and keeps every score. When a model drops against its previous run, it opens a GitHub issue by itself and writes me a draft post.&lt;/p&gt;

&lt;p&gt;Between 21 and 24 July it did that four times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;23 Jul  Gemini 3.5 Flash   -11.4 pts
24 Jul  Gemini 3.1 Pro      -2.9 pts
21 Jul  Grok 4.3            -5.7 pts
22 Jul  Llama 3.3 70B       -2.9 pts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four regressions in four days, across three labs. That's a post that writes itself, and it would have been fast, legible, and wrong.&lt;/p&gt;

&lt;p&gt;None of those models got worse. Here's how I know, because the how is the only part worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of them weren't the model
&lt;/h2&gt;

&lt;p&gt;Every point on the board carries a second number next to accuracy: &lt;strong&gt;reliability&lt;/strong&gt;, the share of probe calls that actually came back. Look at the two Google alerts with that column showing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemini-3.5-flash  22 Jul  acc 1.000  reliability 1.000
                  23 Jul  acc 0.886  reliability 0.914   &amp;lt;- "-11.4 pts"

gemini-3.1-pro    22 Jul  acc 0.914  reliability 0.943
                  23 Jul  acc 0.886  reliability 0.914   &amp;lt;- "-2.9 pts"
                  24 Jul  acc 0.971  reliability 1.000   &amp;lt;- next clean run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Accuracy and reliability fell together. That's the signature of calls that never returned, not answers that got worse — a failed call has no answer to grade, and an ungraded task scores the same as a wrong one.&lt;/p&gt;

&lt;p&gt;I know this signature well because this board already published the lesson. On 20 July, Llama 3.3 70B appeared to fall 66 points overnight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;api.groq.com -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;429: Rate limit reached &lt;span class="k"&gt;for &lt;/span&gt;model &lt;span class="sb"&gt;`&lt;/span&gt;llama-3.3-70b-versatile&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;span class="go"&gt;service tier `on_demand` ... requests per minute (RPM): Limit 30, Used 30
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;34 of 35 calls were rate-limited. The model didn't get dumber; a 429 scored as a zero. &lt;strong&gt;A rate limit scoring as a 0% is the single most misleading thing a drift tracker can do, because it looks exactly like the thing the tracker exists to catch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini 3.1 Pro settles its own case: the next clean run came back at 97.1%, &lt;em&gt;higher&lt;/em&gt; than before the "regression."&lt;/p&gt;

&lt;h2&gt;
  
  
  The other two were one question
&lt;/h2&gt;

&lt;p&gt;The remaining two alerts are more interesting, because reliability held at 1.000 the whole time. Those numbers are real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grok-4.3        0.800 -&amp;gt; 0.743   = -5.7 pts
llama-3.3-70b   0.800 -&amp;gt; 0.771   = -2.9 pts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suite is 35 tasks. One task is &lt;code&gt;100/35 = 2.86&lt;/code&gt; points.&lt;/p&gt;

&lt;p&gt;So -2.9 points is &lt;strong&gt;one question changing its answer.&lt;/strong&gt; -5.7 is two. And -11.4, the scariest number in the set, is four.&lt;/p&gt;

&lt;p&gt;A 35-task suite cannot resolve anything finer than about three points. Every "regression" my board flagged this week was an integer number of questions, which is the tell: I wasn't measuring drift, I was measuring the granularity of my own instrument. Reporting a one-question flip as a model regression is reading noise as signal — and doing it in public, about a named company's model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the alerting is still right to be loud
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to make the tracker quieter — only fire above 10 points, say. I don't think that's right. A tracker that only fires on catastrophes misses the drift you actually want to catch, and the -11.4 that turned out to be failed calls is exactly the shape of a real regression. Sensitivity is the feature.&lt;/p&gt;

&lt;p&gt;Sensitivity is only &lt;em&gt;safe&lt;/em&gt;, though, if something downstream is willing to say no. So the alert doesn't publish anything. It writes a stub that says, in its own text:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Auto-logged when the scheduled probe flagged a run-over-run regression. Before this becomes a post, check the run log and the Reliability metric — a rate limit or provider outage can look exactly like a regression.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The automation's job is to notice. Mine is to check. This week that split did real work: four notices, zero posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I actually care about
&lt;/h2&gt;

&lt;p&gt;If you build evals, you already track your models' scores. The metric I'd argue you're missing is &lt;strong&gt;the share of your own alerts that survive checking.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mine, this week, was zero. That's not a comfortable number to publish, and it's the most useful one I have — it tells me the suite is too small to resolve single-task noise, and that reliability has to sit beside accuracy on every chart or the chart lies.&lt;/p&gt;

&lt;p&gt;Both of those are fixable. Neither would have been visible if I'd shipped the post the tracker wrote for me.&lt;/p&gt;

&lt;p&gt;The hard part of a drift tracker isn't detecting drift. It's not manufacturing it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The board:&lt;/strong&gt; &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;egnaro9.github.io/model-drift&lt;/a&gt; — 16 models, 5 metrics, daily, every run kept. The field notes are on the page; this one is "Four regression alerts, zero regressions."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code:&lt;/strong&gt; &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;github.com/egnaro9/model-drift&lt;/a&gt;. No LLM-as-judge anywhere — every task is graded by a fixed deterministic check, so a score change means the model moved, not the test.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>monitoring</category>
      <category>testing</category>
    </item>
    <item>
      <title>The AI Crash Test: adversarial LLM testing you can audit in the Network tab</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Thu, 23 Jul 2026 04:57:51 +0000</pubDate>
      <link>https://dev.to/agentdev9/the-ai-crash-test-adversarial-llm-testing-you-can-audit-in-the-network-tab-1b29</link>
      <guid>https://dev.to/agentdev9/the-ai-crash-test-adversarial-llm-testing-you-can-audit-in-the-network-tab-1b29</guid>
      <description>&lt;p&gt;&lt;em&gt;A browser tool that points your own API key at an adversarial battery and grades every answer with pure predicates — no LLM judge, and your key never touches my server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first time I ran it against a real model, it told me the model was ~29% vulnerable.&lt;/p&gt;

&lt;p&gt;That number was wrong. And the tool proved it was wrong — to me, in public — because of exactly one design decision.&lt;/p&gt;

&lt;p&gt;Every verdict in The AI Crash Test is a deterministic predicate over the model's answer string: exact match, regex, a number check, an injection canary, a must-refuse rule. No model grades another model. So when the report flagged ~29% vulnerable, it also showed the fail card for every miss — prompt, expected, actual, side by side. Three of those cards didn't show a broken model. They showed a broken grader: false positives in my own code. I fixed the graders; the real number was 0%.&lt;/p&gt;

&lt;p&gt;That's the whole pitch. An auditable grader has bugs you can catch in public. A vibes-based, LLM-as-judge arena just hands you a number and asks you to trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part first
&lt;/h2&gt;

&lt;p&gt;LLM red-teaming is a crowded, mature space. garak (NVIDIA), PyRIT (Microsoft), and promptfoo all do far more than this — more probes, more scale, more integrations. Browser tools that adversarially test with your own key exist too; most lean on an LLM judge.&lt;/p&gt;

&lt;p&gt;So this isn't a new category, and I won't pretend it is. The narrow thing that's mine is an intersection: &lt;strong&gt;browser-based BYOK + deterministic no-judge grading + a provably shared engine with a longitudinal drift board.&lt;/strong&gt; Distinctive engineering and discipline, not a market-novel product. If you want heavy artillery, go use garak. If you want a result you can reproduce byte-for-byte and a key that goes straight to the provider and never touches my server, read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two properties you can check yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Deterministic grading — no LLM in the grade path.&lt;/strong&gt; Every grade is a pure function of the answer string, run in an open-source engine called gradecore. Run a mock model through it twice and the score is byte-identical. No temperature, no judge drift, no "the grader was having a bad day."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. BYOK, never-touches.&lt;/strong&gt; The browser calls the provider directly with your key. crashkit's server receives only the answers — the grade request has no key field at all.&lt;/p&gt;

&lt;p&gt;Don't take my word for it. Open DevTools → Network, run a battery with your key, and search the panel for the key itself. It lights up only on the request to the provider (e.g. &lt;code&gt;api.anthropic.com&lt;/code&gt;, in the &lt;code&gt;x-api-key&lt;/code&gt; header) — never in the &lt;code&gt;/api/grade&lt;/code&gt; call. I verified this live before writing this; you can reproduce it in about thirty seconds.&lt;/p&gt;

&lt;p&gt;The honest caveat: this only works where the provider allows direct browser calls. Anthropic (with the dangerous-direct-browser-access header) and Gemini work; OpenAI-direct is often CORS-blocked. Stating the limit is part of the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, two lenses
&lt;/h2&gt;

&lt;p&gt;gradecore isn't a crashkit-only toy. It's the same deterministic engine behind my live model-drift board, which tracks 16 LLMs over time. Same code, two jobs: the board is longitudinal monitoring; The AI Crash Test is on-demand adversarial testing.&lt;/p&gt;

&lt;p&gt;And it's the &lt;em&gt;same&lt;/em&gt; engine, not a lookalike. Run the board's frozen suite through gradecore and the &lt;code&gt;suite_hash&lt;/code&gt; comes out identical, byte for byte — faithful extraction, not a reimplementation. (To be clear: crashkit uses gradecore, not my whole eval stack.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the battery
&lt;/h2&gt;

&lt;p&gt;Eight tasks across seven attack kinds: prompt-injection, tool-abuse, spec-violation, refusal-calibration, unsafe-compliance, hallucination-bait, and consistency. You get a severity-weighted vulnerability report and a fail card for every miss.&lt;/p&gt;

&lt;p&gt;A real result from a recent run: &lt;strong&gt;claude-haiku-4-5 resisted 100% — 0% vulnerable across all seven kinds&lt;/strong&gt;, deterministically graded, reproducible.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Run it: &lt;a href="https://crashkit.onrender.com" rel="noopener noreferrer"&gt;https://crashkit.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/egnaro9/crashkit" rel="noopener noreferrer"&gt;github.com/egnaro9/crashkit&lt;/a&gt; and &lt;a href="https://github.com/egnaro9/gradecore" rel="noopener noreferrer"&gt;github.com/egnaro9/gradecore&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built this solo, self-taught, in under a year — and yes, with heavy AI assistance in the loop. The design decision I care about is the one you can check without trusting me: open the Network tab, run it twice, read the graders. Prove it — don't take my word for it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>301 duplicate IDs in the browser, 0 on the JVM: one real bug, end to end</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Wed, 22 Jul 2026 00:01:43 +0000</pubDate>
      <link>https://dev.to/agentdev9/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end-10cj</link>
      <guid>https://dev.to/agentdev9/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end-10cj</guid>
      <description>&lt;p&gt;A reader named Ryan left the sharpest comment on my last post. The gist: it was jargon-heavy, kept restating "you have to test the AI's output" in new words, and read more like a pitch deck than a case study. He was right, and he pointed at the fix himself: &lt;em&gt;walk through one real task. What changed, what caught the problem, what proof was required, where did a human step in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So here is one task, start to finish. Everything below is public and runnable. No withheld details, no diagrams of boxes with arrows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, in two sentences
&lt;/h2&gt;

&lt;p&gt;My match-3 game runs on a plain-Java rules engine. It runs two ways: on a JVM (where the tests and CI live) and in the browser, compiled to JavaScript by &lt;a href="https://teavm.org" rel="noopener noreferrer"&gt;TeaVM&lt;/a&gt;, so the same Java drives a real playable board.&lt;/p&gt;

&lt;p&gt;That second runtime is not just a demo. Running one piece of logic on two different machines gives me a free check: &lt;strong&gt;where the two disagree, one of them is wrong.&lt;/strong&gt; I did not have to write down the right answer. I just had to notice a disagreement.&lt;/p&gt;

&lt;p&gt;Here is a task where that check earned its keep.&lt;/p&gt;

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

&lt;p&gt;I ported the engine to the browser. New &lt;code&gt;demo-js&lt;/code&gt; module, TeaVM config, opaque integer handles in and JSON out so boards never actually cross into JavaScript. Mechanical work. The engine code barely moved.&lt;/p&gt;

&lt;p&gt;Except the port compiled a line I had never once looked at hard. This is how every gem got its ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nanoTime&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;RNG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nextInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timestamp plus a random number. It had passed every test for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check caught
&lt;/h2&gt;

&lt;p&gt;The IDs are how the renderer tells one gem from another. Two gems with the same ID animate as a single gem. So I ran the same board generation on both runtimes and counted collisions over 128,000 gems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JVM: &lt;strong&gt;0 duplicates.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Browser (TeaVM): &lt;strong&gt;301 duplicates.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same source code. Same inputs. Different answer. That is the entire signal. A machine counted it; I did not have to guess that something felt off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I stepped in
&lt;/h2&gt;

&lt;p&gt;A number this specific still needs a human to say &lt;em&gt;which&lt;/em&gt; side is wrong and &lt;em&gt;why&lt;/em&gt;. That part was me.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;System.nanoTime()&lt;/code&gt; looks unique but only leans on the clock being high-resolution enough that two calls land on different values. A JVM's timer is fine, so the flaw was invisible there. Browsers deliberately clamp their clock to about 100 microseconds (a Spectre mitigation), so &lt;code&gt;nanoTime&lt;/code&gt; barely advances between gems and &lt;code&gt;RNG.nextInt(1000)&lt;/code&gt; collides on its own often enough to matter.&lt;/p&gt;

&lt;p&gt;Neither runtime was broken. The &lt;strong&gt;code&lt;/strong&gt; was, for depending on clock resolution it was never promised. The browser was just honest about it.&lt;/p&gt;

&lt;p&gt;The fix is boring, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;idSeq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;nextId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;idSeq&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-String id = row + "-" + col + "-" + System.nanoTime() + "-" + RNG.nextInt(1000);
&lt;/span&gt;&lt;span class="gi"&gt;+String id = row + "-" + col + "-" + nextId();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A counter is unique on every clock. The guarantee stops depending on the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What had to exist before it could close
&lt;/h2&gt;

&lt;p&gt;The one rule I do not bend: a fix is not done because I say "fixed." It is done when a check that would catch the bug is sitting on disk and passing in CI. For this one, that meant three tests whose whole job is to fail if IDs ever lean on the clock again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;idsAreUniqueWithoutRelyingOnClockResolution&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Mint many gems as fast as possible: a clock-derived id would collide&lt;/span&gt;
    &lt;span class="c1"&gt;// here on any platform whose timer doesn't advance between calls.&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;GameBoard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Gem&lt;/span&gt;&lt;span class="o"&gt;[][]&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BoardEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createBoard&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GameBoard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Gem&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GameBoard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Gem&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus one for board creation and one for the refill hot path, where new gems get minted every cascade. The suite went from 51 tests to 59. Those three are the receipt that the specific failure cannot come back quietly. Without them, "I fixed the ID thing" is just a sentence.&lt;/p&gt;

&lt;p&gt;The commit and the tests are here: &lt;a href="https://github.com/egnaro9/match3-engine" rel="noopener noreferrer"&gt;&lt;code&gt;match3-engine&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;BoardEngine.java&lt;/code&gt;, &lt;code&gt;IdUniquenessTest.java&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trick, one level up
&lt;/h2&gt;

&lt;p&gt;Around the same time, the same "run it two ways, look for a quiet disagreement" habit pointed the other direction. Auditing where TeaVM and the JVM diverge, I hit a date case they split on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;YEAR = 2002, WEEK_OF_MONTH = 2   (America/New_York, en_US)
JVM:   Sun Jan 06 2002
TeaVM: Sat Jan 12 2002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time my code was fine. The bug was in TeaVM's reimplementation of Java's &lt;code&gt;GregorianCalendar&lt;/code&gt;. One line used &lt;code&gt;days - 2&lt;/code&gt; where every neighbouring branch, and the Apache Harmony code it was ported from, used &lt;code&gt;days - 3&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-days += (fields[WEEK_OF_MONTH] - 1) * 7 + mod7(skew + dayOfWeek - (days - 2)) - skew;
&lt;/span&gt;&lt;span class="gi"&gt;+days += (fields[WEEK_OF_MONTH] - 1) * 7 + mod7(skew + dayOfWeek - (days - 3)) - skew;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One character. The reason no test had caught it: the suite already had four assertions that reach that exact line, commented out since 2015. My change re-enabled them instead of adding new ones. They fail on the old code and pass on the fix, which is the cleanest proof I could ask for that the fix is real and nothing else moved.&lt;/p&gt;

&lt;p&gt;It &lt;a href="https://github.com/konsoletyper/teavm/pull/1213" rel="noopener noreferrer"&gt;merged into TeaVM&lt;/a&gt; on 2026-07-17 and closed a dormant issue. My whole contribution was a &lt;code&gt;2&lt;/code&gt; to a &lt;code&gt;3&lt;/code&gt; and un-commenting eleven-year-old assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's the whole thing
&lt;/h2&gt;

&lt;p&gt;No cleverer reviewer would have found the first bug by reading the code, because the code looked fine and the tests were green. A second runtime found it by disagreeing. My job was the part a machine can't do: read a "0 vs 301" and decide which side was lying, and why.&lt;/p&gt;

&lt;p&gt;If there's a transferable idea here it's just this: don't keep one source of truth for logic you can't fully check by hand. Run it two ways and treat every disagreement as a bug until you've proven which side it lives on. Sometimes it's yours. Once, it was the compiler's.&lt;/p&gt;

&lt;p&gt;Both examples are runnable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/egnaro9/match3-engine" rel="noopener noreferrer"&gt;&lt;code&gt;match3-engine&lt;/code&gt;&lt;/a&gt; — the Java engine, 59 tests, playable in-browser via TeaVM.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://egnaro9.github.io/evals-differential-oracle/" rel="noopener noreferrer"&gt;&lt;code&gt;evals-differential-oracle&lt;/code&gt;&lt;/a&gt; — a tiny browser demo of the same idea: the same match-3 rule written twice, fuzzed against each other over thousands of boards, plus a deliberately-broken version both nets catch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks to Ryan for the nudge. The last post told you I test things. This one showed you one.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>java</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Distinguishing wrong from absent</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Tue, 21 Jul 2026 19:42:32 +0000</pubDate>
      <link>https://dev.to/agentdev9/distinguishing-wrong-from-absent-57ep</link>
      <guid>https://dev.to/agentdev9/distinguishing-wrong-from-absent-57ep</guid>
      <description>&lt;p&gt;model-drift grades models weekly on a frozen suite with an exact-match grader — no LLM judge, so a score change is real. That design has a sharp edge: a call that returns no valid answer scores identically to a wrong one. A refusal, a max_tokens truncation, a timeout, a parser failure on a quietly-changed schema — all land as zero, indistinguishable from the capability collapse the board exists to catch.&lt;/p&gt;

&lt;p&gt;I hit the catchable version: a model appeared to drop from 69% to 3% overnight. The run log showed a 429 on 34 of 35 calls — a rate limit scored as a regression. Rate limits are catchable only because they leave a status code; refusals and truncations don't.&lt;/p&gt;

&lt;p&gt;The board excludes by aggregate reliability: it drops a run's accuracy point only when that run's reliability falls below 50% — a catastrophic-infra floor, never a single failed call. That restraint matters because failures aren't missing-at-random: the long, hard prompts are the ones that hit token caps and timeouts, so a blanket drop-every-failure rule would exclude failures that correlate with difficulty and inflate accuracy on the discriminating tasks. An eval board has to tell a wrong answer from an absent one; miss that and you're scoring the provider's uptime, not the model.&lt;/p&gt;

&lt;p&gt;The design was already exclude-by-class, not blanket. Code: github.com/egnaro9/model-drift&lt;/p&gt;

&lt;p&gt;Writing the methodology in public is how it gets read this closely.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>llm</category>
      <category>ai</category>
      <category>testing</category>
    </item>
    <item>
      <title>I built agentic AI that checks its own work — a 90-second tour</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Tue, 21 Jul 2026 04:19:21 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-built-agentic-ai-that-checks-its-own-work-a-90-second-tour-4e63</link>
      <guid>https://dev.to/agentdev9/i-built-agentic-ai-that-checks-its-own-work-a-90-second-tour-4e63</guid>
      <description></description>
    </item>
    <item>
      <title>I built an AI dev harness that isn't allowed to trust itself</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Mon, 20 Jul 2026 02:12:53 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-built-an-ai-dev-harness-that-isnt-allowed-to-trust-itself-53mh</link>
      <guid>https://dev.to/agentdev9/i-built-an-ai-dev-harness-that-isnt-allowed-to-trust-itself-53mh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Scope note (read first): this describes a system I built and operate to develop an &lt;strong&gt;unannounced game&lt;/strong&gt;. The game's identity, mechanics, and assets are withheld, and so are the harness's tuned prompts, gate implementations, and internal failure specifics. What's shown here is the &lt;strong&gt;method and the evidence discipline&lt;/strong&gt; — the transferable part.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Over about four months (spring–summer 2026) I built and operated, solo, an operator-supervised multi-agent development harness that builds a real, shipping product. Its defining property isn't speed — it's that &lt;strong&gt;nothing an agent produces closes without machine-checkable proof, and no irreversible action happens without a human.&lt;/strong&gt; This is a case study of the system and the evidence trail it leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;LLM coding agents are fast and unreliable. The engineering problem isn't getting output — it's trusting it. So the harness is built around one rule: &lt;strong&gt;an agent's work is unverified until a gate proves it.&lt;/strong&gt; Coordination is automated; consequences are gated. And it's built to &lt;em&gt;ship&lt;/em&gt;, not to gold-plate: every gate exists so I can move fast without shipping something broken — verification in service of velocity, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture (concept level)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A five-role loop:&lt;/strong&gt; Strategy → Execution → Critic → Eval → Ops. Judgment roles (Strategy, Critic, Eval) run on stronger models; execution roles on cheaper ones — cost follows the difficulty of the decision, not a flat default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A manager / orchestration layer.&lt;/strong&gt; Above the execution agents sits one orchestration role that I direct — it plans each unit of work, routes it to the right role and model, and holds the system's state between steps. I designed the roles, the gates, and the routing; the harness runs them. I'm not outside the loop supervising a black box — I'm the system's judgment and authority, and the manager is the layer that extends that across many parallel agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cold, independent critic gate.&lt;/strong&gt; Before a consequential change closes, it's reviewed by a Critic running on a &lt;em&gt;fresh, zero-context&lt;/em&gt; session — a different strong model with no memory of how the code was written — so it reviews the work itself, not the author's rationale for it. It can send the change back for rework. A self-review rubber-stamps; a cold critic catches what the author already talked themselves past.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human-in-the-loop autonomy ladder:&lt;/strong&gt; the loop's &lt;em&gt;handoffs&lt;/em&gt; are automated — one role hands to the next without me — but every &lt;em&gt;irreversible&lt;/em&gt; act (deploying a build to a device, committing to git) stays behind an explicit human approval. Automate coordination; never automate the irreversible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A differential oracle for correctness:&lt;/strong&gt; the core logic is implemented twice and the two versions are fuzzed against each other. Where they disagree, one is wrong — no gold labels required.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The evidence discipline (the differentiator)
&lt;/h2&gt;

&lt;p&gt;Every closed unit of work leaves a durable, machine-checkable proof:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NO-PROOF-NO-CLOSE gate.&lt;/strong&gt; A work item cannot close until an automated check confirms its proof exists on disk. The loop physically cannot skip it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance-bound proof.&lt;/strong&gt; On-device validation screenshots are sanitized (sensitive regions blacked out), and provenance manifests bind images to the exact git SHA, screen dimensions, and redaction method that produced them — so an artifact traces back to the commit it proves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-gated checkpoints.&lt;/strong&gt; Each checkpoint records scoped git staging (explicit paths only), a commit/SHA trail across the repos it touches, an artifact-registry audit, and an explicit operator approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Periodic self-evaluation.&lt;/strong&gt; An independent evaluation role produces a numeric health score with a delta versus the prior period and a failure taxonomy; regressions feed a failure registry that drives fixes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The testing oracle
&lt;/h2&gt;

&lt;p&gt;The product's core logic is held to &lt;strong&gt;property-based invariant tests&lt;/strong&gt; — generated inputs are thrown at the engine and a set of invariants must hold for every one (e.g. a detector must agree with an independent full re-scan, and detection must be side-effect-free). The suite runs against the authoritative implementation, so an invariant is &lt;em&gt;enforced on the logic&lt;/em&gt;, not asserted in prose (last run: zero failures). As a standalone, fully public demonstration of the same technique, my &lt;a href="https://github.com/egnaro9/match3-engine" rel="noopener noreferrer"&gt;match3-engine&lt;/a&gt; repo carries 16 jqwik property invariants over random inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operating record (Apr–Jul 2026, from the on-disk archive)
&lt;/h2&gt;

&lt;p&gt;~200 completed work-arcs · ~190 human-gated checkpoints · 74 independent critic reviews · 13 periodic self-evaluations · a growing failure registry with per-item root-cause fixes · a ~200-file sanitized proof archive with ~90 provenance manifests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifiable outcomes (all public)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An arcade game — &lt;strong&gt;Tap Dodge Rush&lt;/strong&gt;, under SeraphLight Studios — shipped end-to-end to &lt;a href="https://play.google.com/store/apps/details?id=com.seraphlight.tapdodgerush" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A one-character bug fix &lt;strong&gt;merged upstream into TeaVM&lt;/strong&gt; (the Java-to-JavaScript compiler), closing a long-dormant issue.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;live public model-drift board&lt;/strong&gt; grading 16 LLMs daily on a frozen, deterministically-graded suite — no LLM-as-judge, so a score change is real.&lt;/li&gt;
&lt;li&gt;Ten public repos, including a differential-oracle testing project and a Model Context Protocol server built from the spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd bring to a team
&lt;/h2&gt;

&lt;p&gt;Treat AI output as unverified until proven. Build the gate before the feature. Make failures loud, not silent. Keep a human on the irreversible path. The discipline transfers to any codebase — the harness just made me practice it a few hundred times.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full architecture case study &amp;amp; repo: &lt;a href="https://github.com/egnaro9/agentic-dev-harness" rel="noopener noreferrer"&gt;github.com/egnaro9/agentic-dev-harness&lt;/a&gt; · Portfolio: &lt;a href="https://egnaro9.github.io" rel="noopener noreferrer"&gt;egnaro9.github.io&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Set membership is not pairing: a property test that was green for the exact bug it was written to catch</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sun, 19 Jul 2026 05:57:09 +0000</pubDate>
      <link>https://dev.to/agentdev9/set-membership-is-not-pairing-a-property-test-that-was-green-for-the-exact-bug-it-was-written-to-3g6b</link>
      <guid>https://dev.to/agentdev9/set-membership-is-not-pairing-a-property-test-that-was-green-for-the-exact-bug-it-was-written-to-3g6b</guid>
      <description>&lt;p&gt;I have a small board that tracks 16 LLMs across 5 labs on a frozen 35-task suite. The charts are the easy part. The paragraph above them is the part I did not trust, because I had written that kind of paragraph by hand twice and both times it went false — true when I typed it, falsified by a later run.&lt;/p&gt;

&lt;p&gt;So I stopped typing it. The paragraph is generated now. Each sentence is a claim: a predicate over the current numbers plus a renderer, returning a string or &lt;code&gt;None&lt;/code&gt;. A claim whose predicate stops holding is dropped, not reworded. The generator can be silent. It can't be wrong.&lt;/p&gt;

&lt;p&gt;That second sentence is the part I got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sentence that lied
&lt;/h2&gt;

&lt;p&gt;One claim compares a lab's cheaper models against that same lab's flagship. On the live board it produced this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Google's cheaper tiers match or beat its own flagship — Gemini 3.5 Flash at 100% and Gemini 3.1 Flash-Lite at 95%, against Gemini 3.1 Pro at 95%&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Correct. But the first version of that renderer collected the winners, took the highest score among them, and printed one number for the group:&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winners&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;winners&lt;/span&gt;&lt;span class="p"&gt;))&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;Which reads: &lt;em&gt;Gemini 3.5 Flash and Gemini 3.1 Flash-Lite at 100%&lt;/em&gt;. Flash-Lite scored 95%. The sentence put a real number next to a model that did not earn it.&lt;/p&gt;

&lt;p&gt;Naming several models and then printing one number is not a formatting choice. It is a claim about all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that should have caught it
&lt;/h2&gt;

&lt;p&gt;I had written a property test for exactly this class of bug. It asserted that every percentage appearing in the prose was a score some model actually got:&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;real&lt;/span&gt; &lt;span class="o"&gt;=&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;acc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;span class="n"&gt;printed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\d+%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;printed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was green.&lt;/p&gt;

&lt;p&gt;It is green &lt;em&gt;because the assertion is true&lt;/em&gt;. 100% was a real score — Gemini 3.5 Flash got it. The test asked "does this number exist in the data?" The bug was "is this number attached to the right model?" Set membership where pairing was required.&lt;/p&gt;

&lt;p&gt;The replacement checks the pairing:&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;_PAIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b([AB] (?:Big|Small|Tiny)) at (\d+)%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;printed&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_PAIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&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;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;printed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fixture was wrong too
&lt;/h2&gt;

&lt;p&gt;This is the part I would have missed if I had stopped at the test.&lt;/p&gt;

&lt;p&gt;A fixture with one cheap model per lab &lt;strong&gt;cannot reach the failing state&lt;/strong&gt; — with a single cheap model per group, a group is always unanimous, so a renderer printing &lt;code&gt;rows[0]&lt;/code&gt;'s score is always right. That was the shape I started from, and I fixed it before the first commit, so take this part on my word rather than on the history. The property test was not weak because of its assertion alone. It was weak because the data it ran against could not produce a disagreement.&lt;/p&gt;

&lt;p&gt;The fixture now gives LabA two cheaper tiers under one flagship, which is the shape Google actually has (Pro / Flash / Flash-Lite):&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="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;id&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;lab-a:big&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;label&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;A Big&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;group&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;LabA&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;tier&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;flagship&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;id&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;lab-a:small&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;label&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;A Small&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;group&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;LabA&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;tier&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;mini&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;id&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;lab-a:tiny&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;label&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;A Tiny&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;group&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;LabA&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;tier&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;nano&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the baseline board puts those two cheap tiers at &lt;em&gt;different&lt;/em&gt; scores, both at or above their flagship.&lt;/p&gt;

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

&lt;p&gt;Eight mutants run against the new guards. &lt;strong&gt;Three survived the first time&lt;/strong&gt; — the tie guard, the single-flagship rule, and a float-truncation fix — and each one needed a test written before it was really covered. The repair was less covered than it felt.&lt;/p&gt;

&lt;p&gt;The float one is my favourite, because it is a bug in the code whose entire job was to never overstate a number. Percentages truncate rather than round, so a model on 99.6% can never print as "100%" — a perfect score it did not get, in the one place a reader checks hardest. But:&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="c1"&gt;# The epsilon is not decoration: 0.58 * 100 is 57.99999999999999 in binary
# floating point, so a bare floor prints a measured 58% as "57%". [...]
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&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="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1e-9&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A guard against overstating had started understating instead. That is still wrong prose about real data — just wrong in the flattering direction, which is the direction you don't check.&lt;/p&gt;

&lt;p&gt;The suite is 125 tests now, stdlib only, 0.04s. It was 91 at the fix commit, 74 when the generator first landed, and 22 before the generator existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask what state would make this assertion fail, and name it out loud.&lt;/strong&gt; I could not have named it for the set test — not because I hadn't thought about it, but because the fixture made it unreachable and I never noticed that the two facts were connected. An assertion whose failing state you cannot describe is decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutate the code, not just the inputs.&lt;/strong&gt; Property tests over generated data feel rigorous enough to skip this. Three of eight mutants survived my new, careful, adversarially-reviewed guards. That is the number that convinced me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assume the repair is uncovered.&lt;/strong&gt; The fix arrives believing in itself. It has no tests yet.&lt;/p&gt;

&lt;p&gt;And one more, which I found while writing this. The README badge for that repo says &lt;code&gt;tests-91&lt;/code&gt;. It is 125. Four screens further down, the quickstart says &lt;code&gt;# 22 tests&lt;/code&gt; — a badge and its own README disagreeing, both hand-typed, both true on the day they were written and false since. That is the identical failure mode the generator exists to eliminate, sitting in the repo that hosts the generator.&lt;/p&gt;

&lt;p&gt;The verification was broken, not the code. It usually is.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;github.com/egnaro9/model-drift&lt;/a&gt; — the broken test and the real one are both in &lt;code&gt;tests/test_narrative.py&lt;/code&gt;.&lt;br&gt;
Portfolio: &lt;a href="https://egnaro9.github.io" rel="noopener noreferrer"&gt;egnaro9.github.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>softwareengineering</category>
      <category>llm</category>
    </item>
    <item>
      <title>The Cold-Context Critic: A Reviewer That Never Remembers Writing the Code</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Thu, 16 Jul 2026 01:22:41 +0000</pubDate>
      <link>https://dev.to/agentdev9/the-cold-context-critic-a-reviewer-that-never-remembers-writing-the-code-40fp</link>
      <guid>https://dev.to/agentdev9/the-cold-context-critic-a-reviewer-that-never-remembers-writing-the-code-40fp</guid>
      <description>&lt;p&gt;Anyone who has written code and then reviewed it minutes later knows the feeling: the diff looks fine. Of course it does. The person reading it is the same person who just talked themselves into every line of it. The naming made sense because the intent is still warm in memory. The shortcut felt justified because the reason for it is still sitting in working memory, uninspected.&lt;/p&gt;

&lt;p&gt;That is the problem the cold-context critic is built to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;In the harness I built to develop a game, every change passes through a review step performed by a &lt;strong&gt;fresh model instance that has no memory of writing the code&lt;/strong&gt;. It did not plan the change. It did not argue for the approach. It did not feel the small relief of getting something to finally pass. It receives the diff and evaluates it on its merits, the way a stranger would.&lt;/p&gt;

&lt;p&gt;The intuition is old and boring, which is part of why I trust it: an author rationalizes; a cold reviewer does not. A model — or a person — that just produced the work carries a bias toward the choices it already made. Strip the memory of making them, and the same mistakes stop being self-evidently correct. The reviewer has nothing to defend. It can only look at what is actually there.&lt;/p&gt;

&lt;p&gt;I want to be precise about the claim, because it is easy to oversell. This is a &lt;strong&gt;discipline&lt;/strong&gt;, an architecture choice. I am not going to tell you it caught a specific dramatic production bug, because I am not going to invent an incident to sell an idea. What I can say is the mechanism: cold review catches the class of mistakes an author talks itself into — the plausible-looking shortcut, the assumption never stated out loud, the edge case that "obviously" can't happen. Self-review is structurally bad at exactly those, because the author already accepted them once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits
&lt;/h2&gt;

&lt;p&gt;The cold critic is one layer, not the whole story. It sits alongside a few other pieces of the same distrust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-role model routing.&lt;/strong&gt; Stronger models run the judgment stages — planning, critique, evaluation — and cheaper ones handle routine execution. The reviewer isn't grading its own homework, and it isn't the same weight class picked for speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A differential oracle.&lt;/strong&gt; Core game logic is implemented twice and held to shared invariants. When the two implementations disagree, at least one is wrong, and I find out without having to trust either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human approval gate on every irreversible action.&lt;/strong&gt; Deploys and commits do not happen because a model decided they should. They happen because a person approves them. That gate never moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I think about the whole thing on an autonomy ladder, L0 through L4 — from fully hands-on toward more independence. The cold critic is what makes climbing that ladder defensible. More autonomy is only safe if the verification underneath it does not depend on the thing being verified vouching for itself.&lt;/p&gt;

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

&lt;p&gt;Concretely: the critic is a separate invocation with a clean context window. It does not inherit the conversation that produced the change. It gets the diff and the standard it is being held to, and it reports back before a human sees the change. That's the shape of it. The value isn't a clever prompt — it's the missing memory. The setup exists to guarantee the reviewer never accumulated a reason to like the code.&lt;/p&gt;

&lt;p&gt;That's also why I keep the human gate immovable. The cold critic is a good reader, not an authority. It can flag; it doesn't get to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest note
&lt;/h2&gt;

&lt;p&gt;I'm about five to six months into this, career-changing from roughly six years in professional kitchens, no CS degree — I learned through freeCodeCamp and by building. The game is real and published to Google Play, but it is not serving millions, and I'm not running production RAG or fine-tuning pipelines. I'd rather tell you what this is than dress it up.&lt;/p&gt;

&lt;p&gt;So the honest framing is this: I don't have the scars yet that teach senior engineers where the bodies are buried. What I can do is build the distrust in as a habit from the start — assume the author is biased, including when the author is me or a model I'm running, and make something with no stake in the answer do the checking.&lt;/p&gt;

&lt;p&gt;If you want to see the same idea in code you can actually run, the differential oracle is public: &lt;a href="https://github.com/egnaro9/evals-differential-oracle" rel="noopener noreferrer"&gt;github.com/egnaro9/evals-differential-oracle&lt;/a&gt;. It's the same move as the cold critic — distrust your own output — implemented as two independent versions of the logic checked against each other, plus invariant tests over thousands of random boards, with a deliberately buggy implementation included to prove the checks bite. Clone it, run &lt;code&gt;pytest&lt;/code&gt;, watch it catch the planted bug.&lt;/p&gt;

&lt;p&gt;More at &lt;a href="https://egnaro9.github.io" rel="noopener noreferrer"&gt;egnaro9.github.io&lt;/a&gt; and &lt;a href="https://github.com/egnaro9" rel="noopener noreferrer"&gt;github.com/egnaro9&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>codereview</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
