<?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>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>
    <item>
      <title>Three tools said my resume was clean. One of them was not installed.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:26:19 +0000</pubDate>
      <link>https://dev.to/agentdev9/three-tools-said-my-resume-was-clean-one-of-them-was-not-installed-2mic</link>
      <guid>https://dev.to/agentdev9/three-tools-said-my-resume-was-clean-one-of-them-was-not-installed-2mic</guid>
      <description>&lt;p&gt;Today I audited my own public claims and found a false one. My resume said the Android game I shipped, Tap Dodge Rush, was built with "Kotlin / Jetpack Compose". The app has never contained a line of Compose. The build file says so in my own words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// The game is a custom View painting to a Canvas, driven by an Activity with
// an XML layout. Compose came from the project template and was never used.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The claim was falsifiable only by me. That repo is private, so for months the one person who could check it was the person who wrote it. That is most of why it sat there.&lt;/p&gt;

&lt;p&gt;Fixing the HTML and the markdown took a minute. Then I went to check the PDFs, and that is where the real story is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tools, three clean results
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;Erik_Hill_Resume&lt;span class="k"&gt;*&lt;/span&gt;.pdf&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="gp"&gt;    printf '%s %s\n' "$&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;pdftotext &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; - 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; jetpack&lt;span class="o"&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="go"&gt;  done
0 Erik_Hill_Resume.pdf
0 Erik_Hill_Resume_DRAFT.pdf
0 Erik_Hill_Resume_VARIANT_A.pdf
0 Erik_Hill_Resume_VARIANT_B.pdf
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. For a second opinion I ran a small extractor I had written earlier that pulls the PDF content streams, inflates them with zlib, and searches the literal text. Zero hits. Third pass, the blunt one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;strings Erik_Hill_Resume.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; jetpack
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent tools, three clean results. I was one command away from calling it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that broke it
&lt;/h2&gt;

&lt;p&gt;Before closing it out I searched for a string that has to be in the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext Erik_Hill_Resume.pdf - 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; erik
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits for "erik" in Erik Hill's resume. That cannot be true.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; pdftotext
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext Erik_Hill_Resume.pdf -
&lt;span class="go"&gt;zsh: command not found: pdftotext
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pdftotext was never installed on this machine. In a pipeline the error goes to stderr, grep reads an empty stdin, and grep honestly reports zero matches in nothing at all. The exit status I was reading belonged to grep. Every clean result in that sweep was a measurement of the empty string.&lt;/p&gt;

&lt;p&gt;I run sweeps like this with an agent driving the shell, and the agent reported the same clean result. It was reading the same zero I was. The tooling did not fail quietly because it was an agent. It failed quietly because a pipe swallows stderr and nobody asked the pipeline to prove it could find anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap inside the fix
&lt;/h2&gt;

&lt;p&gt;Here is the part I did not expect. &lt;code&gt;strings&lt;/code&gt; also passes a positive control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;strings Erik_Hill_Resume.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; erik
&lt;span class="gp"&gt;/URI (https://linkedin.com/in/erik-hill-98895575)&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="gp"&gt;/URI (https://erikhill.dev/)&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hits. A green control, on a tool that cannot read a word of the body text. Those are link annotations, which PDF stores uncompressed, while the body text sits in Flate-compressed content streams that no byte search will ever reach.&lt;/p&gt;

&lt;p&gt;So the control passed in a layer the target does not live in. A positive control only proves the tool can read the layer the control sits in.&lt;/p&gt;

&lt;p&gt;The zlib extractor failed for a third reason: these PDFs use subsetted fonts and hex strings, so scanning for literal parenthesized text finds nothing. That file has 27 streams, all 27 inflate cleanly, zero literal-paren text operators and 4721 hex-string ones.&lt;/p&gt;

&lt;p&gt;The tool that actually reads a PDF here is pypdf, in a throwaway venv:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
from pypdf import PdfReader
t=&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="s"&gt;.join((p.extract_text() or &lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="s"&gt;) for p in PdfReader(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Erik_Hill_Resume.pdf.bak-20260825-1859&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;).pages)
print(len(t), t.lower().count(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;erik&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;), t.lower().count(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jetpack&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="mi"&gt;4812&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a stale copy that still carries the claim, so the transcript is reproducible today. Nine PDFs had it, including the two copies in &lt;code&gt;~/Downloads&lt;/code&gt; that get attached to applications and the one my site serves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two files every sweep missed
&lt;/h2&gt;

&lt;p&gt;An adversarial pass afterward found two more, and they are the best part. Both are real PDFs whose names do not end in &lt;code&gt;.pdf&lt;/code&gt;, so every &lt;code&gt;*.pdf&lt;/code&gt; glob had skipped them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;find ~/Desktop ~/Downloads ~/.Trash ~/.claude &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.pdf'&lt;/span&gt; |
&lt;span class="gp"&gt;  while read -r f;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="gp"&gt;    [ "$&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 5 &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; 2&amp;gt;/dev/null&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;" = '%PDF-' ] &amp;amp;&amp;amp; echo "&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;  done
.../.Trash/Erik_Hill_Resume.pdf.bak-20260825-1859
.../uploads/Erik_Hill_Resume.pdf.old-0719
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second sits in the uploads directory that LinkedIn Easy Apply pulls from. A stale resume, one filename character away from invisible, in the exact folder an application form reaches into.&lt;/p&gt;

&lt;p&gt;A second checkout of the same Android app is also still on disk with the full Compose stack in its build file, so a future grep that strays one directory sideways will conclude the app really is a Compose app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and one number
&lt;/h2&gt;

&lt;p&gt;The line now reads "Kotlin / Java, Android Views + Canvas, AdMob". Kotlin alone would have replaced a false claim with a misleading one: of 1204 lines in the main source sets, 710 are Java and 494 are Kotlin. The live page and the PDF it serves both carry the corrected line, checked with curl and pypdf rather than trust.&lt;/p&gt;

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

&lt;p&gt;pypdf is not a universal answer either. It returns nothing on a scanned PDF, which needs OCR, and it will hand you the same confident zero. A magic-byte sweep is slower than a glob, which is why nobody runs one until a glob has already lied to them.&lt;/p&gt;

&lt;p&gt;This is one finding from a wider audit of my own published claims. I am not listing the rest yet, because several are still being verified, and shipping a half-verified claim audit would be the same mistake in a new costume.&lt;/p&gt;

&lt;p&gt;The rule I am left with: a search that returns nothing is not evidence of absence until you have shown the search can return something, using a control that lives in the same layer as the thing you are hunting.&lt;/p&gt;

&lt;p&gt;So run one tonight. Take the sweep command you actually trust, over a format you actually search, and point it at a string you know is in the body of that file. If it comes back zero, which layer of the file was your tool reading?&lt;/p&gt;

&lt;p&gt;I build evaluation infrastructure for LLM systems and I am looking for work. &lt;a href="mailto:contact@erikhill.dev"&gt;contact@erikhill.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>testing</category>
      <category>bash</category>
      <category>career</category>
    </item>
    <item>
      <title>My board never scored an outage as a regression. My evidence couldn't prove it.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Mon, 31 Aug 2026 05:53:52 +0000</pubDate>
      <link>https://dev.to/agentdev9/my-board-never-scored-an-outage-as-a-regression-my-evidence-couldnt-prove-it-1038</link>
      <guid>https://dev.to/agentdev9/my-board-never-scored-an-outage-as-a-regression-my-evidence-couldnt-prove-it-1038</guid>
      <description>&lt;p&gt;I put a preprint on arXiv this week. It is about forging evidence bundles that my own verifier calls clean. I mutated the sites where the verifier is supposed to refuse, counted how many mutations survived, and reported the number. The cheapest forgery that survived was four bytes.&lt;/p&gt;

&lt;p&gt;Two people commented. Within twenty four hours they had pressed on the same weak point from opposite directions, and one of them was not talking about my paper at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comment
&lt;/h2&gt;

&lt;p&gt;Nguyen Thanh Vinh:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A refusal-site score is bounded by the refusals that already exist. A comparison that never runs because the field was deleted is the visible version of that, since there is still a site to mutate. The version that stays invisible is an obligation in the spec that no site was ever written for, because it never enters the denominator at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He is right, and it is worse than he could have known from the outside. Four forgeries survived. Three landed in classes I had sites for. The fourth landed in an obligation with nothing to mutate, so it could never have appeared in the score regardless of how the sweep went. The number counted the places I had already thought to look.&lt;/p&gt;

&lt;p&gt;He asked for the one figure that bounds it: how many spec obligations have no refusal site mapped to them.&lt;/p&gt;

&lt;p&gt;I do not have it. SPEC.md carries 39 normative obligations, 37 MUST and 2 MUST NOT, and there is no artifact mapping any of them to sites. "Obligations with no refusal site" is unmeasured, not small. That is the honest state and it is the next thing I am building.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same argument, aimed somewhere else
&lt;/h2&gt;

&lt;p&gt;The same day, Sarvex Jatasra published a correction to his own earlier piece on pricing agent work, crediting Vinh with finding the hole:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;every price and every score quoted for agent work is measured on the subset that was kept, which makes each of them a lower bound on the real cost rather than an estimate of it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vinh's comment on that article and his comment on my paper are one argument in two costumes. On Sarvex's post the missing denominator is the runs a person abandoned and rewrote. On mine it is the obligations nobody wrote a check for. Both quote a number measured on survivors. Neither publishes the denominator.&lt;/p&gt;

&lt;p&gt;Sarvex left a comment on the paper too, and it is the same shape a third time: my counts of 37 through 143 sites are totals over a population re-enumerated on every run, so a site that flipped to surviving cannot be told apart from one deleted while another arrived. Also correct. I had already pulled that per-site claim from the paper for exactly that reason and the v2 with the corrected wording is queued.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got to feel smug about for one minute
&lt;/h2&gt;

&lt;p&gt;I run a public drift board that re-tests 17 models against a frozen 35 task suite daily. In August, Google's endpoint started rate limiting, and a model scored 0.0286. One task out of thirty five.&lt;/p&gt;

&lt;p&gt;That is not a bad model. That is an absent measurement wearing a score. Average it in and you publish a false regression. Drop it silently and you publish a stale standing with no trace anything was missing. Both are wrong and only one looks wrong.&lt;/p&gt;

&lt;p&gt;The board already handled this. There is a reliability floor at 0.5, and low-reliability points are excluded from the accuracy line while being kept in the Reliability metric, because reliability genuinely did drop and that is the true signal. I shipped that on 2026-07-21, the same day I published a post here called "Distinguishing wrong from absent."&lt;/p&gt;

&lt;p&gt;So when I read Sarvex's prescription:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tag every invocation with whether it ended in an artifact somebody kept, then publish runs per accepted change beside your cost per completed task, on the same slide, for the same window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;my first reaction was that I had been doing that for six weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minute ended
&lt;/h2&gt;

&lt;p&gt;My board was right. My published evidence could not demonstrate it.&lt;/p&gt;

&lt;p&gt;The board ships a VAC bundle: hash-pinned artifacts that an offline verifier re-derives from the committed rows, so a reader can check the claim without trusting me. That bundle did not publish the floor. It did not publish which run had been excluded. It published the standing and nothing about the population the standing was computed over.&lt;/p&gt;

&lt;p&gt;Which means the verifier, recomputing honestly from the same data, got different answers for three Google models and refused the bundle. It was correct to refuse. Two of the verdicts it recomputed were inverted against what the board displayed. The registry entry sat PENDING for eight days with a refusal message that read like the board was broken.&lt;/p&gt;

&lt;p&gt;The board was not broken. The evidence was incomplete in exactly the way Vinh described: the excluded runs never entered the published denominator, so nothing downstream could reconstruct the decision.&lt;/p&gt;

&lt;p&gt;The fix, yesterday, was to publish the floor as a pinned value and require both numbers: the current qualifying standing, and the latest observed run with its reliability, whether or not it qualifies. Three of seventeen models currently carry a disqualified latest run. An outage now reads as an outage in the evidence, not only on the chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then, writing this post
&lt;/h2&gt;

&lt;p&gt;I went to check my own claim before publishing it, because a post asserting "the board publishes X" should be verified against the board rather than against my memory of it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;REL_FLOOR = 0.5&lt;/code&gt; is defined twice. Once in &lt;code&gt;modeldrift/policy.py&lt;/code&gt;, once as a JavaScript literal in the dashboard. They agree today. Nothing makes them agree tomorrow, and they are not redundant: the Python one decides the qualifying standing, the JS one decides what the chart draws. If they drift, the table and the chart disagree about the same model on the same day, each internally consistent, with no error raised anywhere.&lt;/p&gt;

&lt;p&gt;Yesterday's work taught the verifier to cross-check the floor against two surfaces. The chart is the fourth surface and it was left out. It is filed now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it adds up to
&lt;/h2&gt;

&lt;p&gt;Knowing a principle and having it enforced everywhere are different states, and the distance between them is invisible until something in the world goes down or a stranger asks the right question. I had the floor in the chart, then in Python, then in the evidence, and I still found a fourth copy outside the check while writing a post about it.&lt;/p&gt;

&lt;p&gt;The mutation score has the same shape and I have not closed it. Until an artifact maps all 39 obligations to sites, that score answers "how many of the checks I wrote can be defeated" while readers will hear "how many ways this can be defeated." Those are different questions. Only one of them currently has a denominator.&lt;/p&gt;

&lt;p&gt;Paper: &lt;a href="https://arxiv.org/abs/2608.26183" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2608.26183&lt;/a&gt;&lt;br&gt;
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;
Verifier and registry: &lt;a href="https://github.com/egnaro9/vac-protocol" rel="noopener noreferrer"&gt;https://github.com/egnaro9/vac-protocol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks to Nguyen Thanh Vinh and Sarvex Jatasra, who between them cost me a claim and handed me the next two pieces of work.&lt;/p&gt;

</description>
      <category>evaluation</category>
      <category>testing</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Five Wrong Root Causes, and the Fifth Was in the Post</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sat, 22 Aug 2026 09:29:27 +0000</pubDate>
      <link>https://dev.to/agentdev9/four-wrong-root-causes-before-sunrise-why-steam-died-on-free-wine-for-macos-and-the-two-2kg2</link>
      <guid>https://dev.to/agentdev9/four-wrong-root-causes-before-sunrise-why-steam-died-on-free-wine-for-macos-and-the-two-2kg2</guid>
      <description>&lt;p&gt;I wrote most of a post about how a failing system's logs seduce you into confident wrong root causes. Then I committed one, in the post itself. Within an hour of publishing, the maintainer of the Wine packages I had written about corrected me in the open.&lt;/p&gt;

&lt;p&gt;This is the whole arc, including the part where the instrument I trusted most was the one that lied.&lt;/p&gt;

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

&lt;p&gt;The Wolf Among Us: the macOS build is 32-bit, dead since Catalina. The Windows build runs under Wine, but it is wrapped in Steam DRM, so a logged-in Steam client has to run in the same prefix. The real boss fight is therefore: log into Steam, under Wine, on a Mac, in 2026.&lt;/p&gt;

&lt;p&gt;Stock Wine could not do it. The login page rendered sometimes, polled forever, and died:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login: Failed to poll auth session. Result 2. Transport Error: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four wrong answers I caught myself
&lt;/h2&gt;

&lt;p&gt;Every one of these was supported by the failing system's logs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Winsock.&lt;/strong&gt; &lt;code&gt;Unknown error 10045 (WSAEOPNOTSUPP)&lt;/code&gt; sat right next to the failing poll. Obvious. Wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CEF sandbox.&lt;/strong&gt; Disabling it changed nothing, and one flag deleted the UI, which I briefly celebrated as a new bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing Gecko.&lt;/strong&gt; I "confirmed" Wine's HTML runtime was absent by searching only the working system for it. It was present in both. A one-sided search dressed up as a comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GL/ANGLE.&lt;/strong&gt; Real errors, and forcing software rendering removed them completely. Login still failed at the same rate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The instrument that broke the tie felt authoritative: diff a working system against the failing one. CrossOver logs the same winsock noise and logs in fine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;signal&lt;/th&gt;
&lt;th&gt;stock Wine (fails)&lt;/th&gt;
&lt;th&gt;CrossOver (works)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WSALookupServiceBegin&lt;/code&gt; failures&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;10045&lt;/code&gt; errors&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed auth polls&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Anything present in both is noise. What remained on only the failing side was two &lt;code&gt;fixme&lt;/code&gt; lines I had scrolled past all night:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixme:bcrypt:export_asymmetric_key unsupported blob type L"OpaqueKeyBlob"
fixme:ncrypt:map_ntstatus unhandled status 0xc0000002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream Wine dropped its gnutls-backed bcrypt after 11.6, and the rewrite never implemented opaque export for asymmetric keys. It is still unimplemented in master. That gap is real. I filed it: &lt;a href="https://bugs.winehq.org/show_bug.cgi?id=60214" rel="noopener noreferrer"&gt;WineHQ 60214&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually worked
&lt;/h2&gt;

&lt;p&gt;I got a Steam-DRM game running on a fully free stack: a GPTk-based Wine (which ships Apple's D3DMetal) plus an archived early-2025 Steam client old enough to predate the heavier client. Login by phone confirmation, library loaded, game ran. Verified, witnessed, screenshotted. I also revived the discontinued Whisky app from its archived runtime, which is the one piece of this whole night nobody has disputed.&lt;/p&gt;

&lt;p&gt;So I published. Wrote it up, filed the bug, posted the thread. Felt good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifth wrong root cause
&lt;/h2&gt;

&lt;p&gt;Within the hour, &lt;a href="https://github.com/Gcenx/macOS_Wine_builds/issues/159" rel="noopener noreferrer"&gt;Gcenx&lt;/a&gt;, who maintains the official WineHQ macOS packages, replied. Two of my claims did not hold up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One:&lt;/strong&gt; I said only Apple's D3DMetal could solve the rendering wall, that the community DXMT layer was a dead end because I hit a cross-process swapchain error. Wrong. Gcenx and YupItzAfi run the current Steam client with a rendering fix alone: a small steamwebhelper wrapper forcing &lt;code&gt;--in-process-gpu&lt;/code&gt;. Gcenx does it on the MoltenVK path; DXMT is a separate self-compiled-Wine route. I had hit the same wall and quit early, because I passed the flag to &lt;code&gt;Steam.exe&lt;/code&gt; instead of into steamwebhelper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two, and this is the one that matters:&lt;/strong&gt; I framed the bcrypt gap as the cause of the login failure. It is not proven. Gcenx has the current client working via the rendering fix alone, which means rendering, not the crypto gap, may be the real blocker. My own attempt to isolate the two was inconclusive. The API gap is real; its role as the Steam login cause is not.&lt;/p&gt;

&lt;p&gt;Sit with that. The differential diff, the instrument I had just praised for cutting through four wrong theories, produced a fifth wrong theory. It felt like the answer because it was cleaner than the others, not because it was proven. Clean is not the same as correct.&lt;/p&gt;

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

&lt;p&gt;Corrected every surface the same hour. Retitled the WineHQ bug to describe the verifiable API gap and stop implying it was the Steam cause. Refocused and renamed the repo to what it can honestly stand on, the bug and this post-mortem, and pointed anyone who wants to actually run Steam at the maintained path: the official WineHQ packages plus the steamwebhelper wrapper. Credited Gcenx and YupItzAfi, who were right and generous with their time.&lt;/p&gt;

&lt;p&gt;Nothing had to be redacted, because the verified core held: the game did run, Whisky did revive, the API gap is real. Only the interpretation was wrong, and interpretation is exactly what a differential diff cannot certify. It tells you what differs. It does not tell you which difference is the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am keeping
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A failing system's logs support every theory you bring to them. Four of mine, equally.&lt;/li&gt;
&lt;li&gt;The instrument you trust most is the one to distrust hardest. Mine handed me a confident wrong answer wearing the costume of rigor.&lt;/li&gt;
&lt;li&gt;Being right was never the measure. Four wrong causes I caught; the fifth I shipped, and someone better-informed caught it. What I could control was how fast and completely I fixed it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part is the whole discipline, and it is the only reason this is a post I am glad I wrote rather than one I wish I could delete.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>debugging</category>
      <category>wine</category>
      <category>testing</category>
    </item>
    <item>
      <title>One capital letter made my verifier call a failing score clean</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Sun, 16 Aug 2026 06:41:51 +0000</pubDate>
      <link>https://dev.to/agentdev9/one-capital-letter-made-my-verifier-call-a-failing-score-clean-4l3p</link>
      <guid>https://dev.to/agentdev9/one-capital-letter-made-my-verifier-call-a-failing-score-clean-4l3p</guid>
      <description>&lt;p&gt;I build a thing that checks whether AI evaluation claims are true. Someone broke it, I fixed it, and then I broke it four more times myself.&lt;/p&gt;

&lt;p&gt;Here's the cheapest one.&lt;/p&gt;

&lt;p&gt;A bundle carries security test results. Each case has a severity, and the score weighs failures by it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;med&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;total_w&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;failed_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graded&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a real bundle with three failing cases. Change &lt;code&gt;critical&lt;/code&gt; to &lt;code&gt;Critical&lt;/code&gt;, &lt;strong&gt;on the failing rows only&lt;/strong&gt;. Their weight drops to zero and leaves the numerator. The passing rows keep theirs. The score divides cleanly:&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;severities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;critical&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;critical&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;high&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;med&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;med&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="s"&gt;-&amp;gt;  [critical, Critical, high, Med, Med]&lt;/span&gt;

&lt;span class="na"&gt;vulnerability_score:  0.0    (honest value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.4545)&lt;/span&gt;
&lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="s"&gt;re-pinned honestly&lt;/span&gt;
&lt;span class="na"&gt;verifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="s"&gt;exit 0, "structural verification&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PASS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No exception. No fallback branch. Ordinary arithmetic, all the way down. &lt;code&gt;.get(sev, 0)&lt;/code&gt; treats an unknown label as harmless, so a weight table I called "frozen" was actually one the issuer controlled.&lt;/p&gt;

&lt;p&gt;And my own spec had written the hole down as a decision: &lt;em&gt;"an unknown severity weighs 0."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more, same shape
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A lie typed as a string.&lt;/strong&gt; The summary comparison walked the JSON and returned early on anything that wasn't a number. Retype every headline as &lt;code&gt;"9999"&lt;/code&gt; and nothing is ever compared. No artifact touched, no hash re-pinned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the check instead of breaking it.&lt;/strong&gt; Nothing required a listed artifact to be &lt;em&gt;covered&lt;/em&gt; by a check. Earlier hardening made &lt;em&gt;breaking&lt;/em&gt; a check a named failure; &lt;em&gt;deleting&lt;/em&gt; one stayed free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the stamp instead of faking it.&lt;/strong&gt; Four comparisons were guarded on the artifact-side key existing. Corrupt those fields and you get refused by name. Remove them and nothing happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one generalizes: &lt;strong&gt;a comparison guarded on both operands existing is not a check, it's a suggestion.&lt;/strong&gt; The party supplying one operand decides whether the comparison happens at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I stopped collecting anecdotes
&lt;/h2&gt;

&lt;p&gt;Four bugs is a story, not a measurement. So I asked a different question: &lt;strong&gt;of every place this verifier can say no, how many are actually protected by a test?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The verifier rejects bundles by appending named reasons. I disabled them one at a time, replacing each &lt;code&gt;failures.append(...)&lt;/code&gt; with &lt;code&gt;pass&lt;/code&gt;, and asked whether anything noticed: the unit suite, a liveness control, or any of the 16 committed tamper fixtures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;112 refusal sites
 37 caught
 75 SURVIVED, deletable in silence, everything still green
mutation score 0.330
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two thirds of the ways this thing could say no were unprotected.&lt;/p&gt;

&lt;p&gt;Worse: I had a CI job named &lt;code&gt;invalidation-liveness&lt;/code&gt;, written specifically to prove the verifier can block. It caught &lt;strong&gt;zero&lt;/strong&gt; of the 75. Each fixture exercises one refusal path, so deleting any &lt;em&gt;other&lt;/em&gt; refusal leaves all sixteen verdicts unchanged. The gate built to prove the gate works had, against this operator, a score of zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I didn't expect
&lt;/h2&gt;

&lt;p&gt;I fixed the four bugs. Added a regression fixture for each, the responsible post-audit move. Re-measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before fixing   37/112 = 0.330
after fixing    39/119 = 0.328
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixing the four defects I'd actually found, each pinned against return, did not meaningfully improve coverage. It closed four real holes, which is real value, but it left the &lt;em&gt;rest&lt;/em&gt; of the gates exactly as unprotected as before, because four regressions sample the failures you discovered rather than the population you own.&lt;/p&gt;

&lt;p&gt;Testing every refusal instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.330  -&amp;gt;  0.941  -&amp;gt;  1.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defects you find are a sample. The gates you own are the population.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where my tools lied
&lt;/h2&gt;

&lt;p&gt;Six times, during this work. Twice inside scripts I wrote &lt;em&gt;to hunt this bug&lt;/em&gt;. The worst:&lt;/p&gt;

&lt;p&gt;The first run after hardening scored a perfect &lt;strong&gt;1.000&lt;/strong&gt;. I almost wrote that down. It was false. The harness ran &lt;code&gt;pytest -x&lt;/code&gt; against a baseline that was already red, so pytest exited nonzero for every mutant, every mutant scored "caught," and nothing was measured at all.&lt;/p&gt;

&lt;p&gt;A tool built to detect checks that pass without checking produced a check that passed without checking. And it presented as the best possible result.&lt;/p&gt;

&lt;p&gt;The fix is the rule the whole exercise is about, turned on itself: the sweep now &lt;strong&gt;aborts unless the clean baseline is green&lt;/strong&gt;. And when it finally did reach a real 1.000, I planted a refusal on an unreachable branch and confirmed the sweep still reported &lt;code&gt;SURVIVED&lt;/code&gt; before believing the number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not claiming
&lt;/h2&gt;

&lt;p&gt;I sent this to an outside reviewer specifically to get the framing attacked, and several claims came back too big:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1.000 is not "the verifier is correct."&lt;/strong&gt; It's a complete score against one narrow operator (disable a refusal) over an enumerated population. Mutated comparisons, boundaries, and control flow are unmeasured.&lt;/li&gt;
&lt;li&gt;The honest name is &lt;strong&gt;refusal-append liveness coverage&lt;/strong&gt;. The denominator is a source-level proxy that misses raises, early returns, and every fail-open behavior that happens &lt;em&gt;before&lt;/em&gt; a refusal is reached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Vacuous pass" is a label, not a discovery.&lt;/strong&gt; This is well-trodden ground: vacuity detection in model checking, the oracle problem, mutation adequacy, fail-open validation.&lt;/li&gt;
&lt;li&gt;Every number here is self-measured on a system I wrote, verifying a registry whose entries are all my own repos. That's one multi-repo fixture, not independent evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one that surprised me
&lt;/h2&gt;

&lt;p&gt;The closure fix, &lt;em&gt;every listed artifact must be read by some check&lt;/em&gt;, was written to close one hole. Pointed at my live registry, it refused &lt;strong&gt;every remaining issuer&lt;/strong&gt; I had.&lt;/p&gt;

&lt;p&gt;And in every single case, the unchecked artifact was &lt;strong&gt;the human-readable one&lt;/strong&gt;. The rendered report. The contract. The &lt;code&gt;.md&lt;/code&gt; file. The machine-readable JSON was always bound. One contract announced "&lt;strong&gt;6/6 seeded defects fixed&lt;/strong&gt;". True, and verified by nothing.&lt;/p&gt;

&lt;p&gt;I don't have the sample to call that a law. Three repos, one author. But as a hypothesis it's cheap to test on your own work: &lt;em&gt;find the document your users actually read, and ask what binds its numbers to the data.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything's public: verifier, spec, the mutation tool, and the write-up with the parts that came back wrong. &lt;a href="https://github.com/egnaro9/vac-protocol" rel="noopener noreferrer"&gt;github.com/egnaro9/vac-protocol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run something similar: what does your suite do if you delete one of your gates? Not break it. &lt;strong&gt;Delete it.&lt;/strong&gt; I'd genuinely like to know whether 0.330 was unusual or ordinary.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I built an answer key for eval suites: six models broken on purpose, exactly.</title>
      <dc:creator>Erik Hill</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:17:27 +0000</pubDate>
      <link>https://dev.to/agentdev9/i-built-an-answer-key-for-eval-suites-six-models-broken-on-purpose-exactly-5f98</link>
      <guid>https://dev.to/agentdev9/i-built-an-answer-key-for-eval-suites-six-models-broken-on-purpose-exactly-5f98</guid>
      <description>&lt;p&gt;This week I launched evalmut — mutation testing for eval suites. Its weakness, stated in the paper: I chose the mutations, so a suite author can call them unrepresentative. Fair.&lt;/p&gt;

&lt;p&gt;So I built the version of the argument you can't dismiss: &lt;strong&gt;reference-fleet&lt;/strong&gt;, six deterministic models, each broken in exactly one documented way, at a stated seeded rate. Not trained — constructed. The defect count over a fixed request set is a constant, not a sample, and the certificate is a test file you can run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;citation-hallucinator&lt;/strong&gt; — fabricates well-formed, on-topic, nonexistent references (the &lt;em&gt;Mata v. Avianca&lt;/em&gt; failure)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;constraint-dropper&lt;/strong&gt; — honors instructions 1..N-1, silently drops the last&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;refuse-then-comply&lt;/strong&gt; — refusal preamble, full compliance after&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tool-arg-swapper&lt;/strong&gt; — schema-valid tool call, first two argument values transposed; executes cleanly into the wrong place&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sycophancy-flip&lt;/strong&gt; — endorses whatever the user asserts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stale-cutoff&lt;/strong&gt; — confidently unhedged past its knowledge boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The framing is metrology's certified reference materials: NIST ships samples of known composition so you can prove your instrument reads correctly. This is that, for eval suites.&lt;/p&gt;

&lt;p&gt;Then I pointed real suites at the fleet. The audit protocol is paired: a defect counts as detected only if the suite fails the defective response AND passes its clean twin — a suite that fails both isn't detecting anything.&lt;/p&gt;

&lt;p&gt;The board's first result, measured (paired protocol, zero false alarms):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;naive-contains&lt;/strong&gt; — the "did it answer, is it on topic, did it not refuse" suite most teams start with — detects &lt;strong&gt;1 of 6&lt;/strong&gt; defect classes (refuse-then-comply). Citation fabrication, dropped constraints, swapped tool arguments, sycophancy, and confident staleness all pass it invisibly, at detection rate 0.000, n=200 each.&lt;/li&gt;
&lt;li&gt;A diligent gradecore suite and a real promptfoo run with docs-style asserts both detect &lt;strong&gt;6 of 6&lt;/strong&gt; at rate 1.000 — with a disclosure that matters: I wrote those suites knowing the task's ground truth. Their perfect rows prove the ceiling is REACHABLE (every fleet defect is catchable by a suite that checks everything checkable), not that typical suites reach it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap between those two rows is the product: the distance between the suite you have and the suite the task admits.&lt;/p&gt;

&lt;p&gt;One honesty note, because the whole project is about honesty notes: what's audited is a suite archetype executed by its real engine — a real promptfoo run, real gradecore graders — for this task format. No named public benchmark's own dataset is being scored. The claim is narrower and therefore checkable: "a suite built this way misses this defect class at this rate." And the reproducibility claim is CI-enforced: the runner refuses to stamp results from a dirty tree, and a CI job re-runs the entire audit on every push and goes red unless the published numbers reproduce byte-for-byte. That gate caught three real problems before this post — including a provenance hole in my own board that would have inverted 12 of 18 rows.&lt;/p&gt;

&lt;p&gt;Board: &lt;a href="https://egnaro9.github.io/reference-fleet/" rel="noopener noreferrer"&gt;https://egnaro9.github.io/reference-fleet/&lt;/a&gt;&lt;br&gt;
Repo: &lt;a href="https://github.com/egnaro9/reference-fleet" rel="noopener noreferrer"&gt;https://github.com/egnaro9/reference-fleet&lt;/a&gt; — &lt;code&gt;python audit/run_audit.py&lt;/code&gt; reproduces every number.&lt;/p&gt;

&lt;p&gt;If you maintain an eval suite: which of the six would yours catch? That's an empirical question now.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>llm</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
