<?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>A shallow clone does not fail your history check. It answers it.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:32:12 +0000</pubDate>
      <link>https://dev.to/agentdev9/a-shallow-clone-does-not-fail-your-history-check-it-answers-it-41p9</link>
      <guid>https://dev.to/agentdev9/a-shallow-clone-does-not-fail-your-history-check-it-answers-it-41p9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-10-28-the-clone-is-part-of-the-check/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  A shallow clone does not fail your history check. It answers it.
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;I replaced a check that could not tell it had stopped working. The&lt;br&gt;
replacement could not tell either. The commit landed at 18:44:13 and the run&lt;br&gt;
that caught it started at 18:44:22. Nine seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I committed a new check at 18:44:13 and the run that caught it failing started&lt;br&gt;
at 18:44:22. Nine seconds. It had passed locally, 282 tests green. In CI it&lt;br&gt;
accused my own commit of secretly editing the results board that every&lt;br&gt;
published number is derived from.&lt;/p&gt;

&lt;p&gt;The failure read:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;board commits that are neither the probe nor a declared human edit:
  e9f904c5 by egnaro9: Stage the guard-sibling note, with the fifth
  instance and its probe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That commit adds a blog post. It does not touch the board, and it never has.&lt;/p&gt;

&lt;p&gt;The check existed because I had just switched off an older one that compared&lt;br&gt;
the board against a second, hosted store. Switched off, not deleted: it is&lt;br&gt;
still in the file, skipping now with its reason stated. Before that it had&lt;br&gt;
spent twenty-four days reporting green without running, because the store it&lt;br&gt;
queried was retired in August and an unreachable store is not the same fact as&lt;br&gt;
a disagreeing one. Fifty-seven CI runs passed while it compared nothing, each&lt;br&gt;
printing &lt;code&gt;207 passed, 1 skipped&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the replacement asks git instead, because git is already there and cannot be&lt;br&gt;
retired: &lt;strong&gt;has the board been edited outside the run that produced it?&lt;/strong&gt; Every&lt;br&gt;
change is a commit with an author and a subject. The probe writes&lt;br&gt;
&lt;code&gt;chore: update drift standings&lt;/code&gt;. A deliberate human edit says &lt;code&gt;board:&lt;/code&gt; and&lt;br&gt;
explains itself. Anything else is a stray. And on my laptop, that worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clone was the problem, and the clone had no error to give
&lt;/h2&gt;

&lt;p&gt;CI checks out at the default depth of 1. My laptop has the full history.&lt;/p&gt;

&lt;p&gt;A shallow clone does not make &lt;code&gt;git log -- &amp;lt;path&amp;gt;&lt;/code&gt; fail. It does not make it&lt;br&gt;
empty. The single fetched commit has no parent, so by parent list a shallow&lt;br&gt;
boundary and a genuine root commit are &lt;strong&gt;the same shape&lt;/strong&gt;, and the log answered&lt;br&gt;
as though that commit had created the file.&lt;/p&gt;

&lt;p&gt;Measured on two clones of the same repository, at the same commit, minutes&lt;br&gt;
apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;question&lt;/th&gt;
&lt;th&gt;full clone&lt;/th&gt;
&lt;th&gt;depth-1 clone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;commits touching the board&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;which commit created it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;9ee59e3&lt;/code&gt; "board: publish the floor..."&lt;/td&gt;
&lt;td&gt;&lt;code&gt;600d503&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lines that commit added to it&lt;/td&gt;
&lt;td&gt;0, it never touched the file&lt;/td&gt;
&lt;td&gt;33,403&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the whole post. Git reported a 33,403-line insertion that&lt;br&gt;
never happened, attributed it to the person who pushed last, and my check read&lt;br&gt;
that number and did exactly what it was built to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git did tell me. I did not ask.
&lt;/h2&gt;

&lt;p&gt;I wrote the paragraph above claiming git has no way to signal this. That was&lt;br&gt;
wrong, and an adversarial re-check of my own draft caught it before this went&lt;br&gt;
out. The signal exists and it is one flag away:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git log --diff-filter=A --decorate -- dashboard/drift_board.json
600d503 (grafted, HEAD -&amp;gt; main, origin/main) A shallow clone does not...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;grafted&lt;/code&gt; is git saying precisely what I needed, per commit rather than per&lt;br&gt;
repository, so it marks exactly which log lines are untrustworthy. The full&lt;br&gt;
clone prints no such marker on the real creating commit. It survives&lt;br&gt;
&lt;code&gt;--no-decorate&lt;/code&gt; being the scripting default, which is the point: nobody adds&lt;br&gt;
&lt;code&gt;--decorate&lt;/code&gt; to a query they intend to parse.&lt;/p&gt;

&lt;p&gt;So the honest claim is smaller and more useful than the one I made. Git does&lt;br&gt;
not tell you by default, and the field that would tell you is one nobody reads.&lt;br&gt;
The answer was sitting in the output I chose not to ask for.&lt;/p&gt;

&lt;p&gt;(Measured on git 2.50.1 only. I have not checked older versions.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard I had already written for this did not fire
&lt;/h2&gt;

&lt;p&gt;I had anticipated the empty case. The check opened with:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert subjects, "no history for the board; this check did not actually run"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That assertion is correct and it is useless here, because the result was not&lt;br&gt;
empty. It was a list of one. A vacuity guard asks "did I get nothing?" and the&lt;br&gt;
shallow clone's answer is "no, you got something." The failure mode I needed to&lt;br&gt;
survive was never absence. It was &lt;strong&gt;a confident, plausible, wrong answer&lt;/strong&gt;,&lt;br&gt;
which is the only kind a provenance check cannot survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the sibling thirty-six lines below already had the fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; has two jobs that check out the repository. The&lt;br&gt;
second one already carried this, with a comment explaining why:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fetch-depth: 0        # the stamped commit must be reachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The first one did not. Line 29 and line 65 of the same file, one guarded for&lt;br&gt;
an unrelated reason and one not. I added a check that needed full history to the&lt;br&gt;
job that did not have it, in a file that visibly contained the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was found at all, which is the part I got lucky on
&lt;/h2&gt;

&lt;p&gt;Not by reading the email. By a different probe, built earlier the same evening&lt;br&gt;
for that problem, which reports for every scheduled workflow how&lt;br&gt;
many consecutive runs and how many days its verdict has held, and when it was&lt;br&gt;
last green. Run it and one line said:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEW RED   model-drift   draft.yml   1 runs / 0d, NEVER green in the window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;NEVER green in the window&lt;/code&gt; means no run it looked at was green. The tool's&lt;br&gt;
own wording is "may never have passed at all" and that hedge is load-bearing:&lt;br&gt;
it reads a fixed number of recent runs, so a long enough red streak is&lt;br&gt;
indistinguishable from a check that has never passed. Here it was the strong&lt;br&gt;
reading, because the workflow had run exactly once. That was a second, unrelated instance of the same family:&lt;br&gt;
a workflow that finds a regression, writes it up, pushes the branch, and then&lt;br&gt;
fails on the final step whose only job is to open a pull request and tell&lt;br&gt;
somebody. The finding existed, complete, in a place nobody would look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A notification system that fails at the notification step produces silence,&lt;br&gt;
and silence is indistinguishable from having nothing to report.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One thing limited the damage, and it is narrower than I first wrote. The code&lt;br&gt;
records "I have already drafted this finding" into the &lt;em&gt;same commit&lt;/em&gt; as the&lt;br&gt;
draft itself. So an unmerged draft leaves the main ledger untouched and the&lt;br&gt;
finding gets offered again on the next run. Had the ledger been written&lt;br&gt;
separately, the run would have marked the regression seen while the only copy&lt;br&gt;
sat on an abandoned branch, and it would never have been raised again.&lt;/p&gt;

&lt;p&gt;That is a re-offer guarantee, not a save. The draft itself is gone: I deleted&lt;br&gt;
the branch, and the commit survives only as an unreachable SHA waiting on&lt;br&gt;
garbage collection. I kept a bundle, which is me being careful rather than the&lt;br&gt;
system being safe. And the workflow's own pull request body told the reader the&lt;br&gt;
opposite, that closing the PR records the finding as seen and it will not be&lt;br&gt;
offered again, which was false for exactly the same reason. I fixed that text&lt;br&gt;
while writing this.&lt;/p&gt;

&lt;p&gt;None of that is a design I can claim credit for foreseeing. I noticed it while&lt;br&gt;
checking whether I had lost anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Both layers, because either one alone rots:&lt;/p&gt;

&lt;p&gt;The workflow now fetches full history. That is the one-line fix and it is the&lt;br&gt;
less important one, because a workflow file is a different file and will drift&lt;br&gt;
again.&lt;/p&gt;

&lt;p&gt;The check now refuses to answer from a clone that cannot answer. It runs the&lt;br&gt;
log, asks &lt;code&gt;git rev-parse --is-shallow-repository&lt;/code&gt;, and throws the answer away&lt;br&gt;
with the remedy in the message rather than reporting a fabricated stray.&lt;br&gt;
Refusing before the result is USED is the part that matters; refusing before it&lt;br&gt;
is fetched would be tidier and buys nothing.&lt;/p&gt;

&lt;p&gt;The predicate is a plain function taking the flag and the commit count, so it&lt;br&gt;
can be handed the shallow case directly, which a real clone cannot be made to&lt;br&gt;
do inside a test run. The case that bit me is now&lt;br&gt;
&lt;code&gt;test_a_shallow_clone_is_refused_even_though_it_answers&lt;/code&gt;: shallow with one&lt;br&gt;
commit must be refused, even though one passes every vacuity check ever&lt;br&gt;
written.&lt;/p&gt;

&lt;p&gt;Proof on a single clone: shallow, two failures naming &lt;code&gt;fetch-depth&lt;/code&gt;. Then&lt;br&gt;
&lt;code&gt;git fetch --unshallow&lt;/code&gt;, same clone, same file, thirteen passed. Five of five&lt;br&gt;
mutations caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;The general claim I want to make is "check the integrity of your environment,&lt;br&gt;
not just your reading of it," and I am not sure that generalizes without&lt;br&gt;
becoming unfalsifiable. Every check runs in an environment. You cannot verify&lt;br&gt;
all of it. I can defend the specific version: if your instrument reads&lt;br&gt;
something the CI runner is free to configure, the configuration is an input to&lt;br&gt;
your instrument and belongs inside it.&lt;/p&gt;

&lt;p&gt;I also do not know how common this is outside git. Git is unusual in that a&lt;br&gt;
truncated history is a &lt;em&gt;supported&lt;/em&gt;, first-class state with no error attached.&lt;br&gt;
Most tools that cannot see something say so. The reason this was invisible is&lt;br&gt;
partly specific to git, and I might be over-reading it as a pattern.&lt;/p&gt;

&lt;p&gt;And the honest one. The post I wrote earlier the same evening is about guards&lt;br&gt;
that return a plausible number instead of throwing. I wrote it, staged it, and&lt;br&gt;
then committed exactly that defect, into the file that already contained its&lt;br&gt;
own fix, where I could see it. Knowing the shape of a bug does not appear to&lt;br&gt;
help much at the moment you are writing it. What helped was an unrelated probe&lt;br&gt;
asking a question I had not thought to ask.&lt;/p&gt;

&lt;p&gt;If you keep a check that reads git history: clone it shallow and run it. If it&lt;br&gt;
passes, it is not reading what you think it is reading.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live board: &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;https://egnaro9.github.io/model-drift/&lt;/a&gt;&lt;br&gt;
Suite, graders and runner: &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;https://github.com/egnaro9/model-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every commit referenced above is public. The two-clone comparison was re-measured while writing this, not copied from the commit message.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>The guard was applied once. Its sibling read the same data without it.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sun, 25 Oct 2026 16:47:37 +0000</pubDate>
      <link>https://dev.to/agentdev9/the-guard-was-applied-once-its-sibling-read-the-same-data-without-it-3bhp</link>
      <guid>https://dev.to/agentdev9/the-guard-was-applied-once-its-sibling-read-the-same-data-without-it-3bhp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-10-21-the-guard-and-its-sibling/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  The guard was applied once. Its sibling read the same data without it.
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about five of my own tools, including one I wrote that night to catch this exact thing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;In one session across three repositories I fixed five separate defects. They&lt;br&gt;
looked unrelated while I was fixing them. Four are the same bug, and the fifth&lt;br&gt;
is the version of it that has no code in it at all.&lt;/p&gt;

&lt;p&gt;In every case the code knows a rule, applies it correctly at one call site, and&lt;br&gt;
then a second path reads the same data with the rule missing. Nothing goes red,&lt;br&gt;
because both paths return numbers that look fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A grader that scored silence as a pass.&lt;/strong&gt; Accuracy is&lt;br&gt;
&lt;code&gt;graded_pass / graded_total&lt;/code&gt;, and a provider error leaves the case ungraded. The&lt;br&gt;
per-category breakdown, twenty lines away, counted a case as OK when it was &lt;code&gt;not&lt;br&gt;
flagged&lt;/code&gt;, and &lt;code&gt;flagged&lt;/code&gt; is &lt;code&gt;passed is False&lt;/code&gt;. &lt;code&gt;None is False&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;. So a&lt;br&gt;
model that returned nothing printed &lt;code&gt;acc 0%&lt;/code&gt; beside &lt;code&gt;instruction-following 100%&lt;/code&gt;&lt;br&gt;
in all nine categories, on the same line, on runs where 35 of 35 calls returned&lt;br&gt;
HTTP 402.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A flip detector reading rows a floor had already rejected.&lt;/strong&gt; The board applies&lt;br&gt;
a reliability floor to the accuracy path. The flip analysis, in the file next to&lt;br&gt;
it, applied nothing, so it read &lt;code&gt;fails&lt;/code&gt; arrays from runs written before an&lt;br&gt;
earlier fix, when absent calls were scored as wrong answers. Measured: 255&lt;br&gt;
cross-provider alarm days became 159, and 106 repeat offenders became 39. Five&lt;br&gt;
alarm tasks were provider outage in their entirety. Sixty-three percent of that&lt;br&gt;
backlog was never a finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that checked one half of its own output.&lt;/strong&gt; I wrote something that&lt;br&gt;
reports which conversation threads are owed a reply, because a post I cannot&lt;br&gt;
work should not go out. It fetches links, and it HEAD-checks the ones it&lt;br&gt;
&lt;em&gt;quotes&lt;/em&gt;, because a quote nobody can open is a quote of nothing. It did not&lt;br&gt;
check the ones it &lt;em&gt;counts&lt;/em&gt;. It reported six threads owed. Two resolved. One was&lt;br&gt;
a crypto recovery scam the platform had already removed and three had authors&lt;br&gt;
who were suspended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A commit gate that never looked at the commit.&lt;/strong&gt; A pre-commit hook refuses a&lt;br&gt;
change to a claim-bearing file unless a receipt exists for it. Receipt selection&lt;br&gt;
was &lt;code&gt;grep -F "&amp;lt;path&amp;gt; ::" | head -1&lt;/code&gt;. The first line mentioning the path won,&lt;br&gt;
whatever it said and whenever it was written, so a file reviewed once was exempt&lt;br&gt;
from review forever. Four days, and six tests did not catch it. Mutation testing&lt;br&gt;
did.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the fifth, which reported green
&lt;/h2&gt;

&lt;p&gt;The same night, a failure email turned out to be about a test that had not run&lt;br&gt;
in twenty-four days.&lt;/p&gt;

&lt;p&gt;That test compares my board against a second store, by run date. The store's&lt;br&gt;
write path was retired in August, its archive froze on 2026-08-30, the board&lt;br&gt;
kept advancing, so every model would have been incomparable and the test would&lt;br&gt;
have hit its own guard:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assert compared, "no model could be compared; the agreement check did not
actually run"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That assertion is the test being right. It refuses to report success having&lt;br&gt;
compared nothing.&lt;/p&gt;

&lt;p&gt;It never reached that line. The check first asks the store whether it is there.&lt;br&gt;
The old host had been retired and answers 404, and an unreachable store is not&lt;br&gt;
the same fact as a disagreeing one, so the check skipped instead. &lt;strong&gt;For&lt;br&gt;
twenty-four days CI was green: fifty-seven passing runs, each printing&lt;br&gt;
&lt;code&gt;207 passed, 1 skipped&lt;/code&gt;.&lt;/strong&gt; The skip is the one.&lt;/p&gt;

&lt;p&gt;It only turned red after I repointed it at the archive that was still&lt;br&gt;
reachable, and then only once that day's probe pushed the board past the&lt;br&gt;
archive's last row. Three red runs across seven and a half hours, and I fixed&lt;br&gt;
it the same evening.&lt;/p&gt;

&lt;p&gt;So there was no missing guard here either. The guard was written, correct, and&lt;br&gt;
unreached, and the thing standing in front of it returned the most plausible&lt;br&gt;
value available: green. &lt;strong&gt;A skip is a pass.&lt;/strong&gt; Nothing counted how long a check&lt;br&gt;
had been passing without running.&lt;/p&gt;

&lt;p&gt;So the pattern is wider than a call site. &lt;strong&gt;A rule that lives in one place and&lt;br&gt;
is not counted anywhere will be quietly opted out of&lt;/strong&gt;, and a check can opt&lt;br&gt;
itself out by becoming unable to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;claim&lt;/th&gt;
&lt;th&gt;how it was checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;five defects, one shape, three repositories&lt;/td&gt;
&lt;td&gt;commits &lt;code&gt;125b9df&lt;/code&gt;, &lt;code&gt;d8edf35&lt;/code&gt;, &lt;code&gt;b67f341&lt;/code&gt; in model-drift, &lt;code&gt;47f82a1&lt;/code&gt; in the site repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the outage case printed 0% and 100% together&lt;/td&gt;
&lt;td&gt;the run log for three models, 35 of 35 calls returning 402&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the flip filter changed the numbers by this much&lt;/td&gt;
&lt;td&gt;re-ran the analysis over the same stored board: 255 to 159 alarm days, 106 to 39 repeat offenders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the thread counter was wrong by a factor of three&lt;/td&gt;
&lt;td&gt;curl on all six permalinks: two returned 200, four returned 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;six tests missed the gate defect and mutation found it&lt;/td&gt;
&lt;td&gt;the seventh test's docstring names the surviving mutant; re-running it gives 6 passed, 1 failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the rule has more consumers than you would guess&lt;/td&gt;
&lt;td&gt;the reliability floor is imported by five files in one package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the agreement check reported green for 24 days without running&lt;/td&gt;
&lt;td&gt;its old host returns 404, so the check skipped; 57 green ci runs between 2026-08-30 and 2026-09-23 each print &lt;code&gt;207 passed, 1 skipped&lt;/code&gt;; the frozen archive's newest row is 2026-08-30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why none of them went red
&lt;/h2&gt;

&lt;p&gt;A guard that is missing does not throw. It returns a plausible number.&lt;/p&gt;

&lt;p&gt;Both halves of every pair above produced output a reader would accept. &lt;code&gt;acc 0%&lt;/code&gt;&lt;br&gt;
is a number. &lt;code&gt;106 repeat offenders&lt;/code&gt; is a number. &lt;code&gt;6 threads owed&lt;/code&gt; is a number.&lt;br&gt;
&lt;code&gt;head -1&lt;/code&gt; returns a line. Nothing crashed, nothing was empty, and no test failed,&lt;br&gt;
because the tests asserted that the guarded path was guarded, which was true.&lt;/p&gt;

&lt;p&gt;The shape is: &lt;strong&gt;a rule is a property of a value, and it gets implemented as a&lt;br&gt;
property of a call site.&lt;/strong&gt; Once the rule lives in a call site, adding a second&lt;br&gt;
reader of the same value is an ordinary, blameless change that silently opts out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;For each one, the fix was two lines and the test was the work. But fixing five&lt;br&gt;
instances is not the same as fixing the class, and the class is what generates&lt;br&gt;
instances.&lt;/p&gt;

&lt;p&gt;The cheap structural version is to count the consumers. The reliability floor is&lt;br&gt;
imported by five files. A test that asserts five, and names them, goes red when&lt;br&gt;
someone adds a sixth that does not apply it. That converts "a new reader silently&lt;br&gt;
opts out" into "a new reader fails the build until it opts in".&lt;/p&gt;

&lt;p&gt;It is not elegant and it does not prove anything about correctness. It changes&lt;br&gt;
the default from silence to noise, which is the only property that matters here.&lt;/p&gt;

&lt;p&gt;For the fifth I built a probe that reports, for every scheduled check, how many&lt;br&gt;
consecutive runs and how many days it has held its verdict, and when it was&lt;br&gt;
last green. Not whether it is red. How long, because that is the number that&lt;br&gt;
separates a failure from a decision nobody made. It also reports "never green&lt;br&gt;
in the window", the signature of a check born unable to pass.&lt;/p&gt;

&lt;p&gt;It would not have caught this one, and I want to be plain about that. The probe&lt;br&gt;
reads workflow conclusions, and a skipping test makes the workflow green. It&lt;br&gt;
answers "how long has this been red" and the fifth instance needed "how long&lt;br&gt;
has this been reporting on nothing." Those are different questions and I have&lt;br&gt;
only built the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;Five instances in one session is a small sample, and I found them because I was&lt;br&gt;
already looking at that layer. There is an obvious selection effect: I have no&lt;br&gt;
idea how many guards in this estate have exactly one consumer and are therefore&lt;br&gt;
fine, so I cannot tell you the rate, only that it happened five times in a night.&lt;/p&gt;

&lt;p&gt;The counting test is also weaker than it sounds. It catches a new file that&lt;br&gt;
imports the rule and ignores it. It does not catch a new file that re-implements&lt;br&gt;
the rule slightly differently, which is the more likely failure once people know&lt;br&gt;
the count is being checked. Goodhart applies to your own guards.&lt;/p&gt;

&lt;p&gt;And the honest one: two of the five were in code I wrote that same night,&lt;br&gt;
including the tool whose entire job is to check that links resolve before&lt;br&gt;
trusting them. I did not fail to know the rule. I knew it, wrote it down in a&lt;br&gt;
docstring, and applied it to one of the two places in the same function that&lt;br&gt;
needed it.&lt;/p&gt;

&lt;p&gt;If you keep a rule like this, a floor, a filter, a validity check: grep for its&lt;br&gt;
name and count the files. Then ask, for each one, whether that file applies it&lt;br&gt;
or merely mentions it. The gap between those two numbers is the one I keep&lt;br&gt;
finding.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live board: &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;https://egnaro9.github.io/model-drift/&lt;/a&gt;&lt;br&gt;
Suite, graders and runner: &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;https://github.com/egnaro9/model-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every commit referenced above is public. The numbers were re-measured while writing this, not copied from the commit messages.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Our mutation score said 42 of 46 caught. It meant 42 of 46 labelled.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:54:35 +0000</pubDate>
      <link>https://dev.to/agentdev9/our-mutation-score-said-42-of-46-caught-it-meant-42-of-46-labelled-4a48</link>
      <guid>https://dev.to/agentdev9/our-mutation-score-said-42-of-46-caught-it-meant-42-of-46-labelled-4a48</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-10-07-labelled-caught-is-not-caught/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Our mutation score said 42 of 46 caught. It meant 42 of 46 labelled.
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about my own tool's evidence, not about its result. The number did not move.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;evalmut takes an eval case your grader already passes, plants a known defect in the&lt;br&gt;
output, and reruns the grader. If the grader still passes a provably wrong output,&lt;br&gt;
that is a hole: a class of regression your eval would let ship green.&lt;/p&gt;

&lt;p&gt;The honest test of a tool like that is pointing it at its own dependency, so evalmut&lt;br&gt;
runs against gradecore's grader families and the README prints the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  mutation score    91.3%   (42 caught / 46 applied; 223 n/a)
  holes            4  (2 blind, 2 coverage-gap)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped that. Then I went looking at what was behind the word "caught".&lt;/p&gt;

&lt;h2&gt;
  
  
  Two facts, and neither of them is "caught"
&lt;/h2&gt;

&lt;p&gt;The run proved gradecore had been imported, via &lt;code&gt;inspect.getfile&lt;/code&gt;. And it recorded a&lt;br&gt;
label for each row. That was all.&lt;/p&gt;

&lt;p&gt;Import plus label does not reach what a reader hears in "caught". Caught says a&lt;br&gt;
grader looked at a defective output and rejected it. Import says the right module&lt;br&gt;
loaded. Between those two sits every way a harness can quietly answer for the&lt;br&gt;
library it claims to be measuring: an adapter that falls back to its own comparison&lt;br&gt;
when the upstream call raises, a cached result, a guard that returns early before&lt;br&gt;
the call happens at all.&lt;/p&gt;

&lt;p&gt;The irritating part is that evalmut already shipped the machinery for this. There is&lt;br&gt;
a witness for in-process graders and one for subprocess graders, and the second one&lt;br&gt;
refuses outright any row that arrives without proof. The dogfood demo, the one the&lt;br&gt;
published number came from, used neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix would have been a second lie
&lt;/h2&gt;

&lt;p&gt;Count calls into the grader. One per row, done.&lt;/p&gt;

&lt;p&gt;That would have been a false witness in both directions, because evalmut's own&lt;br&gt;
operators call the grader while deciding whether they apply. In this run those&lt;br&gt;
probes account for 59 calls. A naive counter would report "multiple calls" on&lt;br&gt;
healthy rows and would be satisfied just as happily by a probe on a row where the&lt;br&gt;
decision never happened.&lt;/p&gt;

&lt;p&gt;So every entry is attributed by its caller frame. The call on the decision line&lt;br&gt;
witnesses a decision. The call in the baseline witnesses the clean control. Anything&lt;br&gt;
from the operator module is a probe and satisfies neither. Anything else is&lt;br&gt;
unattributed and fails the row closed.&lt;/p&gt;

&lt;p&gt;A row counts as an outcome only when both forms were witnessed. Rows that cannot&lt;br&gt;
show it leave the denominator and are listed as incomplete with a reason. A&lt;br&gt;
population with zero witnessed rows reports null and renders unavailable. Not 1.0,&lt;br&gt;
which is safe arithmetic and unsafe evidence, and not 0.0, which reads as a measured&lt;br&gt;
failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number did not move
&lt;/h2&gt;

&lt;p&gt;42 of 46. Gated score identical to ungated. Zero incomplete rows, zero unattributed&lt;br&gt;
calls, and the 59 operator probes sorted into a bucket where they count for nothing.&lt;/p&gt;

&lt;p&gt;An unchanged number is a bad headline and a good result. If the witness had moved&lt;br&gt;
the score, I would have had a more exciting post and a worse tool, because it would&lt;br&gt;
have meant the old number was counting something other than what it said.&lt;/p&gt;

&lt;p&gt;What changed is not the value. Before, 42 rows carried a label. Now 42 rows carry a&lt;br&gt;
label backed by a recorded entry into a named callable and the verdict that came&lt;br&gt;
back out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The witness shipped with its own broken field
&lt;/h2&gt;

&lt;p&gt;Each witness site records a hash that claims to identify the grader implementation.&lt;br&gt;
I implemented it as a hash of compiled bytecode, which identifies bytecode on one&lt;br&gt;
interpreter instead.&lt;/p&gt;

&lt;p&gt;That cost two things. The committed artifact could not verify anywhere except the&lt;br&gt;
machine that produced it, because it was generated on CPython 3.14 and checked by a&lt;br&gt;
3.11 and 3.12 matrix. And worse, bytecode could not distinguish two different&lt;br&gt;
adversarial graders whose instructions match and whose closure constants differ, so&lt;br&gt;
88 witness sites carried one hash for two implementations. A provenance field that&lt;br&gt;
merges two implementations is not weaker evidence. It is wrong evidence.&lt;/p&gt;

&lt;p&gt;My first attempt at the portability half was to pin the dependency version in CI.&lt;br&gt;
That could not have worked: the grader source was byte-identical between my checkout&lt;br&gt;
and the published wheel. The variable was the interpreter. Pinning the dependency is&lt;br&gt;
the obvious fix and the wrong one.&lt;/p&gt;

&lt;p&gt;And the test that should have caught it had been red since the day it was written:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It is not a test that broke, it is a test that was born unable to pass, which&lt;br&gt;
produces the same red and generates none of the urgency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;CI failed on every run from 2026-08-20 and first went green again on 2026-08-30, at&lt;br&gt;
the re-emit that followed the fix. Ten days of red that looked exactly like ordinary&lt;br&gt;
red.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;Portability is argued, not proven. The source-text fingerprint should be interpreter&lt;br&gt;
independent, but only one Python is installed on my machine. The CI matrix is the&lt;br&gt;
proof and it fails on whichever leg disagrees, which is the right design and is not&lt;br&gt;
the same as me having verified it.&lt;/p&gt;

&lt;p&gt;The witness is a separate command. One subcommand gates rows and the other does not,&lt;br&gt;
and the README banner is still the ungated one. They agree on this suite. They are&lt;br&gt;
not the same claim.&lt;/p&gt;

&lt;p&gt;The stamped evidence bundle still carries the unwitnessed run. The witnessed&lt;br&gt;
artifact lives beside it and is not inside the sealed bundle yet.&lt;/p&gt;

&lt;p&gt;And one document in the repo currently contradicts another. The README says 91.3%&lt;br&gt;
with four holes. The paper draft still says 91.4% with three. That is mine to fix,&lt;br&gt;
not a rounding difference, and I am pointing at it here rather than quietly&lt;br&gt;
correcting it before anyone notices.&lt;/p&gt;

&lt;p&gt;The witness proves entry and return, and nothing else. It says nothing about whether&lt;br&gt;
the grader judged well. A witnessed row is a row whose numbers are about gradecore.&lt;br&gt;
That is the entire claim.&lt;/p&gt;

&lt;p&gt;If you publish a score from a harness that wraps someone else's library: ask what&lt;br&gt;
would still be true if the library were never called. If the answer is "the score",&lt;br&gt;
your harness is answering for it.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tool, the witness and the dogfood run: &lt;a href="https://github.com/egnaro9/evalmut" rel="noopener noreferrer"&gt;https://github.com/egnaro9/evalmut&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every figure above was checked against the repository before publication. The judgement of whether it was worth saying is mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Two eval scorers passed a wrong answer because it mentioned the right one</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Fri, 16 Oct 2026 22:35:35 +0000</pubDate>
      <link>https://dev.to/agentdev9/two-eval-scorers-passed-a-wrong-answer-because-it-mentioned-the-right-one-10c7</link>
      <guid>https://dev.to/agentdev9/two-eval-scorers-passed-a-wrong-answer-because-it-mentioned-the-right-one-10c7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-10-14-two-scorers-passed-a-wrong-answer/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Two eval scorers passed a wrong answer because it mentioned the right one
&lt;/h1&gt;

&lt;p&gt;The reply was "No. The passage does not support Yes." The target was Yes. Two of the three deterministic scorers that ship with Inspect AI marked it correct.&lt;/p&gt;

&lt;p&gt;I found that on Sunday night while adding a third external rubric to a tool I had been building for a week. The tool has one job: take a rubric, take material with known defects, and report whether the rubric can tell good work from bad. By that point it had already produced one finding each in promptfoo and OpenAI evals. Inspect was supposed to be the control, because its BoolQ task uses a scorer that is anchored at both ends and hard to fool.&lt;/p&gt;

&lt;p&gt;It is hard to fool. That was the finding. Here is the full table, ten real BoolQ validation rows, seven completions per row, five correct by intent and two wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scorer                   detects  false alarms  separation
pattern, as shipped      20/20    20/50         +0.60
includes()               10/20     0/50         +0.50
match(), location=end    10/20    10/50         +0.30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shipped scorer, &lt;code&gt;pattern(r"\A[\W_]*(Yes|No)[\W_]*\Z")&lt;/code&gt;, accepts nothing wrong. Its 40% false-alarm rate is every correct answer that disobeyed the prompt's "Include nothing else": "Yes, because the passage says so." is rejected, and so is "The passage supports it. Yes." That is a documented choice. The scorer records the reason as &lt;code&gt;invalid_response_format&lt;/code&gt; and charges it to the model.&lt;/p&gt;

&lt;p&gt;The other two are the library's general-purpose scorers, and they are what most custom tasks use. &lt;code&gt;includes()&lt;/code&gt; casefolds both strings and tests &lt;code&gt;target in completion&lt;/code&gt;, so a No that mentions Yes contains the target. &lt;code&gt;match()&lt;/code&gt; strips whitespace, casefolds, strips punctuation and tests &lt;code&gt;completion.endswith(target)&lt;/code&gt; at its default location, so a sentence that happens to end with the word passes. Nobody chose either of those. They are what a substring check and a suffix check do.&lt;/p&gt;

&lt;p&gt;Then the tool did something I did not expect. Its default coverage policy is strict: zero false alarms, and a checkpoint has to catch at least half the defective specimens. Under that policy &lt;code&gt;includes()&lt;/code&gt; is the scorer that counts as covering the defect class, and the shipped scorer is not. The policy did exactly what it says. It also made the point I keep relearning: whether a format rejection costs more than a substring hole is a decision, and a tool that prints only one column has made it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other two tools, one line each
&lt;/h2&gt;

&lt;p&gt;promptfoo's own airline example has a &lt;code&gt;not-contains "Seat updated to 16F"&lt;/code&gt; assertion on a test where the agent must refuse a third party. The assertion is case-sensitive. A refusal followed by &lt;code&gt;seat updated to 16f&lt;/code&gt; passes it, which is refusal-then-compliance passing the tool's own refusal test.&lt;/p&gt;

&lt;p&gt;OpenAI evals' &lt;code&gt;Match&lt;/code&gt; is &lt;code&gt;sampled.startswith(ideal)&lt;/code&gt;, verbatim from &lt;code&gt;evals/api.py&lt;/code&gt;. On eight real samples from the sarcasm eval: &lt;code&gt;"10"&lt;/code&gt; is accepted for ideal &lt;code&gt;"1"&lt;/code&gt;, and &lt;code&gt;" 1"&lt;/code&gt; with a leading space is rejected. Half the wrong answers in, a third of the right answers out, and both are the predicate rather than a statistic.&lt;/p&gt;

&lt;p&gt;None of these is a bug report. Each adapter reproduces the upstream line and does not fix it, because an adapter that quietly repaired the predicate would be measuring a rubric nobody runs.&lt;/p&gt;

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

&lt;p&gt;The tool is called deadcheck. It takes a rubric as code (a checkpoint is a predicate, so no file format can carry one), a specimen set with constructed ground truth (a clean base plus one injection, so each defect is known by construction rather than labelled by opinion), and reports three numbers per checkpoint: separation, which is detection rate minus false-alarm rate; coverage per defect class, measured against what the specimens carry and never against what the rubric claims; and the residual, the classes nothing catches, printed as a column rather than a footnote.&lt;/p&gt;

&lt;p&gt;Before any of that it runs a battery of naive probes over the material, including a deterministic coin flip, and prints the floor. A rubric that cannot beat the coin on a class has not measured that class.&lt;/p&gt;

&lt;p&gt;The part I trust most is the part that embarrassed me. The seed specimen pack has four specimens per class. The power check turns a detection rate into a Wilson interval and at 3 of 4 it reports [0.21, 0.94], which does not clear the bar. So the tool's own certificate for its own example rubric says UNDERPOWERED, the CI step that checks it is non-blocking and labelled as such, and the fix is sixteen specimens per class, hand-written. I left it red rather than widen the predicate's keyword list, because that is the overfit the same tool had already caught twice that week.&lt;/p&gt;

&lt;p&gt;The second embarrassment was cheaper. The first wheel built from the package shipped no adapters at all. The editable install imported them fine, so every test was green while the artifact a user would install was missing the module this whole post is about. There is now a CI step that installs the wheel into a clean venv and imports the adapters from outside the checkout, and it went red on the old package list before it went green on the new one.&lt;/p&gt;

&lt;p&gt;Domain specimen packs are not published. Ground truth is hand-made, does not parallelize, and is the part that takes real time to build. The tool, the taxonomy and the seed pack are public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break it
&lt;/h2&gt;

&lt;p&gt;The claim is that this certifies a rubric can fail. The only honest way to trust that is to make it certify something false. Four targets, in the order I would try them, with details in &lt;code&gt;BREAKING.md&lt;/code&gt; in the repo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Forge a certificate the registry accepts. It re-hashes every evidence file and recomputes every headline, and the last protocol in this family was forged in four bytes by an outside auditor.&lt;/li&gt;
&lt;li&gt;Score coverage without detecting anything: a predicate that clears the calibration floor on the seed pack while a reviewer reading it would call it blind.&lt;/li&gt;
&lt;li&gt;Evade a covered checkpoint with a mechanical rewrite that keeps the defect. The paraphrase number is already published; find one the attack suite calls 0%.&lt;/li&gt;
&lt;li&gt;Show that an adapter misreads its upstream. One counterexample voids the finding it supports, which is why it is on the list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A reproducible script in an issue is a finding. A finding gets a test named after it.&lt;/p&gt;

&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tool, the three adapters and the fixture: &lt;a href="https://github.com/egnaro9/deadcheck" rel="noopener noreferrer"&gt;https://github.com/egnaro9/deadcheck&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every figure above was checked against the repository before publication. The judgement of whether it was worth saying is mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>My multi-agent review panel was confidently wrong. I measured why.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:11:02 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-multi-agent-review-panel-was-confidently-wrong-i-measured-why-1ie7</link>
      <guid>https://dev.to/agentdev9/my-multi-agent-review-panel-was-confidently-wrong-i-measured-why-1ie7</guid>
      <description>&lt;p&gt;I run a lot of code review through agent fan-outs. Dozens of agents, each with a different lens, structured output, adversarial verification passes. The kind of pipeline that looks rigorous in a diagram.&lt;/p&gt;

&lt;p&gt;Last week it returned a confident, unanimous, wrong answer. I kept the transcripts, so I can tell you exactly what it cost and exactly what fixed it.&lt;/p&gt;

&lt;p&gt;The numbers are from one week of runs: &lt;strong&gt;158 agents across 18 workflows, 139 MB of transcript, roughly 28 million tokens.&lt;/strong&gt; About a third of that was avoidable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;A panel of three agents reviewed a technical claim and had to decide whether it was real and worth acting on. All three said no. Each wrote a detailed, specific, well-argued rejection.&lt;/p&gt;

&lt;p&gt;Their reasoning turned on a published standard that governed the decision. None of them could open it. The document sat behind a login, and the agents correctly refused to authenticate, which is the behavior I want. So they reasoned from the title, from search snippets, and from what a document with that title probably says.&lt;/p&gt;

&lt;p&gt;It says close to the opposite.&lt;/p&gt;

&lt;p&gt;I read it myself through an authenticated browser session. One tool call. The actual text answered, in its own worked example, the two specific objections all three agents had built their rejections on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That read cost about 2,000 tokens. It overturned about 1.2 million.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A 600 to 1 asymmetry, and it is the single most useful thing I did that week.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that is actually uncomfortable
&lt;/h2&gt;

&lt;p&gt;I re-ran the panel with the real document text. This time all three agents reversed and said yes.&lt;/p&gt;

&lt;p&gt;Good result, except for one thing: I wrote both briefings. The first one told them to be hostile and assume the claim was wrong. The second one quoted the standard and noted the previous panel had misread it.&lt;/p&gt;

&lt;p&gt;Same question. Same model. Same evidence. Opposite framings, opposite verdicts.&lt;/p&gt;

&lt;p&gt;So the second run was not a correction. It was me getting the answer I had already decided on, and three agents handing it back with citations. I caught it only because I happened to reread my own prompt and noticed I had written "read that last paragraph twice" above the paragraph I wanted them to weight.&lt;/p&gt;

&lt;p&gt;I killed that run and rewrote it: quote the source with no commentary, strip the &lt;code&gt;helps&lt;/code&gt; and &lt;code&gt;hurts&lt;/code&gt; labels off the evidence before passing it on, and add a required field asking each agent where the briefing is wrong.&lt;/p&gt;

&lt;p&gt;That version also said yes. But this time two of the three told me my briefing had overstated one constraint and buried a counterargument I should have surfaced. One pointed out I had handed them a favorable argument while omitting an unfavorable clause from the same document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is what a panel is for.&lt;/strong&gt; Agreement is cheap. The value is in the disagreement, and you only get it if you leave room for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure mode you are not watching for
&lt;/h2&gt;

&lt;p&gt;Partway through the week I hit a usage limit. Four agents died mid-run.&lt;/p&gt;

&lt;p&gt;That cost me nothing. The runtime printed a failures block. A one-line check over the journal confirmed it independently:&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="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json;ev=[json.loads(l) for l in open('&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/journal.jsonl') if l.strip()]
print('&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;',sum(1 for x in ev if x.get('type')=='started'),
            sum(1 for x in ev if x.get('type')=='result'))"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Started versus returned. If those numbers differ, something died. Loud failures are cheap because they are visible.&lt;/p&gt;

&lt;p&gt;The expensive failure was the agent that &lt;strong&gt;succeeded&lt;/strong&gt;. It completed normally, returned a well-structured result, and reported that it could not reach the document it needed. Then it reasoned from snippets anyway, and the agents downstream treated its output as settled. Nothing flagged it, because nothing had gone wrong in any way a harness can detect.&lt;/p&gt;

&lt;p&gt;An agent that dies tells you it died. An agent that guesses tells you nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix, which is nearly free
&lt;/h2&gt;

&lt;p&gt;I added one required field to every judging agent's output schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provenance: for each load-bearing claim, state READ (and what),
            EXECUTED (and what you ran), INFERRED, or BLOCKED.
            A verdict whose key premise is INFERRED must say so.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both models I tested comply with this well, and it costs almost nothing. It changes what you get back from confident to checkable.&lt;/p&gt;

&lt;p&gt;The best example came from a run where an agent marked a login-gated page &lt;strong&gt;BLOCKED&lt;/strong&gt;, listed precisely which figures it had taken on trust from an earlier agent without re-running them, and separated those from the facts it had verified at source itself. That agent's verdict was worth more than the three confident ones, because I could see where to push.&lt;/p&gt;




&lt;h2&gt;
  
  
  The clean negative that was the most thorough run
&lt;/h2&gt;

&lt;p&gt;After the framing result I wanted a harder measurement, so I set up a blind test. This is the same method I use for grading eval suites: take material where the answer is already established, hide the answer, and see what the instrument actually detects. A detection rate you measure beats a detection rate you assert.&lt;/p&gt;

&lt;p&gt;I locked six agents to a source tree, put every file containing prior analysis out of reach, and gave all six an identical prompt that never said what to look for.&lt;/p&gt;

&lt;p&gt;Three of the six surfaced the underlying mechanism. The thing that actually mattered was two separate observations that only become significant once you put them together.&lt;/p&gt;

&lt;p&gt;My first pass at scoring said none of the six made that connection. That was wrong, and it was wrong for an embarrassing reason: I graded their output with a keyword search instead of reading it. When I read it, two of the three had made the connection explicitly. One wrote that a step in its third finding was "the exact precondition finding 1 needs." Another wrote that the same step "is what finding 1 and point (a) above both exploit."&lt;/p&gt;

&lt;p&gt;Then each of them filed that observation as a sub-clause of a minor finding and moved on.&lt;/p&gt;

&lt;p&gt;So the failure was not detection, and it was not even composition. It was &lt;strong&gt;re-scoring&lt;/strong&gt;. Both agents found the link, wrote it down in plain language, and then let the combined thing inherit the low severity of its parts instead of asking what the combination was worth on its own. The interesting result is not that agents miss connections. It is that they can find a connection and still not update on it.&lt;/p&gt;

&lt;p&gt;My scoring error is the same shape: a check that returned a confident answer for the wrong reason, and only reading the underlying material caught it. That is three times in one week, which is roughly the point of this whole piece.&lt;/p&gt;

&lt;p&gt;The run I keep thinking about returned nothing at all.&lt;/p&gt;

&lt;p&gt;That agent did the most careful work of the six. It built the project, ran the existing 391 tests, extracted every reference document in the repository, and checked each of its candidates against them. It correctly matched the pieces it found to issues that were already documented and accepted. Then it concluded, reasonably, that nothing new was there.&lt;/p&gt;

&lt;p&gt;Its own notes include a sentence describing the exact lines the real problem lives on, and recording them as safe.&lt;/p&gt;

&lt;p&gt;It was not careless. It was following my instruction to treat the list of already-known issues as a hard filter, and it followed that instruction more faithfully than the agents that got closer. Each documented issue described one mechanism. Each carried an exception for a case its own mechanism never reaches. The combination reaches it.&lt;/p&gt;

&lt;p&gt;So my filter was wrong, not the agent. "Does this match something already known" discards the combination that escapes what is known. The question has to be "does this match, and does any combination of these candidates reach the exception that the known item itself states."&lt;/p&gt;

&lt;p&gt;The general version: &lt;strong&gt;when you give agents a filter, you are also telling them what to throw away, and a careful agent will throw away more.&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;Seven rules, in the order they run:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the governing documents yourself, before any agent starts.&lt;/strong&gt; Whatever the decision actually turns on: the spec, the standard, the policy, the config. Hand one facts file to every agent so none of them re-derive it, and none of them guess. If it is behind a login, that is a human's job, and it is one tool call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap checks before expensive ones.&lt;/strong&gt; My pipeline built full reproductions and then discovered, late, that two candidates died on questions costing one lookup each. Now the cheap disqualifying questions run first, on everything, before anything expensive runs on anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never brief a panel toward an answer.&lt;/strong&gt; Quote sources without commentary. Strip verdict-shaped labels off evidence before passing it downstream. Require a &lt;code&gt;disagreements&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap exploratory work on elapsed time or error rate, not on completion.&lt;/strong&gt; One probe ran 99 minutes and accumulated 465 errors before dying on rate limits, chasing something an earlier probe had already ruled out structurally. Nothing stopped it, because nothing was watching anything except whether it had finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a composition pass, and re-score what it finds from scratch.&lt;/strong&gt; One agent, not a fan-out, asking of the whole candidate list: does any combination here add up to something none of them is alone, and what is that combination worth judged on its own rather than inherited from its parts. The second half is the one that failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit your filters.&lt;/strong&gt; For every filter you hand an agent, write down what that filter throws away, then go and look at the discard pile once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not grade agents with a regex.&lt;/strong&gt; I scored a detection test with a keyword search and got the headline result backwards. Read the output.&lt;/p&gt;




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

&lt;p&gt;One week. One pipeline. One domain. I measured transcript volume and token counts, not a controlled experiment, and the framing result is n equals 3 on a single question.&lt;/p&gt;

&lt;p&gt;What I am confident about is narrower than it sounds: &lt;strong&gt;when a panel of agents agrees, and one person wrote all the briefings, the agreement is evidence about the briefing.&lt;/strong&gt; Everything else above is a consequence of that.&lt;/p&gt;

&lt;p&gt;The deeper version of this is not new. A check that cannot fail is not a check. We know this about CI gates, where a required job satisfied by a skipped step passes review for years because nobody asked it to prove it could go red. We know it about test suites. It is just as true of a review panel, and it is harder to see, because the output is fluent, structured, and arrives in triplicate.&lt;/p&gt;

&lt;p&gt;If you run agent fan-outs, go and read your own prompts. Look for the sentence where you told them what you wanted to hear.&lt;/p&gt;

&lt;p&gt;I found mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>I wrote down which test would catch each bug. The one aimed straight at it was blind.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:53:27 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-wrote-down-which-test-would-catch-each-bug-the-one-aimed-straight-at-it-was-blind-2pgk</link>
      <guid>https://dev.to/agentdev9/i-wrote-down-which-test-would-catch-each-bug-the-one-aimed-straight-at-it-was-blind-2pgk</guid>
      <description>&lt;p&gt;I have three checks over the same game engine and they all look like determinism checks.&lt;br&gt;
That raises a question the checks cannot answer about themselves: do they cover different&lt;br&gt;
failure modes, or the same one three times?&lt;/p&gt;

&lt;p&gt;So I wrote down, before running anything, which check would catch which bug. Then I&lt;br&gt;
injected the bugs.&lt;/p&gt;

&lt;p&gt;Two rows came out as predicted. The third did not, and the third is the only one worth&lt;br&gt;
writing about.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three checks that look alike
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;S&lt;/strong&gt;, &lt;code&gt;theSameSeedProducesTheSameRun&lt;/code&gt;: runs the engine twice in one runtime, compares.&lt;br&gt;
&lt;strong&gt;G&lt;/strong&gt;, &lt;code&gt;ParityTraceTest&lt;/code&gt;: asserts a 600-frame, 11-checkpoint trace against a committed&lt;br&gt;
golden file.&lt;br&gt;
&lt;strong&gt;D&lt;/strong&gt;, &lt;code&gt;:web:parityCheck&lt;/code&gt;: diffs the JVM trace against the shipped TeaVM artifact's trace.&lt;/p&gt;

&lt;p&gt;And a fourth column as a footnote, &lt;strong&gt;U&lt;/strong&gt;, &lt;code&gt;InterstitialCadenceTest&lt;/code&gt;: a plain unit test on&lt;br&gt;
how often an interstitial cue fires.&lt;/p&gt;

&lt;p&gt;Context: this is the rules engine of a small Android game, compiled a second time to the&lt;br&gt;
browser with TeaVM. Same Java, two compilers. The determinism story is &lt;a href="https://dev.to/agentdev9/my-determinism-test-passed-for-months-while-the-two-builds-played-different-games-1if4"&gt;its own piece&lt;/a&gt;; this one is about the checks that came out of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Real patches, real rebuilds, real exit codes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;matrix/run_matrix.sh&lt;/code&gt;, 134 lines: apply patch, rebuild &lt;strong&gt;both&lt;/strong&gt; artifacts, run S, G, D and&lt;br&gt;
U independently, revert under a trap.&lt;/p&gt;

&lt;p&gt;They have to run separately, because &lt;code&gt;:web:parityCheck&lt;/code&gt; declares &lt;code&gt;dependsOn :engine:test&lt;/code&gt;.&lt;br&gt;
A golden failure would stop the differential from ever running, and then I could not&lt;br&gt;
attribute the columns: a blank cell would mean "did not run" rather than "did not fire".&lt;br&gt;
&lt;code&gt;ParityTraceTest&lt;/code&gt; writes its traces before it asserts, which is what makes the split&lt;br&gt;
possible.&lt;/p&gt;

&lt;p&gt;Every cell below is an exit status, not a judgement.&lt;/p&gt;

&lt;p&gt;Three faults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F1&lt;/strong&gt;: &lt;code&gt;Rng&lt;/code&gt; delegates to &lt;code&gt;java.util.Random&lt;/code&gt; instead of the written-out LCG. On the JVM
that is behaviour-identical, because it &lt;em&gt;is&lt;/em&gt; the documented algorithm. TeaVM's
implementation is not the JDK's. This is the original bug, reproduced from source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F2&lt;/strong&gt;: a shared constant, &lt;code&gt;PLAYER_BOTTOM_MARGIN&lt;/code&gt;, 120f to 121f. Both builds compile it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F3&lt;/strong&gt;: &lt;code&gt;INTERSTITIAL_EVERY&lt;/code&gt;, 3 to 4. Behaviour the trace did not record.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The first run
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;fault&lt;/th&gt;
&lt;th&gt;S&lt;/th&gt;
&lt;th&gt;G&lt;/th&gt;
&lt;th&gt;D&lt;/th&gt;
&lt;th&gt;U&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1 runtime-supplied RNG&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F2 shared constant&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F3 untraced cadence&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pass, predicted FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the two that worked first, because they are the boring half and they are the reason&lt;br&gt;
the exercise was worth doing.&lt;/p&gt;

&lt;p&gt;F1 is caught &lt;strong&gt;only&lt;/strong&gt; by the differential. S compares the JVM to itself, and the golden&lt;br&gt;
file's JVM side never moves, so neither can see a fault that only changes the browser.&lt;/p&gt;

&lt;p&gt;F2 is caught &lt;strong&gt;only&lt;/strong&gt; by the golden. Both builds move together, so the differential sees&lt;br&gt;
two artifacts agreeing perfectly on the wrong number.&lt;/p&gt;

&lt;p&gt;Neither check subsumes the other. Good.&lt;/p&gt;
&lt;h2&gt;
  
  
  The row I got wrong
&lt;/h2&gt;

&lt;p&gt;F3 moving nothing in S, G or D was half expected. The trace recorded frame, score, streak,&lt;br&gt;
run state, player position and obstacles, but not &lt;code&gt;runsSinceInterstitial&lt;/code&gt;. Blind by schema.&lt;br&gt;
Fine.&lt;/p&gt;

&lt;p&gt;The half I did not expect was column U. &lt;code&gt;InterstitialCadenceTest&lt;/code&gt; exists for exactly this&lt;br&gt;
behaviour. It passed.&lt;/p&gt;

&lt;p&gt;Its assertions referenced &lt;code&gt;GameEngine.INTERSTITIAL_EVERY&lt;/code&gt; on eight lines. When the fault&lt;br&gt;
moved that constant from 3 to 4, the test's expectation moved with it. It looped&lt;br&gt;
&lt;code&gt;for (run = 1; run &amp;lt; INTERSTITIAL_EVERY; run++)&lt;/code&gt; and then asserted "no cue after&lt;br&gt;
INTERSTITIAL_EVERY runs". Both halves defined by the thing under test.&lt;/p&gt;

&lt;p&gt;It checked the predicate against the constant, not against an answer key. A test written&lt;br&gt;
that way cannot notice the constant changing, which is the only interesting way that&lt;br&gt;
constant can be wrong.&lt;/p&gt;

&lt;p&gt;So F3 had no catching check at all. Three trace-based checks blind by schema, and the one&lt;br&gt;
test aimed directly at the behaviour blind because it had no independent expectation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fixing it, and the part I nearly got wrong twice
&lt;/h2&gt;

&lt;p&gt;The obvious fix is both halves: give the test a literal answer key, and put the field in the&lt;br&gt;
trace.&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="c1"&gt;// before: expectation derived from the thing under test&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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;GameEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INTERSTITIAL_EVERY&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after: expectation is an answer key&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;final&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="no"&gt;CUE_AFTER_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test now mentions &lt;code&gt;INTERSTITIAL_EVERY&lt;/code&gt; exactly once, in a comment explaining why it must&lt;br&gt;
not use it. The trace gained &lt;code&gt;runsSinceInterstitial&lt;/code&gt; on both sides of the differential.&lt;/p&gt;

&lt;p&gt;Here is the part worth the whole article. Before re-running, I wrote down what I expected:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;F3 flips to U=FAIL only, and G and D stay green. The scripted run dies once, so the&lt;br&gt;
counter reads 0 then 1 and never approaches any threshold. Recording it cannot make the&lt;br&gt;
trace-based checks see a threshold change the run never exercises.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That prediction is the interesting one, because the naive expectation is the opposite. I had&lt;br&gt;
just added the missing field to the trace. Surely now the trace-based checks can see it?&lt;/p&gt;

&lt;h2&gt;
  
  
  The re-run
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;fault&lt;/th&gt;
&lt;th&gt;S&lt;/th&gt;
&lt;th&gt;G&lt;/th&gt;
&lt;th&gt;D&lt;/th&gt;
&lt;th&gt;U&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1 runtime-supplied RNG&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F2 shared constant&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F3 untraced cadence&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prediction held. U fires. G and D stayed green &lt;strong&gt;even though the field they were&lt;br&gt;
missing is now in the trace&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because the scripted run only ever dies once. The counter reads 0, then 1. Changing the&lt;br&gt;
threshold from 3 to 4 cannot move a number that never gets near either value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a check is built blind
&lt;/h2&gt;

&lt;p&gt;The first run showed two. The second showed the third, and the third only became visible&lt;br&gt;
after the first two were fixed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The schema.&lt;/strong&gt; A trace cannot report a field it does not carry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The expectation.&lt;/strong&gt; If the expected value in an assertion is imported from the code
under test, the assertion measures internal consistency, not correctness. Grep your suite
for assertions that reference the constant they are testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The input plan.&lt;/strong&gt; A check is only sensitive to behaviour its inputs actually exercise.
Coverage of a field is not coverage of a behaviour. This is the one I would have missed
entirely if I had stopped at the first fix and declared the residual closed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The caveats this needs, in the body rather than a footer
&lt;/h2&gt;

&lt;p&gt;Three faults is not a coverage measurement. It is three worked examples chosen to separate&lt;br&gt;
four checks. A real matrix would keep going until it stopped finding new residual rows, and&lt;br&gt;
mine stopped when I ran out of faults I could think of.&lt;/p&gt;

&lt;p&gt;The predictions were written in a commit message and a chat log, not a pre-registration&lt;br&gt;
anyone can audit. Take the first miss as the evidence they were genuinely made in advance,&lt;br&gt;
since it is the row I would have quietly dropped otherwise.&lt;/p&gt;

&lt;p&gt;And the defect was in a test, not in shipped behaviour. The game's cadence is correct. What&lt;br&gt;
shipped broken was the confidence.&lt;/p&gt;

&lt;p&gt;A matrix without a residual row is advertising. The row where nothing fires is the&lt;br&gt;
deliverable.&lt;/p&gt;




&lt;p&gt;Harness, patches and full output: github.com/egnaro9/tapdodge-engine, &lt;code&gt;matrix/&lt;/code&gt;&lt;br&gt;
Write-up: &lt;code&gt;docs/field-note-fault-matrix.md&lt;/code&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>java</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>I shipped a linter for checks that cannot fail. It had four of them.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-shipped-a-linter-for-checks-that-cannot-fail-it-had-four-of-them-50da</link>
      <guid>https://dev.to/agentdev9/i-shipped-a-linter-for-checks-that-cannot-fail-it-had-four-of-them-50da</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where this landed, 9 October 2026.&lt;/strong&gt; This post opens with 40%. That number is not&lt;br&gt;
wrong so much as it is not the quantity you would take it for, and I only found that out&lt;br&gt;
by being argued with in the comments. It has taken a week of measuring to say what it&lt;br&gt;
should have been, and the tool has changed more than the number has. This block replaces&lt;br&gt;
the two corrections that were stacked here, because three corrections in a row is not&lt;br&gt;
honesty, it is sediment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sampling was measuring the wrong unit.&lt;/strong&gt; The samples behind every figure were&lt;br&gt;
stratified with a cap of two findings per repository, to stop one repo dominating. But&lt;br&gt;
the tool emits one finding per (job, upstream) pair, and the false positives cluster in&lt;br&gt;
jobs with many dependencies: one reporting job produced eleven by itself. The cap deletes&lt;br&gt;
exactly that clustering. Simulated against a population I later labelled completely, the&lt;br&gt;
capped sampler is an &lt;strong&gt;unbiased estimator of the per-job rate and runs about 16 points&lt;br&gt;
low for the per-finding rate&lt;/strong&gt;. So every percentage in this post understates, all in the&lt;br&gt;
same direction. &lt;a href="https://dev.to/arhancanli"&gt;@arhancanli&lt;/a&gt; called this in the thread before&lt;br&gt;
I measured it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the numbers are now, all hand-labelled, no estimate of an estimate.&lt;/strong&gt; Four&lt;br&gt;
detectors are two. D4 went on 0 defensible of 18 sampled, D2 on 0 true of 27 &lt;strong&gt;censused&lt;/strong&gt;.&lt;br&gt;
Corpus output went 1986 findings to 1026.&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;basis&lt;/th&gt;
&lt;th&gt;false&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;D3, all 653&lt;/td&gt;
&lt;td&gt;complete census, every finding read&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;36.1%&lt;/strong&gt; &lt;code&gt;[32.5, 39.9]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;gated on &lt;code&gt;--repo&lt;/code&gt;, and &lt;strong&gt;0&lt;/strong&gt; findings with it&lt;/td&gt;
&lt;td&gt;see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The severity tiers are gone, and that is the result I did not expect.&lt;/strong&gt; Censusing all&lt;br&gt;
three strata gave HIGH 30.9%, LOW 31.6%, MEDIUM 52.8%. HIGH's interval overlaps the&lt;br&gt;
merged rest. The only real separation is MEDIUM being &lt;em&gt;worse&lt;/em&gt;, and it came from the&lt;br&gt;
severity function short-circuiting on whether the workflow runs on &lt;code&gt;pull_request&lt;/code&gt; before&lt;br&gt;
it looked at the finding at all. &lt;strong&gt;The axis measured the trigger, not correctness.&lt;/strong&gt; It&lt;br&gt;
hid the better findings behind &lt;code&gt;--all&lt;/code&gt; and shouted the worse ones, so collapsing it&lt;br&gt;
raised the default output from 238 findings to 653 and &lt;em&gt;lowered&lt;/em&gt; the false rate a reader&lt;br&gt;
sees, from 44.1% to 36.1%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D1 is gated rather than removed, and the gate reports nothing.&lt;/strong&gt; Its text says a&lt;br&gt;
skipped upstream leaves the dependent running without it; &lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idneeds" rel="noopener noreferrer"&gt;GitHub documents that the&lt;br&gt;
dependent is skipped too&lt;/a&gt;.&lt;br&gt;
68% of its findings are that case. So it now says nothing&lt;br&gt;
unless &lt;code&gt;--repo&lt;/code&gt; confirms the job is a required check. Then I ran that against live branch&lt;br&gt;
protection for all 373 findings: 10 resolved to REQUIRED, all in one repository, and all&lt;br&gt;
10 vanish on a complete checkout where the tool can read the script that job calls. &lt;strong&gt;On&lt;br&gt;
275 repositories, &lt;code&gt;--repo&lt;/code&gt; has never reported a finding.&lt;/strong&gt; It is kept because the gate is&lt;br&gt;
correct, not because it is productive, and that distinction is the whole post in one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three numbers I published and had to take back,&lt;/strong&gt; every one caught by checking my own&lt;br&gt;
claim rather than by anyone complaining. The "65% false" I quoted for D1 came from a&lt;br&gt;
census covering 12% of the population it was quoted against. The "37.0%, 245 of 662" in&lt;br&gt;
my own 0.3.0 release notes added a stratum censused at &lt;strong&gt;153&lt;/strong&gt; to a population taken&lt;br&gt;
after a suppression had already removed 9 of them; every commit since emits 653. And the&lt;br&gt;
&lt;strong&gt;54.9%&lt;/strong&gt; in the table that used to sit right here kept 3 findings labelled true from a&lt;br&gt;
sample taken &lt;em&gt;before&lt;/em&gt; the insight that refutes them, which is holding both positions at&lt;br&gt;
once: I shipped a suppression on the grounds that &lt;code&gt;cmd | grep -q X&lt;/code&gt; is not a defect&lt;br&gt;
while still counting three of those lines as defects. The arithmetic carries its own&lt;br&gt;
check, and I had not run it. The census was 68 true and 85 false of 153, so removing 9&lt;br&gt;
false findings has to leave true at 68, and 68 + 76 = 144. A correction is a new claim&lt;br&gt;
and needs verifying as hard as the original.&lt;/p&gt;

&lt;p&gt;The part of this post I would still defend is the part about taste: the corpus average&lt;br&gt;
hid the problem, and pointing the tool at two repositories I respected is what found it.&lt;br&gt;
The part I would not defend is any specific percentage in it. The working is in the&lt;br&gt;
comment thread, including the fixes measurement killed before they shipped. The latest&lt;br&gt;
was a filter that genuinely improved precision, 31% false down to 17%, and I deleted it&lt;br&gt;
anyway: it bought those 14 points by discarding two thirds of the &lt;strong&gt;true&lt;/strong&gt; findings, and&lt;br&gt;
on that population it turned out to be a shape classifier wearing a semantic test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yesterday I published a tool called deadgate to PyPI. It reads GitHub Actions workflows and finds checks that cannot fail: a required gate satisfied by a skipped job, an exit status swallowed by a pipe, a fan-in job that never reads whether its upstream passed.&lt;/p&gt;

&lt;p&gt;Then I pointed it at &lt;a href="https://github.com/Arize-ai/openinference" rel="noopener noreferrer"&gt;Arize-ai/openinference&lt;/a&gt;, 1.2k stars, 22 workflows. It reported 13 HIGH findings.&lt;/p&gt;

&lt;p&gt;Twelve of them were false.&lt;/p&gt;

&lt;p&gt;Not borderline. Not "technically true but low impact." Their CI was correct and my tool could not see it. The version doing the reporting had been on PyPI for about an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it could not see
&lt;/h2&gt;

&lt;p&gt;GitHub's documented way to ask "did anything upstream fail" is the wildcard form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then exit 1; fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My detector tested for that with &lt;code&gt;needs\.[A-Za-z0-9_-]+\.(result|outputs)&lt;/code&gt;, which cannot match &lt;code&gt;*&lt;/code&gt;. So the one spelling the documentation recommends was the one spelling the tool was blind to, and every correctly written fan-in gate came back as a gate that cannot fail.&lt;/p&gt;

&lt;p&gt;The finding's own message read "never reads needs.*.result" while failing to match that exact string.&lt;/p&gt;

&lt;p&gt;I fixed it, shipped 0.1.1, and pointed the fixed version at &lt;a href="https://github.com/astral-sh/ruff" rel="noopener noreferrer"&gt;astral-sh/ruff&lt;/a&gt;. Twenty-one HIGH findings. Eighteen false. Ruff writes its gate a third way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NEEDS_JSON&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ toJSON(needs) }}&lt;/span&gt;
&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;failing=$(echo "$NEEDS_JSON" | jq -r 'to_entries[]&lt;/span&gt;
    &lt;span class="s"&gt;| select(.value.result != "success" and .value.result != "skipped")')&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads every upstream at once and the word &lt;code&gt;result&lt;/code&gt; never appears in any workflow expression. There is nothing for my pattern to match.&lt;/p&gt;

&lt;p&gt;The fourth one was not a spelling at all. scikit-learn has a job that calls a reusable workflow, so it has &lt;code&gt;uses:&lt;/code&gt; and no &lt;code&gt;steps:&lt;/code&gt;, and it passes &lt;code&gt;needs.check-sdist.result&lt;/code&gt; through job-level &lt;code&gt;with:&lt;/code&gt;. That expression was one I already understood perfectly. It just lived somewhere I was not looking, because I scanned step-level &lt;code&gt;with&lt;/code&gt; and not job-level &lt;code&gt;with&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Four classes. The first two shipped to PyPI. I found the third and fourth only because I kept pointing the "fixed" version at new repositories instead of declaring victory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I think is actually generalizable
&lt;/h2&gt;

&lt;p&gt;Here is the number that matters, measured on a 275 repository, 4543 file corpus with both versions reading identical bytes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;th&gt;findings&lt;/th&gt;
&lt;th&gt;HIGH&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before&lt;/td&gt;
&lt;td&gt;4562&lt;/td&gt;
&lt;td&gt;1104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after&lt;/td&gt;
&lt;td&gt;2917&lt;/td&gt;
&lt;td&gt;658&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Forty percent of HIGH findings were false. That sounds bad, and it undersells it, because 40% is an average and the errors were not spread evenly.&lt;/p&gt;

&lt;p&gt;Only 15 of those 275 repositories use the wildcard idiom at all. The false positives piled onto the repositories that write their gates carefully. On openinference the false rate was 92%. On ruff, 86%. On a repository with no fan-in gate at all, it was zero, because there was nothing to misread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My linter was least accurate on the best code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a tool is noisy on bad code, the noise and the signal arrive together and you read both. If it is noisy specifically on good code, the people who did the work correctly are the ones punished, and the obvious response is to stop running it. A corpus average cannot surface that, because the corpus is mostly ordinary repositories where the tool happens to be right.&lt;/p&gt;

&lt;p&gt;I had the corpus. I ran it. It said 40%, and I would have shipped that as the headline. What actually found this was pointing the tool at two repositories whose CI I respected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mutation that survived
&lt;/h2&gt;

&lt;p&gt;To stop reporting jobs whose failure a gate already catches, I walked the needs graph transitively: if a gate covers &lt;code&gt;ci&lt;/code&gt;, and &lt;code&gt;ci&lt;/code&gt; needs &lt;code&gt;changes&lt;/code&gt;, then &lt;code&gt;changes&lt;/code&gt; is covered too.&lt;/p&gt;

&lt;p&gt;I mutation tested the fix, which for me means planting the defect each test claims to catch and checking that something goes red. One mutation survived: the one that &lt;strong&gt;deleted&lt;/strong&gt; my transitive walk.&lt;/p&gt;

&lt;p&gt;My first instinct was a missing test. It was not. The mutation survived because the walk was wrong: &lt;code&gt;needs.*.result&lt;/code&gt; reports only direct needs, and a failed grandparent makes the parent &lt;em&gt;skip&lt;/em&gt;, and skipped is not failure, so the gate never sees it. Walking transitively would have suppressed real findings.&lt;/p&gt;

&lt;p&gt;A surviving mutant has two meanings and they are easy to confuse. Either a test is missing, or the code does nothing. If I had written a test to kill it, I would have pinned a bug in place and called it coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small one that is somehow the worst
&lt;/h2&gt;

&lt;p&gt;While fixing all this I noticed five of my repositories advertised a stale test count in the README. I corrected them by hand and added a CI check so they could not drift again.&lt;/p&gt;

&lt;p&gt;The check went red immediately. On a count I had just "corrected."&lt;/p&gt;

&lt;p&gt;I had changed agent-graph's README from 42 to 58 and written a public commit message saying the previous commit overstated the count at 67. CI collects 67. My local venv was missing an optional extra, and &lt;code&gt;tests/test_rag.py&lt;/code&gt; opens with &lt;code&gt;pytest.importorskip&lt;/code&gt;, so nine tests vanished at collection with no error, no warning, and no mention of the file. pytest said "58 tests collected" and meant it.&lt;/p&gt;

&lt;p&gt;The original number was right. I corrected a correct claim, publicly, using a measurement from an environment that was not the real one.&lt;/p&gt;

&lt;p&gt;The check I shipped an hour earlier had the same blind spot and agreed with 58. It now fails when any test file on disk contributes zero collected tests, because that is the only evidence an environment is incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not showing
&lt;/h2&gt;

&lt;p&gt;The sweeps above are driven by my own harness, and the agent scaffolding, gate implementations and prompts behind it are not in the repository and are not going in it. deadgate itself is small and MIT and you can read all of it. The thing that pointed it at 275 repositories and classified the survivors is the part I am keeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I actually have
&lt;/h2&gt;

&lt;p&gt;I do not know how to test a linter for this class of error without a sample of good code, and "good code" is not a property I can detect automatically. A corpus gives you volume and volume gives you the average, which is exactly the statistic that hid this.&lt;/p&gt;

&lt;p&gt;The only thing that worked was taste: picking two repositories I already believed were well engineered and reading the output by hand. That does not scale and it is not reproducible, and I would like to be talked out of it.&lt;/p&gt;

&lt;p&gt;If you maintain a static analysis tool, how do you measure your false positive rate on the code that is &lt;em&gt;already correct&lt;/em&gt;? I would rather hear that I am missing something obvious than keep doing it by hand.&lt;/p&gt;

&lt;p&gt;deadgate is at &lt;a href="https://pypi.org/project/deadgate/" rel="noopener noreferrer"&gt;pypi.org/project/deadgate&lt;/a&gt; and &lt;a href="https://github.com/egnaro9/deadgate" rel="noopener noreferrer"&gt;github.com/egnaro9/deadgate&lt;/a&gt;, MIT, no model in the loop.&lt;/p&gt;

&lt;p&gt;I work on AI evaluation and testing, open to relocation above $120K, portfolio at &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>cicd</category>
      <category>python</category>
    </item>
    <item>
      <title>The gate could not see what it was gating</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 03 Oct 2026 14:37:55 +0000</pubDate>
      <link>https://dev.to/agentdev9/the-gate-could-not-see-what-it-was-gating-5f3p</link>
      <guid>https://dev.to/agentdev9/the-gate-could-not-see-what-it-was-gating-5f3p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-10-02-the-gate-could-not-see-what-it-was-gating/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  The gate could not see what it was gating
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about my own tooling. The gate it describes is mine, and so is the hole.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I keep a pre-commit hook on my portfolio repo that refuses a commit touching a&lt;br&gt;
claim-bearing file unless a receipts file carries a receipt for it. It never judges&lt;br&gt;
whether a claim is true. It checks that a receipt exists and that its pointer&lt;br&gt;
resolves: a local path has to exist, an https pointer has to answer 2xx. Prose alone&lt;br&gt;
cannot satisfy it.&lt;/p&gt;

&lt;p&gt;For four days, any file that had been reviewed once was exempt from review forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defect is one line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; ::"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;head -1&lt;/code&gt; on a pathname. The first line in the receipts file that mentioned the path&lt;br&gt;
won, whatever it said and whenever it was written. A receipt recorded for an earlier&lt;br&gt;
version of a file kept satisfying every later version of it.&lt;/p&gt;

&lt;p&gt;So the gate reported success along a path where it never examined the staged&lt;br&gt;
content. That is the precise failure I build tooling to find, sitting in the tooling&lt;br&gt;
I built to find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;claim&lt;/th&gt;
&lt;th&gt;how it was checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the defect line existed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git show 47f82a1^:.githooks/pre-commit&lt;/code&gt; line 61, the &lt;code&gt;head -1&lt;/code&gt; shown above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the window was four days&lt;/td&gt;
&lt;td&gt;the hook's full history is four commits: installed &lt;code&gt;c9af511&lt;/code&gt; 2026-08-23, fixed &lt;code&gt;47f82a1&lt;/code&gt; 2026-08-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;selection is now by blob&lt;/td&gt;
&lt;td&gt;the fix reads &lt;code&gt;BLOB=$(git rev-parse ":$f")&lt;/code&gt; and greps &lt;code&gt;":: blob $BLOB"&lt;/code&gt;, so line order decides nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a stale receipt is now refused by name&lt;/td&gt;
&lt;td&gt;the hook prints &lt;code&gt;no receipt carries the staged blob $BLOB&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;six tests did not catch it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tools/test_claim_receipt_blob.py&lt;/code&gt; has 7 tests; the seventh is the one that kills the mutant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I re-ran the mutant while writing this&lt;/td&gt;
&lt;td&gt;baseline 7 passed. Truncating the comparison to &lt;code&gt;${BLOB:0:4}&lt;/code&gt;: 1 failed, 6 passed, and the failure is &lt;code&gt;test_a_blob_prefix_is_not_a_blob&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What found it, and what did not
&lt;/h2&gt;

&lt;p&gt;I wrote six tests for the fix. A receipt for the previous blob is refused. The&lt;br&gt;
refusal names the blob. A legacy path-only receipt satisfies nothing. An honest&lt;br&gt;
receipt passes. A stale line sitting first does not shadow a correct one below it. A&lt;br&gt;
stale override is refused too, because an escape hatch that never expires is the&lt;br&gt;
same hole.&lt;/p&gt;

&lt;p&gt;All six passed. Then I mutated the guard: compare only the blob's first four hex&lt;br&gt;
characters.&lt;/p&gt;

&lt;p&gt;All six still passed. The hole was still open.&lt;/p&gt;

&lt;p&gt;The seventh test is the one that kills that mutant, and its docstring says so:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Found by mutation, not by inspection: truncating the comparison to the blob's&lt;br&gt;
first four hex characters survived every other test in this file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Six is not a coverage number. It is how many tests I wrote before I felt finished,&lt;br&gt;
and my sense of finished ran out exactly one test before the bug did.&lt;/p&gt;

&lt;h2&gt;
  
  
  I had already written the symptom down and filed it wrong
&lt;/h2&gt;

&lt;p&gt;Two days before the fix, in a different commit, I described this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;passing needed two receipts for one file in a specific order, because the generic&lt;br&gt;
loop reads the first matching line only. That ordering was load-bearing and&lt;br&gt;
recorded nowhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I noticed that line order was deciding outcomes. I filed it as a documentation gap.&lt;/p&gt;

&lt;p&gt;It was the bug. Inspection had the evidence in hand and did not convert it into a&lt;br&gt;
failing test, because inspection asks what I believe the code does. Mutation does&lt;br&gt;
not ask. It breaks the code and reports which assertion noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;The hook's own header states the residual and I am not going to soften it: &lt;code&gt;git&lt;br&gt;
commit --no-verify&lt;/code&gt; skips every client-side hook, so no local hook is a complete&lt;br&gt;
policy boundary. This gate stops an accident. It does not stop a decision.&lt;/p&gt;

&lt;p&gt;I know the window and not the damage. Four days is a fact about dates. I did not go&lt;br&gt;
back and check whether any claim actually shipped through the hole while it was&lt;br&gt;
open, so I cannot tell you the blast radius, only its duration.&lt;/p&gt;

&lt;p&gt;The receipts file is gitignored, so my own migration state is not independently&lt;br&gt;
checkable by a reader. Take that part on my word or leave it.&lt;/p&gt;

&lt;p&gt;And the mutation was hand-applied by me to a shell script, which means the operator&lt;br&gt;
set is whatever I happened to think of. That is the same limitation that produced&lt;br&gt;
the six-test blind spot in the first place, one level up.&lt;/p&gt;

&lt;p&gt;If you have run mutation testing against your &lt;em&gt;guard&lt;/em&gt; code rather than your product&lt;br&gt;
code, I would like to know what it killed that you were sure was fine.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The gate, its tests and the fix: &lt;a href="https://github.com/egnaro9/egnaro9.github.io" rel="noopener noreferrer"&gt;https://github.com/egnaro9/egnaro9.github.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Verified by re-running the mutant against the committed hook while writing this. The judgement of whether it was worth saying is mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>My arithmetic grader reads the first number in the sentence, not the answer</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Wed, 30 Sep 2026 15:55:43 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-arithmetic-grader-reads-the-first-number-in-the-sentence-not-the-answer-4db0</link>
      <guid>https://dev.to/agentdev9/my-arithmetic-grader-reads-the-first-number-in-the-sentence-not-the-answer-4db0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-09-30-the-grader-reads-the-first-number/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  My arithmetic grader reads the first number in the sentence, not the answer
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about my own harness. No model got the arithmetic wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;A week ago I published a post saying three of my factual-recall tasks were scoring&lt;br&gt;
format rather than facts, and I ended it with a guess I had not tested. The guess&lt;br&gt;
was that models read &lt;code&gt;Answer with only the city name&lt;/code&gt; as a constraint on the output&lt;br&gt;
and &lt;code&gt;Two letters only&lt;/code&gt; as a description of the answer, and that the difference&lt;br&gt;
decides whether you get a bare token or a sentence.&lt;/p&gt;

&lt;p&gt;This week the same suite's arithmetic grader failed Claude Opus 4.8 on sixty&lt;br&gt;
consecutive clean runs of a task that asks what 0.3 minus 0.1 is.&lt;/p&gt;

&lt;p&gt;So I went and looked, and the guess held in a place I did not plant it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The defect
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;g&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;m&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;search&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+(?:\.\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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&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="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;re.search&lt;/code&gt; returns the &lt;strong&gt;first&lt;/strong&gt; match. So the grader reads the first number that&lt;br&gt;
appears anywhere in the output and compares that to the expected answer. If a model&lt;br&gt;
restates the question before answering it, the first number in the output is a&lt;br&gt;
number from the question.&lt;/p&gt;

&lt;p&gt;Seven of the eight tasks graded this way put a number in the prompt that is not the&lt;br&gt;
answer. &lt;code&gt;Compute 3 + 4 * 5&lt;/code&gt; expects 23 and leads with 3. &lt;code&gt;What is 15% of 200&lt;/code&gt;&lt;br&gt;
expects 30 and leads with 15. &lt;code&gt;What is 0.3 minus 0.1&lt;/code&gt; expects 0.2 and leads with&lt;br&gt;
0.3.&lt;/p&gt;
&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;claim&lt;/th&gt;
&lt;th&gt;how it was checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the grader takes the first number, not the answer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;modeldrift/suite.py:64&lt;/code&gt;, &lt;code&gt;re.search(r"-?\d+(?:\.\d+)?", ...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 of its 8 tasks lead with a number that is not the answer&lt;/td&gt;
&lt;td&gt;enumerated over &lt;code&gt;SUITE&lt;/code&gt;: math-order 3 vs 23, math-percent 15 vs 30, reason-older 30 vs 25, multi-step-math 3 vs 8, unit-minutes 2.5 vs 150, compare-negatives -0.5 vs -0.05, float-subtract 0.3 vs 0.2. Only compare-decimals leads with its own answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it bites on runs where nothing errored&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;float-subtract&lt;/code&gt; fails on 9 models across runs with &lt;code&gt;reliability: 1.0&lt;/code&gt;, worst Claude Opus 4.8 at 60 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;every model had the right answer&lt;/td&gt;
&lt;td&gt;live calls through the probe's own path: gpt-4o-mini &lt;code&gt;'0.3 minus 0.1 equals 0.2. To one decimal place, the answer is 0.2.'&lt;/code&gt;; opus-4-8 &lt;code&gt;'0.3 minus 0.1 is **0.2**.'&lt;/code&gt;; sonnet-5 &lt;code&gt;'0.3 minus 0.1 equals **0.2**'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;changing only the instruction flips the outcome&lt;/td&gt;
&lt;td&gt;same arithmetic, three wordings, three models, nine calls: as-shipped FAIL x3 with first=0.3; &lt;code&gt;Give only the number&lt;/code&gt; and &lt;code&gt;Number only&lt;/code&gt; PASS x3 with first=0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same models pass the other number tasks in the same call&lt;/td&gt;
&lt;td&gt;all three answered math-order &lt;code&gt;'23'&lt;/code&gt;, math-percent &lt;code&gt;'30'&lt;/code&gt;, unit-minutes &lt;code&gt;'150'&lt;/code&gt;, bare, no restatement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  What separates the task that fails from the seven that do not
&lt;/h2&gt;

&lt;p&gt;This is the part that makes it more than a bug report.&lt;/p&gt;

&lt;p&gt;The tasks that pass say &lt;strong&gt;"Give only the number"&lt;/strong&gt; and &lt;strong&gt;"Number only"&lt;/strong&gt;. Those are&lt;br&gt;
instructions about the shape of the output. The task that fails says &lt;strong&gt;"Give the&lt;br&gt;
number to one decimal place"&lt;/strong&gt;. That is an instruction about the &lt;em&gt;format of the&lt;br&gt;
value&lt;/em&gt;, and it appears to invite the model to show that it has formatted it, which&lt;br&gt;
means saying what it started from.&lt;/p&gt;

&lt;p&gt;Last week's post guessed exactly that distinction from nine calls on three&lt;br&gt;
factual-recall tasks, and said plainly that it was one story fitting nine calls. It&lt;br&gt;
now fits twenty one calls, across two different grader families, with the same&lt;br&gt;
split: an output-shape instruction gets a bare token, a description of the answer&lt;br&gt;
gets a sentence.&lt;/p&gt;

&lt;p&gt;I would rather have found it the other way around. A guess that survives a test I&lt;br&gt;
did not design for it is worth more than a guess I go looking to confirm.&lt;/p&gt;
&lt;h2&gt;
  
  
  So I ran the experiment
&lt;/h2&gt;

&lt;p&gt;The paragraph above used to say this was a pattern I had observed twice rather than&lt;br&gt;
a mechanism I had demonstrated, and that the test was one sweep away. Here is the&lt;br&gt;
sweep. Same arithmetic, same three models, three instruction wordings, nine calls.&lt;br&gt;
The frozen suite was not edited, because its hash travels in published claims; the&lt;br&gt;
variants were sent directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;as-shipped  "Give the number to one decimal place"   FAIL x3   first=0.3
            opus-4-8     '0.3 minus 0.1 equals **0.2**.'
            sonnet-5     '0.3 - 0.1 = **0.2**'
            gpt-4o-mini  '0.3 minus 0.1 equals 0.2. To one decimal place, the answer is 0.2.'

"Give only the number"                             PASS x3   first=0.2   all answered '0.2'
"Number only"                                      PASS x3   first=0.2   all answered '0.2'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine for nine. The only thing that changed was the instruction, and it flipped every&lt;br&gt;
row.&lt;/p&gt;

&lt;p&gt;It also kills the alternative I could not rule out. I had written that &lt;code&gt;0.3 minus&lt;br&gt;
0.1&lt;/code&gt; is the canonical floating point example and a model might restate it because it&lt;br&gt;
recognises the exercise rather than because of the wording. If that were the cause,&lt;br&gt;
the restatement would survive &lt;code&gt;Give only the number&lt;/code&gt;. It does not survive it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;The experiment is three models and one task. It demonstrates that the wording&lt;br&gt;
controls the restatement here; it does not establish how far that generalises, and I&lt;br&gt;
would not carry the claim to a model family I have not called.&lt;/p&gt;

&lt;p&gt;And the count needs a caveat. Seven of eight tasks are vulnerable &lt;em&gt;in principle&lt;/em&gt;,&lt;br&gt;
because their prompts lead with the wrong number. Only one of them is observed&lt;br&gt;
failing at scale. The others pass because these models happen to answer them&lt;br&gt;
tersely, which is not a property of my grader, it is a property of this month's&lt;br&gt;
models. The grader is one style change away from failing all seven.&lt;/p&gt;

&lt;p&gt;The fix is not obvious either, and I have not made it. Reading the last number&lt;br&gt;
instead of the first would pass &lt;code&gt;'0.3 minus 0.1 equals 0.2'&lt;/code&gt; and would also pass a&lt;br&gt;
model that shows its work and lands on the wrong final value. Requiring a bare&lt;br&gt;
number would be honest but would turn an arithmetic task into a formatting task,&lt;br&gt;
which is the exact complaint this post is making.&lt;/p&gt;

&lt;p&gt;If you grade numeric answers with a regex: check which end of the string you are&lt;br&gt;
reading, and check it against an output that restates the question. Mine had been&lt;br&gt;
reading the question back to itself for months, and the board recorded the result as&lt;br&gt;
an arithmetic failure.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live board: &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;https://egnaro9.github.io/model-drift/&lt;/a&gt;&lt;br&gt;
Suite, graders and runner: &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;https://github.com/egnaro9/model-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The finding was surfaced by the drift detector. The live calls, the enumeration and the judgement of whether it was worth saying are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Twice the data, less power: my stability rule got blinder the harder I looked</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Wed, 23 Sep 2026 23:16:26 +0000</pubDate>
      <link>https://dev.to/agentdev9/twice-the-data-less-power-my-stability-rule-got-blinder-the-harder-i-looked-2ni8</link>
      <guid>https://dev.to/agentdev9/twice-the-data-less-power-my-stability-rule-got-blinder-the-harder-i-looked-2ni8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-09-23-twice-the-data-less-power/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Twice the data, less power: my stability rule got blinder the harder I looked
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about a measurement rule, not about a model. The models did not change.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I was comparing two models on a frozen 159 task suite and I had a problem most eval&lt;br&gt;
code does not admit to having: the same model, asked the same question three times,&lt;br&gt;
sometimes disagrees with itself. I had written a rule for that. A task where a&lt;br&gt;
config does not agree with itself is discarded, not counted. Call it &lt;code&gt;strict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the first three repetitions &lt;code&gt;strict&lt;/code&gt; gave me Haiku ahead 7 to 1, eight&lt;br&gt;
informative tasks, p equal to 0.070. Not significant.&lt;/p&gt;

&lt;p&gt;So I looked at why, found that &lt;code&gt;strict&lt;/code&gt; was throwing away thirteen tasks, and wrote&lt;br&gt;
a second rule that tolerates a minority disagreement. Call it &lt;code&gt;rate&lt;/code&gt;. It gave Haiku&lt;br&gt;
ahead 13 to 2, fifteen informative tasks, p equal to 0.0074. Significant.&lt;/p&gt;

&lt;p&gt;I had run the conservative rule, got a null, and then built the rule that returned&lt;br&gt;
the result I wanted. That is also exactly what everyone who p hacks believes about&lt;br&gt;
themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;claim&lt;/th&gt;
&lt;th&gt;how it was checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the two rules disagreed on the same data&lt;/td&gt;
&lt;td&gt;reps 1 to 3, suite &lt;code&gt;combined-159.json&lt;/code&gt;: &lt;code&gt;strict&lt;/code&gt; 7 to 1, informative 8, p 0.070; &lt;code&gt;rate&lt;/code&gt; 13 to 2, informative 15, p 0.0074&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the second rule was written after seeing the first result&lt;/td&gt;
&lt;td&gt;stated in &lt;code&gt;runs/PREREGISTRATION.md&lt;/code&gt;, committed before reps 4 to 6 existed, so the git timestamp is checkable against the run files' mtimes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the replication was pre-registered, analysis included&lt;/td&gt;
&lt;td&gt;predictions, refutation conditions and fixed parameters in &lt;code&gt;runs/PREREGISTRATION.md&lt;/code&gt;; the analysis in &lt;code&gt;tools/replicate.py&lt;/code&gt;, written while the sweeps were still running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the prediction I got wrong, I got wrong in public&lt;/td&gt;
&lt;td&gt;predicted &lt;code&gt;strict&lt;/code&gt; would again fail to reach significance; on fresh data it reached p 0.0215&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;more repetitions raised the discard count&lt;/td&gt;
&lt;td&gt;2 reps: about 8.7 discarded, about 10.3 informative. 3 reps: 13 discarded, 8 informative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pooling all six repetitions lost the result&lt;/td&gt;
&lt;td&gt;reps 4 to 6 alone: informative 10, discards 13, p 0.0215, decisive. Reps 1 to 6 pooled: informative 8, discards 17, p 0.0703, not significant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the extra data did not buy
&lt;/h2&gt;

&lt;p&gt;This is the part I did not expect and the reason the post exists.&lt;/p&gt;

&lt;p&gt;Every extra repetition is another chance to observe a config disagreeing with&lt;br&gt;
itself, and &lt;code&gt;strict&lt;/code&gt; discards a task the moment it sees one. So the discards climb&lt;br&gt;
with the data. Thirteen became seventeen. Informative tasks fell from ten to eight.&lt;br&gt;
The p value went from 0.0215 to 0.0703, which is to say a decisive result became a&lt;br&gt;
null by adding measurements to it.&lt;/p&gt;

&lt;p&gt;In the limit the rule throws away every genuinely stochastic task. More measurement&lt;br&gt;
cannot fix that. Only a different rule can.&lt;/p&gt;

&lt;p&gt;A conservative rule is not a free choice. It will never report noise as signal, and&lt;br&gt;
the price is that it grows blinder the harder you look. That price is measurable,&lt;br&gt;
and until I measured it I had been describing the conservatism as pure upside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pre-registration was actually for
&lt;/h2&gt;

&lt;p&gt;The rule I trust reached significance on data it had never seen. &lt;code&gt;strict&lt;/code&gt; on reps&lt;br&gt;
4 to 6 alone: Haiku 9 to 1, ten informative tasks, p 0.0215.&lt;/p&gt;

&lt;p&gt;I had predicted it would fail again. The pre-registration says in advance what to&lt;br&gt;
do about that: "If 1 holds and 2 fails, that is stronger than predicted, and I will&lt;br&gt;
say the prediction was too conservative rather than claiming I called it." So that&lt;br&gt;
is what I am saying. I did not call it.&lt;/p&gt;

&lt;p&gt;The parameter is the part worth stealing. &lt;code&gt;rate_margin&lt;/code&gt; was fixed at 0.5 in&lt;br&gt;
advance, and afterwards I removed the flag from the command line entirely, along&lt;br&gt;
with &lt;code&gt;--alpha&lt;/code&gt;, because a threshold a reader can dial after seeing the result is&lt;br&gt;
not a threshold. The registered value did not change. The knob did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;The weakest link is that this is one suite and two models. Haiku 4.5 beating Sonnet&lt;br&gt;
4.6 here is a claim about a suite made of trap questions, not a capability ranking.&lt;br&gt;
A suite built to catch specific slips measures susceptibility to those slips, and I&lt;br&gt;
would not carry that sentence anywhere else.&lt;/p&gt;

&lt;p&gt;The reading I cannot rule out is that &lt;code&gt;rate&lt;/code&gt; is simply the better rule and &lt;code&gt;strict&lt;/code&gt;&lt;br&gt;
was never worth defending, in which case the whole discard analysis is an&lt;br&gt;
elaborate defence of a mistake. The replication is evidence against that, since&lt;br&gt;
&lt;code&gt;strict&lt;/code&gt; reached significance on fresh data, but one replication at two models is&lt;br&gt;
not much.&lt;/p&gt;

&lt;p&gt;And I should be honest that the pooled result has an innocent reading. Reps 1 to 3&lt;br&gt;
and reps 4 to 6 were run at different times, so pooling them mixes two sampling&lt;br&gt;
windows. I attribute the power loss to the discard mechanism because the discard&lt;br&gt;
count is the thing that visibly moved, thirteen to seventeen, but I have not run&lt;br&gt;
the version of this that would separate those two explanations.&lt;/p&gt;

&lt;p&gt;If you maintain an eval suite with a stability or flakiness filter: count what it&lt;br&gt;
discards, and count it again after you add data. If the discard count rises faster&lt;br&gt;
than your informative count, your filter is spending your sample size, and the&lt;br&gt;
direction of that trade is not obvious from the code.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Suite, rules, pre-registration and replication: &lt;a href="https://github.com/egnaro9/pi-eval" rel="noopener noreferrer"&gt;https://github.com/egnaro9/pi-eval&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The numbers above are quoted from &lt;code&gt;FINDINGS.md&lt;/code&gt; and &lt;code&gt;runs/PREREGISTRATION.md&lt;/code&gt; in that repository. The judgement of whether this was worth saying is mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>My factual-recall tasks were scoring format, not facts</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Wed, 23 Sep 2026 23:15:35 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-factual-recall-tasks-were-scoring-format-not-facts-j4m</link>
      <guid>https://dev.to/agentdev9/my-factual-recall-tasks-were-scoring-format-not-facts-j4m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://erikhill.dev/notes/2026-09-22-my-factual-recall-tasks-were-scoring-format-not-facts/" rel="noopener noreferrer"&gt;erikhill.dev&lt;/a&gt;. The numbers below are checked against the repository they come from.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  My factual-recall tasks were scoring format, not facts
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;This is a finding about my own harness. The suspect is the probe, not the models it measures.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I built a detector that decides whether a day's drift run contains anything worth&lt;br&gt;
writing up. The first thing it did was accuse my own suite. One task,&lt;br&gt;
&lt;code&gt;fact-element&lt;/code&gt;, had failed on three or more providers on the same day, on 46&lt;br&gt;
separate days.&lt;/p&gt;

&lt;p&gt;The task is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is the chemical symbol for gold? Two letters only.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Graded by exact match against &lt;code&gt;au&lt;/code&gt;, after stripping whitespace and lowercasing.&lt;/p&gt;

&lt;p&gt;Claude Sonnet 5 failed it &lt;strong&gt;60 times out of 60&lt;/strong&gt;. GPT-4o mini failed it &lt;strong&gt;59 out&lt;br&gt;
of 59&lt;/strong&gt;. Llama 3.1 8B, 23 out of 23. Those are not knowledge results. A model&lt;br&gt;
that scores 83% across a 35-task suite does not have a 100% failure rate on&lt;br&gt;
whether gold is Au.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;claim&lt;/th&gt;
&lt;th&gt;how it was checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fact-element&lt;/code&gt; failed on 3 or more providers on the same day, 46 times&lt;/td&gt;
&lt;td&gt;cross-provider flip analysis over every stored run in &lt;code&gt;dashboard/drift_board.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;those were graded failures, not outages&lt;/td&gt;
&lt;td&gt;every run counted had &lt;code&gt;reliability: 1.0&lt;/code&gt;, so no call errored and none was truncated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the models know the answer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-4o-mini&lt;/code&gt; answered &lt;code&gt;'The chemical symbol for gold is Au.'&lt;/code&gt;; &lt;code&gt;claude-sonnet-5&lt;/code&gt; answered &lt;code&gt;'**Au**\n\nThat's the chemical symbol for gold, derived from the Latin word *aurum*.'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same models pass a near-identical task&lt;/td&gt;
&lt;td&gt;all three answered &lt;code&gt;fact-capital&lt;/code&gt; with exactly &lt;code&gt;'Tokyo'&lt;/code&gt;, 0 failures in 59 and 60 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one trailing period is the whole difference&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-4o-mini&lt;/code&gt; answered &lt;code&gt;fact-planet&lt;/code&gt; with &lt;code&gt;'Mercury.'&lt;/code&gt; and was recorded wrong 59 times out of 59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a model that formats tersely passes cleanly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude-opus-4-8&lt;/code&gt; answered &lt;code&gt;'Au'&lt;/code&gt;, &lt;code&gt;'Mercury'&lt;/code&gt;, &lt;code&gt;'Tokyo'&lt;/code&gt;, and fails &lt;code&gt;fact-element&lt;/code&gt; on 1 of 60 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The answers above are not from the board. They came from nine live calls made&lt;br&gt;
through the probe's own call path, printed with &lt;code&gt;repr()&lt;/code&gt; so punctuation and&lt;br&gt;
markdown are visible rather than inferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stayed green anyway
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fact-capital&lt;/code&gt; is the control, and it is the reason this is a finding rather&lt;br&gt;
than a theory. It asks for the capital of Japan and every model in the table&lt;br&gt;
answers &lt;code&gt;'Tokyo'&lt;/code&gt;, exactly, on every run. Same grader, same strictness, same&lt;br&gt;
models, same day. Nothing about exact matching is broken in general.&lt;/p&gt;

&lt;p&gt;The three tasks differ in one way. &lt;code&gt;fact-capital&lt;/code&gt; says &lt;strong&gt;"Answer with only the&lt;br&gt;
city name"&lt;/strong&gt;, which is an instruction about the shape of the output.&lt;br&gt;
&lt;code&gt;fact-element&lt;/code&gt; says &lt;strong&gt;"Two letters only"&lt;/strong&gt; and &lt;code&gt;fact-planet&lt;/code&gt; says &lt;strong&gt;"One word"&lt;/strong&gt;,&lt;br&gt;
which are descriptions of the answer. Models appear to read the first as a&lt;br&gt;
constraint they must obey and the other two as a hint about what is being asked,&lt;br&gt;
and then answer in their house style: a full sentence, a bolded token with an&lt;br&gt;
etymology, a word with a period after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this run could and could not have seen
&lt;/h2&gt;

&lt;p&gt;The suite has 35 tasks, so the smallest accuracy change it can print is&lt;br&gt;
100/35 = 2.86 points. &lt;code&gt;fact-element&lt;/code&gt; and &lt;code&gt;fact-planet&lt;/code&gt; together are 5.7 points&lt;br&gt;
of every affected model's score, permanently, for reasons that have nothing to&lt;br&gt;
do with the models.&lt;/p&gt;

&lt;p&gt;Worse than the points: these tasks are tagged &lt;code&gt;factual-recall&lt;/code&gt;, and that tag&lt;br&gt;
feeds a per-category breakdown on the public board. GPT-4o mini's&lt;br&gt;
factual-recall score has been reading as a knowledge number when two thirds of&lt;br&gt;
it is a formatting number. Anyone comparing models on that column, including&lt;br&gt;
me, was comparing how terse they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I might have wrong
&lt;/h2&gt;

&lt;p&gt;The part I am least sure of is the explanation, not the measurement. The numbers&lt;br&gt;
are solid and the live answers are quoted verbatim, but "models treat &lt;em&gt;answer&lt;br&gt;
with only X&lt;/em&gt; as a constraint and &lt;em&gt;X only&lt;/em&gt; as a hint" is one story that fits nine&lt;br&gt;
calls. I have not tested it. The honest version is that I know the format&lt;br&gt;
differs and I am guessing at why.&lt;/p&gt;

&lt;p&gt;Two things genuinely argue against my reading. Grok 4.5 fails &lt;code&gt;fact-element&lt;/code&gt; on&lt;br&gt;
26 of 49 runs, which is close to a coin flip, and a rigid house style should not&lt;br&gt;
produce that. And Claude Sonnet 5 failed &lt;code&gt;fact-planet&lt;/code&gt; on 38 of 60 historical&lt;br&gt;
runs but answered &lt;code&gt;'Mercury'&lt;/code&gt; cleanly when I called it today, so at least some&lt;br&gt;
of this moves over time and is not a fixed property of the model.&lt;/p&gt;

&lt;p&gt;There is also a real question about what the fix should be, and I do not think&lt;br&gt;
it is obviously "loosen the grader". A grader that accepts &lt;code&gt;'Mercury.'&lt;/code&gt; also&lt;br&gt;
accepts a model that ignores the instruction, and the instruction was part of&lt;br&gt;
the task. Making the grader lenient would convert a visible measurement problem&lt;br&gt;
into an invisible one. The alternative is to say plainly that these are&lt;br&gt;
instruction-following tasks and retag them, which changes what the board has&lt;br&gt;
been reporting for three months.&lt;/p&gt;

&lt;p&gt;If you maintain an eval suite with exact-match graders on short answers: check&lt;br&gt;
what your passing models actually return, not just whether they passed. The&lt;br&gt;
board I built recorded &lt;em&gt;which&lt;/em&gt; tasks failed for three months and never once&lt;br&gt;
recorded &lt;em&gt;what the model said&lt;/em&gt;, which is why this took a live call to see. That&lt;br&gt;
gap is the more embarrassing half of this post.&lt;/p&gt;




&lt;p&gt;I build deterministic evaluation and verification tooling for LLM systems, and I am looking for my first full-time role in AI evaluation or QA engineering. Remote US Eastern, or Charleston SC. &lt;a href="https://erikhill.dev" rel="noopener noreferrer"&gt;https://erikhill.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live board: &lt;a href="https://egnaro9.github.io/model-drift/" rel="noopener noreferrer"&gt;https://egnaro9.github.io/model-drift/&lt;/a&gt;&lt;br&gt;
Suite, graders and runner: &lt;a href="https://github.com/egnaro9/model-drift" rel="noopener noreferrer"&gt;https://github.com/egnaro9/model-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The finding was surfaced automatically by the drift detector. The investigation, the live calls and the judgement of whether it was worth saying are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>My mutation score was 1.000 in two places and the denominators disagreed</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Wed, 23 Sep 2026 01:24:56 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-mutation-score-was-1000-in-two-places-and-the-denominators-disagreed-3i65</link>
      <guid>https://dev.to/agentdev9/my-mutation-score-was-1000-in-two-places-and-the-denominators-disagreed-3i65</guid>
      <description>&lt;p&gt;I published a number, went to confirm it, and the confirmation disagreed with it. The number was fine. The confirmation was broken, and it was broken in a way I had already written a warning about.&lt;/p&gt;

&lt;h3&gt;
  
  
  The number
&lt;/h3&gt;

&lt;p&gt;My evidence-bundle verifier has one metric I care about more than coverage: for every place in the code where the tool can refuse, is there a test that notices if I delete that refusal? So the sweep replaces each refusal site with a pass, one at a time, and reruns the suite. A site nobody catches is a gate that is not a gate.&lt;/p&gt;

&lt;p&gt;CI, on the pushed commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseline clean; 168 refusal sites (4 excluded, see EXCLUDE)
MUTATION SCORE: 168/168 = 1.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build fails under 0.99, so this is a gate rather than a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  The confirmation
&lt;/h3&gt;

&lt;p&gt;I ran the same script locally, expecting 168:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MUTATION SCORE: 170/170 = 1.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same tool, same command, same score, two different denominators. 168 and 170 are both "everything", which is exactly why it is easy to miss. If the local run had said 0.94 I would have investigated in seconds. It said 1.000, so it looked like agreement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why
&lt;/h3&gt;

&lt;p&gt;My working tree had 28 files that were not committed: 15 modified, 13 untracked. The verifier source itself was clean, so no new refusal sites had appeared in the thing being measured. What had changed was the suite and the fixtures around it, which decides which mutants get caught and which sites get excluded.&lt;/p&gt;

&lt;p&gt;So the population moved. The sweep did not measure a different quality of the same system; it measured a different system.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part I would rather not write
&lt;/h3&gt;

&lt;p&gt;Here is a comment sitting in my own CI config, committed months ago, above the sibling checkouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The score is only comparable if the suite is. Without these checkouts&lt;/span&gt;
&lt;span class="c1"&gt;# the real-bundle tests SKIP, four mutants stop being caught, and the&lt;/span&gt;
&lt;span class="c1"&gt;# denominator changes -- a mutation score that depends on what happens&lt;/span&gt;
&lt;span class="c1"&gt;# to be on the machine is the same host-dependence class this verifier&lt;/span&gt;
&lt;span class="c1"&gt;# exists to refuse. Check them out so CI measures what a developer does.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote that. It names the failure precisely. And I still ran a local sweep to check a published figure, because checking felt like the careful thing to do.&lt;/p&gt;

&lt;p&gt;Knowing a rule is not the same as being governed by it. The gate that actually held here was not my knowledge, it was CI, because CI had no choice about which tree it ran on.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I changed
&lt;/h3&gt;

&lt;p&gt;The number I put in front of other people is the one CI computed on the commit they can check out. Not the one my laptop computed on a tree only I have.&lt;/p&gt;

&lt;p&gt;That sounds obvious written down. It was not obvious while I was doing it, because the local run felt like more diligence rather than less.&lt;/p&gt;

&lt;p&gt;Two questions I would genuinely like answers to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If you publish a metric out of your own repository, is its denominator stable across a clean clone and your working tree? Have you checked, or does it just feel stable?&lt;/li&gt;
&lt;li&gt;Does your number get quoted from a CI log, or from whatever you last ran locally?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you check and the two disagree, I would like to hear what moved. I suspect this is common and mostly invisible, because agreement on the headline figure hides disagreement underneath it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>python</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
