<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Testing | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/testing/</link><atom:link href="https://kaguc.com/tag/testing/index.xml" rel="self" type="application/rss+xml"/><description>Testing</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Wed, 29 Jul 2026 00:00:00 +0000</lastBuildDate><image><url>https://kaguc.com/media/logo.svg</url><title>Testing</title><link>https://kaguc.com/tag/testing/</link></image><item><title>The testing pyramid for LLM applications: evals, not assertions</title><link>https://kaguc.com/blog/llm-testing-pyramid/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/llm-testing-pyramid/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/llm-testing-pyramid-zh/">中文&lt;/a>. Part 8 of the Engineering LLM Applications series. The evidence base is the same as the rest of the series: a production-grade AI writing agent (reads data → renders figures → generates LaTeX → compiles PDF), 351 test functions across the project.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-non-deterministic-output-defeats-assertions">The problem: non-deterministic output defeats assertions&lt;/h2>
&lt;p>The core move of conventional testing is asserting an exact value: &lt;code>assert f(x) == y&lt;/code>. On an LLM main path this does not work — two generations from the same input are never verbatim-identical, so tests that assert fixed strings either stay red or get labeled flaky and lose all credibility. Teams commonly settle into one of two bad equilibria: test only the LLM-free edges and let the main path run naked, or force exact assertions and re-run until green. The real cost lands at change time: edit one word in a prompt and generation quality can silently degrade, with no signal in CI — &lt;a href="https://kaguc.com/blog/deterministic-boundary/">part 11&lt;/a> lists this as failure mode four of a probabilistic component, &amp;ldquo;untestable regressions&amp;rdquo;.&lt;/p>
&lt;p>The answer our project converged on fits in one sentence: &lt;strong>an LLM application runs evals, not exact-value tests.&lt;/strong> Assertions are not abandoned — they change their object, from &amp;ldquo;the output equals X&amp;rdquo; to &amp;ldquo;the output satisfies property P&amp;rdquo;: structure score above a threshold, pairwise preference no worse than the previous version, a PDF that compiles, citations and numbers closed against their sources, metamorphic relations holding. Deterministic code keeps its exact assertions; the two kinds of test coexist in layers.&lt;/p>
&lt;h2 id="the-philosophy-eval-driven-development">The philosophy: eval-driven development&lt;/h2>
&lt;p>Our methodology survey (docs/testing/01-research in the project, 2026-07, compiled from multi-source web search) compresses the field consensus into three points:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>EDD (Eval-Driven Development)&lt;/strong>: define the evaluation criteria before writing the agent; on every prompt/flow change → run the evals → read the score movement → decide. Evaluation is not post-hoc acceptance but the steering wheel of the development loop — TDD for agents.&lt;/li>
&lt;li>&lt;strong>The behavioral-testing triad&lt;/strong> (CheckList, ACL 2020): MFT (minimum functionality), INV (invariance), DIR (directional) tests. Transferred to this project: material with a planted gap must be flagged by the material check (MFT); deleting a data file must increase the gap count (DIR).&lt;/li>
&lt;li>&lt;strong>Metamorphic testing&lt;/strong>: when there is no reference answer, assert relations between inputs and outputs instead — &amp;ldquo;fuller material ⇒ structure score does not drop&amp;rdquo;, &amp;ldquo;delete half the data ⇒ more gap warnings&amp;rdquo;. This sidesteps the fundamental obstacle that non-deterministic output cannot be asserted exactly.&lt;/li>
&lt;/ol>
&lt;p>The tooling conclusion was &lt;strong>no heavy dependencies&lt;/strong>: borrow DeepEval&amp;rsquo;s form — &amp;ldquo;an eval is a pytest case plus a threshold assertion&amp;rdquo; — and wrap our own scorers into pytest cases: zero new dependencies, offline-capable, self-hosted.&lt;/p>
&lt;h2 id="four-layers-split-by-does-it-call-a-real-llm">Four layers, split by &amp;ldquo;does it call a real LLM&amp;rdquo;&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>What runs&lt;/th>
&lt;th>Calls LLM&lt;/th>
&lt;th>Speed&lt;/th>
&lt;th>How to run&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>L1 unit&lt;/strong>&lt;/td>
&lt;td>Pure logic: parsing / indexing / routing / guards / sandbox whitelist / the scorers themselves / source verification&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Seconds&lt;/td>
&lt;td>&lt;code>pytest&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>L2 integration&lt;/strong>&lt;/td>
&lt;td>Mock LLM drives the flow: full run_agent pipeline, tool loop, endpoint contracts&lt;/td>
&lt;td>Mock&lt;/td>
&lt;td>Seconds&lt;/td>
&lt;td>&lt;code>pytest&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>smoke&lt;/strong>&lt;/td>
&lt;td>Real local execution: matplotlib figure sandbox, xelatex compile&lt;/td>
&lt;td>No&lt;/td>
&lt;td>A few seconds&lt;/td>
&lt;td>&lt;code>pytest&lt;/code> (when xelatex is present)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>L3 online eval&lt;/strong>&lt;/td>
&lt;td>Real keys run the agent/judge; scorers plus thresholds decide&lt;/td>
&lt;td>Yes (DeepSeek + Qwen)&lt;/td>
&lt;td>Minutes&lt;/td>
&lt;td>&lt;code>pytest --run-live&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The layering criterion is not the textbook unit/integration split but two switches: &lt;strong>does it call a real LLM&lt;/strong>, and &lt;strong>does it need a real execution environment&lt;/strong>. A few design points:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>How L2 is written&lt;/strong>: &lt;code>monkeypatch&lt;/code> swaps &lt;code>agent._client&lt;/code> for a fake, tool_calls and final content are scripted, and the test asserts flow events plus an &lt;code>eval_report&lt;/code> score — it verifies &amp;ldquo;wired correctly&amp;rdquo;, not &amp;ldquo;high quality&amp;rdquo;. Note the scorer reuse: the same &lt;code>eval_report&lt;/code> scores mock output in L2 and real output in L3, while being itself an L1 test subject — the scorer is tested before it is allowed to test anything else.&lt;/li>
&lt;li>&lt;strong>Tests never require compilation&lt;/strong>: &lt;code>pytest&lt;/code> runs the Python source in seconds; the Nuitka build exists only to produce the customer-facing .exe. The dev loop = edit code → &lt;code>pytest&lt;/code> → green.&lt;/li>
&lt;li>&lt;strong>Gating&lt;/strong>: &lt;code>@pytest.mark.live&lt;/code> is skipped by default; only &lt;code>--run-live&lt;/code> runs it, and missing keys auto-skip. The two real keys (DeepSeek for the text/agent main path, Aliyun Qwen for vision image reading) live in &lt;code>backend/.env&lt;/code>, never in git. CI runs only the three offline layers by default; L3 runs manually, pre-release, or nightly.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pytest &lt;span class="c1"># L1+L2+smoke (default, seconds, offline, mandatory in CI)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pytest --run-live &lt;span class="c1"># add L3 online evals (real keys, slow, costs money)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python run_evals.py &lt;span class="c1"># one-shot golden-sample eval: scores vs threshold baseline&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="objective-criteria-five-assertable-properties">Objective criteria: five assertable properties&lt;/h2>
&lt;p>Agent output is never asserted against fixed strings; these properties are asserted instead:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Implementation&lt;/th>
&lt;th>Assertion form&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Structure score&lt;/td>
&lt;td>&lt;code>eval_report.score_report&lt;/code>: 8 reference-free checks (compilable skeleton / ≥3 sections / error analysis / citation closure / data tables / equations / no AI boilerplate / no TODO), returns 0–1&lt;/td>
&lt;td>score ≥ threshold&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pairwise preference&lt;/td>
&lt;td>&lt;code>eval_judge.judge_pairwise&lt;/code>: judged twice with positions swapped, ruled only if consistent&lt;/td>
&lt;td>new version no worse than baseline&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compile pass&lt;/td>
&lt;td>Real xelatex compile&lt;/td>
&lt;td>PDF produced, page count &amp;gt; 0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Source closure&lt;/td>
&lt;td>&lt;code>verify.verify&lt;/code>: &lt;code>\cite&lt;/code> closed against the bib, data numbers traceable to the material&lt;/td>
&lt;td>no undefined citations; unsourced numbers bounded&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metamorphic relations&lt;/td>
&lt;td>metamorphic / DIR&lt;/td>
&lt;td>delete data → more gaps; fabricated number → more unsourced; fuller material → score does not drop&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two points deserve expansion. &lt;strong>Pairwise preference must treat position bias&lt;/strong>: LLM judges systematically favor the first candidate (arXiv:2406.07791), so &lt;code>judge_pairwise&lt;/code> evaluates twice with positions swapped and rules only on agreement; before judging anything, the judge must pass a &lt;code>reconstruction_accuracy&lt;/code> self-check — correctly ranking known strong/weak pairs (≥ 0.7) — to earn the right to judge drafts. Validity engineering for the judge itself is the subject of &lt;a href="https://kaguc.com/blog/judge-validity/">part 9&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Metamorphic relations are the cheapest anti-hallucination criterion.&lt;/strong> The DIR test in the source-verification module, verbatim (string literals translated from the Chinese original):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_verify_metamorphic_fabricated_number_increases_unsourced&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;DIR metamorphic: add a number absent from the material →
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> the unsourced (suspected-fabricated) set must grow.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">mat&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;measured 1.0 and 2.0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">verify&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">verify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;values 1.0 and 2.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mat&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;numbers&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;unsourced&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">more&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">verify&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">verify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;values 1.0 and 2.0 and 7.77 from thin air&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mat&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="s2">&amp;#34;numbers&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;unsourced&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">more&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;7.77&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">more&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>No knowledge of the &amp;ldquo;correct output&amp;rdquo; is needed — only a direction is asserted: one number appearing out of thin air must enlarge the unsourced set. The pattern replicates cheaply: delete a data file → more gap warnings; supply the .bib → the citation gap disappears.&lt;/p>
&lt;h2 id="the-golden-regression-gate-the-scorer-must-prove-itself-before-it-may-gate">The golden regression gate: the scorer must prove itself before it may gate&lt;/h2>
&lt;p>Online evals are the most faithful, but they need keys and take minutes — they cannot protect &lt;em>every&lt;/em> change. So the regression gate is dual-track: beside the online gate &lt;code>run_evals.py&lt;/code> (real generation plus judge), an offline gate &lt;code>test_golden_gate.py&lt;/code> runs inside plain &lt;code>pytest&lt;/code>, no keys needed, three gates:&lt;/p>
&lt;p>&lt;strong>Gate 1: scorer discriminative power.&lt;/strong> Gold-standard &amp;ldquo;good/bad&amp;rdquo; reports must be clearly separated by &lt;code>eval_report&lt;/code> (assertion messages translated):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">assert&lt;/span> &lt;span class="n">good&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;score&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mf">0.75&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;gold good report scored too low &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">good&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;score&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">good&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;flags&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">assert&lt;/span> &lt;span class="n">bad&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;score&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mf">0.35&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;gold bad report scored too high &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">bad&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;score&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> (scorer lost discrimination)&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">assert&lt;/span> &lt;span class="n">good&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;score&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">bad&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;score&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mf">0.4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;good/bad gap too small: scorer discrimination regressed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The logical order matters: &lt;strong>first prove the scorer actually encodes quality, only then allow it to act as a gate.&lt;/strong> A scorer whose discrimination has decayed renders every downstream threshold meaningless — and it will never raise an alarm about itself, which is why something else must pin it.&lt;/p>
&lt;p>&lt;strong>Gate 2: the gold-standard skeleton really compiles.&lt;/strong> The gold report skeleton must pass a real xelatex compile (skipped when no engine is installed locally; on the release machine / CI with TeX it is a hard gate). A generated report that fails to compile is the most direct signal of prompt or template regression; this gate protects the template plus the compile chain.&lt;/p>
&lt;p>&lt;strong>Gate 3: prompt shape.&lt;/strong> Offline tests cannot run a real LLM and cannot judge output quality — but they can judge the structure of a prompt: the scoped prompt &lt;code>build_user_scoped&lt;/code> must still demand &amp;ldquo;rewrite only this section&amp;rdquo;, with &lt;code>\documentclass&lt;/code> and &lt;code>\end{document}&lt;/code> appearing inside the &amp;ldquo;strictly forbidden to output&amp;rdquo; clause; the full-document path &lt;code>build_user(gaps=None)&lt;/code> must be byte-identical to the call without gaps. The moment someone edits the prompt back to &amp;ldquo;output the whole document&amp;rdquo;, plain &lt;code>pytest&lt;/code> goes red.&lt;/p>
&lt;p>(One additional foundation check: the gold-standard report must parse correctly under the deterministic sectionizer — protecting the base of section-level editing.)&lt;/p>
&lt;h2 id="the-baseline-every-score-pinned-to-git_sha-and-prompt_hash">The baseline: every score pinned to git_sha and prompt_hash&lt;/h2>
&lt;p>The online gate &lt;code>run_evals.py&lt;/code> outputs more than red/green: every real run appends its scores to &lt;code>docs/testing/eval_baseline.tsv&lt;/code>, and its exit code feeds CI. The file&amp;rsquo;s actual contents (fixture names translated):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>date&lt;/th>
&lt;th>git_sha&lt;/th>
&lt;th>prompt_hash&lt;/th>
&lt;th>metric&lt;/th>
&lt;th>name&lt;/th>
&lt;th>score&lt;/th>
&lt;th>threshold&lt;/th>
&lt;th>pass&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2026-07-01 21:49&lt;/td>
&lt;td>cb5bba9&lt;/td>
&lt;td>6f3202abff3a&lt;/td>
&lt;td>judge.recon&lt;/td>
&lt;td>strong/weak pair&lt;/td>
&lt;td>1.000&lt;/td>
&lt;td>0.70&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2026-07-03 11:29&lt;/td>
&lt;td>39b9e29&lt;/td>
&lt;td>46bed3f78ca1&lt;/td>
&lt;td>report.score&lt;/td>
&lt;td>field-sweep S-params&lt;/td>
&lt;td>0.875&lt;/td>
&lt;td>0.50&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2026-07-03 11:29&lt;/td>
&lt;td>39b9e29&lt;/td>
&lt;td>46bed3f78ca1&lt;/td>
&lt;td>report.compiles&lt;/td>
&lt;td>field-sweep S-params&lt;/td>
&lt;td>1.000&lt;/td>
&lt;td>1.00&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2026-07-03 11:29&lt;/td>
&lt;td>39b9e29&lt;/td>
&lt;td>46bed3f78ca1&lt;/td>
&lt;td>judge.recon&lt;/td>
&lt;td>strong/weak pair&lt;/td>
&lt;td>1.000&lt;/td>
&lt;td>0.70&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Every row pins both &lt;code>git_sha&lt;/code> and &lt;code>prompt_hash&lt;/code>: when a score drifts, the first question — &amp;ldquo;did the code change or did the prompt change&amp;rdquo; — is answerable immediately. The full development loop:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">flowchart TD
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> A[Edit code / edit prompt] --&amp;gt; B{pytest: L1+L2+smoke, seconds}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|red| A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|green, quality-relevant code untouched| Z[Commit]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|green, agent/quality-relevant code touched| C[pytest --run-live or run_evals.py]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt; D[Real agent run → scorer/judge scores]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> D --&amp;gt; E{Compare against thresholds and eval_baseline.tsv}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> E --&amp;gt;|meets thresholds| F[Green: scores appended to baseline] --&amp;gt; Z
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> E --&amp;gt;|regression| A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Z --&amp;gt; R[Pre-release: default suite + --run-live all green → only then the Nuitka build]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Measured numbers from the rollout: on the day the system landed (2026-07-01), the default suite ran 144 passed + 7 skipped (live) and &lt;code>--run-live&lt;/code> passed 7/7; two days later the golden gate was added, 248 passed with no regressions; at the time of writing the project holds 351 test functions. Flakiness governance comes down to two moves: LLM cases assert thresholds/intervals/metamorphic relations rather than exact strings, and the judge uses swap-averaging to reduce variance.&lt;/p>
&lt;h2 id="where-this-does-not-apply">Where this does not apply&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>A small golden set is not a proof of quality.&lt;/strong> The survey notes that aggregate metrics need on the order of hundreds of examples to be trustworthy (that figure not independently verified); this project&amp;rsquo;s online golden set started with 1 report fixture plus 2 strong/weak pairs. At that scale the online eval is a smoke gate — it catches large regressions — not a quality metric. Do not conclude &amp;ldquo;quality improved&amp;rdquo; from it before the set grows.&lt;/li>
&lt;li>&lt;strong>Objective criteria only measure the measurable.&lt;/strong> The 8-item structure score is a reference-free floor: it judges &amp;ldquo;is this report solid and compilable&amp;rdquo;, not &amp;ldquo;is it well written&amp;rdquo;. Preference-level quality above the floor needs a judge — and the judge must be validity-tested first; an unvalidated judge gate is more dangerous than no gate.&lt;/li>
&lt;li>&lt;strong>Green L2 is not green quality.&lt;/strong> Mock-driven integration tests verify wiring and flow events; they know nothing about the quality of real model output. Reading L2 passes as a quality signal is the most common misreading of this layering.&lt;/li>
&lt;li>&lt;strong>Online evals are slow, cost money, and carry variance — keep them out of the fast loop.&lt;/strong> They belong pre-release, nightly, or after quality-relevant changes. And if the product is still in prototype phase with prompts rewritten daily, the threshold-maintenance cost of golden gates will exceed their value — establish a reasonably stable product definition first, then build the gates.&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>CheckList (ACL 2020) — the MFT/INV/DIR behavioral-testing triad; the direct source of this article&amp;rsquo;s criteria design.&lt;/li>
&lt;li>arXiv:2406.07791 — systematic study of position bias in LLM judges (systematic preference for the first candidate).&lt;/li>
&lt;li>arXiv:2410.15393 — judge calibration methods: swap-and-average, balanced-position calibration, and others.&lt;/li>
&lt;li>arXiv:2504.18827 — metamorphic testing applied to LLMs (LLMorph and related work).&lt;/li>
&lt;li>DeepEval — the &amp;ldquo;eval as a pytest case + threshold assertion&amp;rdquo; form we borrowed (without taking the dependency).&lt;/li>
&lt;/ul>
&lt;p>These external sources come from the project&amp;rsquo;s research document (2026-07, compiled from multi-source web search); apart from this project&amp;rsquo;s own measurements, second-hand claims were not independently re-verified.&lt;/p></description></item><item><title>Validity engineering for LLM-as-judge</title><link>https://kaguc.com/blog/judge-validity/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/judge-validity/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/judge-validity-zh/">中文&lt;/a>. Part 9 of the Engineering LLM Applications series. The first-hand data comes from a writing-judgment distillation experiment (whose design and headline results are the subject of &lt;a href="https://kaguc.com/blog/distill-judgment-experiment/">Part 12&lt;/a>); every number is taken from that project&amp;rsquo;s experiment journal, run ledger, and source code.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-the-judge-is-itself-an-unmeasured-probabilistic-component">The problem: the judge is itself an unmeasured probabilistic component&lt;/h2>
&lt;p>Pairwise judging (pairwise LLM-as-judge) is the mainstream way to evaluate LLM application quality: show the model drafts A and B, ask which is better, tally win rates. It sidesteps the calibration problems of absolute scoring — and introduces a subtler one: &lt;strong>the judge is a probabilistic component too. Why should its verdicts be trusted?&lt;/strong>&lt;/p>
&lt;p>This is not a hypothetical risk. It happened twice in our experiment. The primary judge of the first round was llama-3.3-70b; measured after the fact, its QWK against the quality anchor was 0.10 — near random — and &lt;strong>the entire round was voided&lt;/strong>. In a later round, claude-sonnet-4.6 ruled B&amp;gt;A while gpt-4o ruled A&amp;gt;B on the same drafts; the aggregate verdict could only be recorded as &amp;ldquo;ambiguous&amp;rdquo;. If you cannot answer &amp;ldquo;which judge is credible&amp;rdquo;, the win-rate table is just another array of random numbers.&lt;/p>
&lt;p>This article names the practice we converged on &lt;strong>validity engineering&lt;/strong>: before consuming any verdict from a judge, establish with independent evidence that the judge measures what you think it measures. It consists of five mechanisms: admission, anchor independence, scale diagnosis, bias control, and a pre-deployment acceptance gate.&lt;/p>
&lt;h2 id="mechanism-1-judge-admission--human-score-anchors-and-a-qwk-bar">Mechanism 1: judge admission — human-score anchors and a QWK bar&lt;/h2>
&lt;p>Admission works like this: have each candidate judge score a set of anchor texts that carry quality scores, then compute QWK (Quadratic Weighted Kappa, an ordinal-agreement metric) against the anchor scores. The bar is written directly into the module docstring of &lt;code>qwk_anchor.py&lt;/code>: &amp;ldquo;≥0.5 → the judge is credible and pairwise win-rate conclusions are worth believing; &amp;lt;0.3 → go back and fix the rubric first&amp;rdquo;, with 0.3–0.5 recorded as &amp;ldquo;marginal&amp;rdquo;.&lt;/p>
&lt;p>Measured results for the four candidate judges:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Judge&lt;/th>
&lt;th>QWK&lt;/th>
&lt;th>Ruling&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>claude-sonnet-4.6&lt;/td>
&lt;td>0.55–0.61&lt;/td>
&lt;td>Retained (primary judge)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>qwen3.7-max&lt;/td>
&lt;td>0.571&lt;/td>
&lt;td>Retained (cross judge)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>gpt-4o&lt;/td>
&lt;td>0.281&lt;/td>
&lt;td>Eliminated (&amp;lt;0.3, not credible)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llama-3.3-70b&lt;/td>
&lt;td>0.10&lt;/td>
&lt;td>Eliminated; the first round it served as primary judge was voided in full&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Elimination is not academic fastidiousness: gpt-4o was one party in the &amp;ldquo;two judges, opposite verdicts&amp;rdquo; incident above, and removing it returned the ruling to the discriminative power of the credible judges. But this round of QWK was computed against a &lt;strong>synthetic&lt;/strong> anchor — which leads to the next mechanism, and the most expensive lesson in the whole system.&lt;/p>
&lt;h2 id="mechanism-2-the-anchor-must-be-independent-of-the-system-under-evaluation--a-circular-validation-incident">Mechanism 2: the anchor must be independent of the system under evaluation — a circular-validation incident&lt;/h2>
&lt;p>The incident was triggered by one user question: &amp;ldquo;is the test data even representative?&amp;rdquo; The audit found that none of the three real datasets the plan specified (arXivEdits / peS2o / ASAP) &lt;strong>had ever been downloaded successfully&lt;/strong> — a hardcoded URL had long 404&amp;rsquo;d, the Kaggle login step was never done, and the download script ignored the system proxy. Everything had run on seed and synthetic fallback data: 13 exemplars, 5 hand-written gold pairs, 8 synthetic pairs, and a 39-item anchor whose &lt;strong>scores were synthetic, not human&lt;/strong>.&lt;/p>
&lt;p>The consequence was structural: the so-called &amp;ldquo;QWK anchor&amp;rdquo; was in fact the judge aligning with &lt;strong>the program&amp;rsquo;s own degradation rules&lt;/strong> — circular self-validation. Historical QWK of 0.51–0.61 was voided in full; it constitutes no human-grounded credibility whatsoever. One asymmetry is worth stating: what the incident voided were the &amp;ldquo;pass&amp;rdquo; rulings; the &amp;ldquo;out&amp;rdquo; rulings actually stand — a judge that cannot even align with the coarse ordering of a synthetic anchor is even less likely to align with humans (our inference, not an independent experiment).&lt;/p>
&lt;p>The fix was to actually connect the real data: 200 genuine expert-revision pairs from arXivEdits (filtered by the &lt;code>intention&lt;/code> field for writing-quality edits, discarding trivial typo fixes; median length difference 27 characters), and 120 essays with real human holistic scores from ASAP 2.0 (stratified sampling, 20 per score band 1–6, guaranteeing QWK has range to work with). The lesson in one sentence: &lt;strong>the anchor must come from outside the system under evaluation, or QWK measures the judge&amp;rsquo;s agreement with your own code.&lt;/strong>&lt;/p>
&lt;h2 id="mechanism-3-joint-qwkspearman-diagnosis--scale-compression-is-not-ranking-failure">Mechanism 3: joint QWK×Spearman diagnosis — scale compression is not ranking failure&lt;/h2>
&lt;p>With the human anchor in place, the first real QWK came out at &lt;strong>0.323&lt;/strong>: model mean 2.32 vs human mean 3.50, a systematic severity bias of about 1.2 points. A multi-agent investigation diagnosed three root causes: severity-biased scale misalignment; a genre/rubric mismatch (a physics-report rubric grading English argumentative essays); and the inherently low ceiling of single-essay absolute scoring as a usage mode (the project&amp;rsquo;s investigation cited roughly 0.60 as the zero-shot ceiling for ASAP holistic scoring — a reference value, not independently verified).&lt;/p>
&lt;p>The fix took the zero/low-cost route: switch to the ASAP holistic six-band rubric, delete all &amp;ldquo;strict&amp;rdquo; wording (the system prompt changed from &amp;ldquo;strict&amp;rdquo; to &amp;ldquo;well-calibrated&amp;rdquo;), add a full-range prior; few-shot examples stayed disabled behind a comment, because the anchor data had no train/eval split and would leak. Before and after:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Before&lt;/th>
&lt;th>After&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>QWK&lt;/td>
&lt;td>0.323&lt;/td>
&lt;td>&lt;strong>0.456&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Spearman&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>0.813&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pearson&lt;/td>
&lt;td>—&lt;/td>
&lt;td>0.788&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Systematic bias&lt;/td>
&lt;td>-1.18&lt;/td>
&lt;td>-0.87&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model mean&lt;/td>
&lt;td>2.32&lt;/td>
&lt;td>2.63 (human 3.50)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>QWK still did not clear 0.5 — but the key finding sits in another number: &lt;strong>Spearman 0.813 ≫ QWK 0.456&lt;/strong>. The diagnostic rule in &lt;code>qwk_anchor.py&lt;/code> summarizes it in one line: &amp;ldquo;high Spearman with low QWK → a pure scale problem (recalibration can save it); both low → the ranking signal itself is weak.&amp;rdquo; The confusion matrix supplied the hard evidence — the judge &lt;strong>never awards a 5 or a 6&lt;/strong>; all 40 essays humans scored 5 or 6 were compressed into the 3–4 columns. This is a &lt;strong>strong-ranking, conservative-scoring&lt;/strong> judge, not one scoring at random.&lt;/p>
&lt;p>The conclusion lands on usage: &lt;strong>pairwise judging consumes only the ranking (the judge&amp;rsquo;s strength), never the absolute scale (its weakness) — so pairwise win rates stand on solid ground as the primary signal, while single-essay absolute scores are unusable.&lt;/strong> The scale can be partially corrected by post-hoc monotonic recalibration, but its legitimacy has three preconditions (confirmed by adversarial verification): the mapping must be monotone, numbers must be reported on held-out/CV data, and parameters must not be transferred across domains — a 5-fold reproduction showed in-sample recalibration inflates the number and held-out evaluation automatically strips the inflation away. And recalibration cannot rescue the top of the scale: humans&amp;rsquo; 5s and 6s are completely indistinguishable to the judge (both get 3s and 4s) — a genuine ceiling.&lt;/p>
&lt;h2 id="mechanism-4-bias-control--position-swapping-identity-masking-a-rubric-that-penalizes-verbosity">Mechanism 4: bias control — position swapping, identity masking, a rubric that penalizes verbosity&lt;/h2>
&lt;p>LLM judges have position preferences (favoring whichever draft appears first or last). The countermeasure is protocol-level: every pair is judged twice, in the orders (A,B) and (B,A), and &lt;strong>if the two rulings disagree, the pair is recorded as a tie&lt;/strong> — never a fake verdict. The core logic is four lines (&lt;code>eval_judge.py&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">sys_ab&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_MAP_AB&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">_one_order&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">domain&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rubric&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cf&lt;/span>&lt;span class="p">)]&lt;/span> &lt;span class="c1"># order 1: first=A&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sys_ba&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_MAP_BA&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">_one_order&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">domain&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rubric&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cf&lt;/span>&lt;span class="p">)]&lt;/span> &lt;span class="c1"># order 2: first=B&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">consistent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">sys_ab&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">sys_ba&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">decided&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">sys_ab&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">consistent&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">sys_ab&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;tie&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="s2">&amp;#34;tie&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The design yields a judge-quality signal for free: swap consistency. Measured, claude-sonnet-4.6 was swap-consistent on 93% of pairs at N=30 (28/30), and 8/8 in the N=8 pilot; qwen3.7-max mostly ranged 0.2–0.5, its win rates dominated by ties — the protocol automatically absorbs a weak judge&amp;rsquo;s instability into ties instead of letting it contaminate conclusions.&lt;/p>
&lt;p>Two companion measures: &lt;strong>identity masking&lt;/strong> (the judge cannot see which pipeline produced which draft), and &lt;strong>length bias treated by rubric rather than by hope&lt;/strong> — the judging prompt states explicitly that &amp;ldquo;longer is not a virtue; low information density is penalized as verbosity; when substantively equal, the longer one must not win for being longer.&amp;rdquo;&lt;/p>
&lt;h2 id="mechanism-5-reconstruction-acceptance--judge-known-strongweak-pairs-before-judging-new-drafts">Mechanism 5: reconstruction acceptance — judge known strong/weak pairs before judging new drafts&lt;/h2>
&lt;p>Beyond admission and diagnosis there is one final gate before deployment, called &amp;ldquo;ICAI-style acceptance&amp;rdquo; (reconstruction) inside the project: hand the judge known strong/weak pairs (gold-standard good drafts vs deliberately degraded weak ones) along with its rubric and have it pick &amp;ldquo;the better one&amp;rdquo;; &lt;strong>only a pick-correct rate ≥ 0.7 (the code constant &lt;code>GATE_THRESHOLD = 0.7&lt;/code>) qualifies it to judge new drafts&lt;/strong>, with swap consistency reported alongside. The logic is plain: a judge that cannot pick correctly when the answer is known has no standing to rule when it is not.&lt;/p>
&lt;p>The cost boundary is also written into the module docstring: two swap calls per pair are real LLM calls and burn tokens — so this gate lives on the &lt;strong>development/offline side&lt;/strong> and does not run in the client&amp;rsquo;s real-time path by default.&lt;/p>
&lt;h2 id="the-whole-picture-a-judges-path-to-deployment">The whole picture: a judge&amp;rsquo;s path to deployment&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="n">flowchart&lt;/span> &lt;span class="n">TD&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">A&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Candidate&lt;/span> &lt;span class="n">judge&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">B&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Score&lt;/span> &lt;span class="n">human&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">anchor&lt;/span> &lt;span class="n">set&lt;/span> &lt;span class="err">→&lt;/span> &lt;span class="n">QWK&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">B&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">QWK&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mf">0.3&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">X&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Eliminated&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">B&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">clears&lt;/span> &lt;span class="n">the&lt;/span> &lt;span class="n">bar&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">C&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="ne">Joint&lt;/span> &lt;span class="n">QWK&lt;/span> &lt;span class="err">×&lt;/span> &lt;span class="n">Spearman&lt;/span> &lt;span class="n">diagnosis&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">C&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">ranking&lt;/span> &lt;span class="n">weak&lt;/span> &lt;span class="n">too&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">Y&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Fix&lt;/span> &lt;span class="n">rubric&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">few&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">shot&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">re&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">test&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">C&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">ranking&lt;/span> &lt;span class="n">strong&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scale&lt;/span> &lt;span class="n">compressed&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">D&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">License&lt;/span> &lt;span class="n">pairwise&lt;/span> &lt;span class="n">judging&lt;/span> &lt;span class="n">only&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">br&lt;/span>&lt;span class="o">/&amp;gt;&lt;/span>&lt;span class="n">absolute&lt;/span> &lt;span class="n">scoring&lt;/span> &lt;span class="n">unusable&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">D&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">E&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Reconstruction&lt;/span> &lt;span class="n">gate&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">known&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">pair&lt;/span> &lt;span class="n">pick&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">correct&lt;/span> &lt;span class="n">rate&lt;/span> &lt;span class="err">≥&lt;/span> &lt;span class="mf">0.7&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">E&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">fail&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">Y&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">E&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="k">pass&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">F&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Deploy&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">swap&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">order&lt;/span> &lt;span class="n">double&lt;/span> &lt;span class="n">judging&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">br&lt;/span>&lt;span class="o">/&amp;gt;&lt;/span>&lt;span class="n">disagreement&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tie&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">identity&lt;/span> &lt;span class="n">masked&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rubric&lt;/span> &lt;span class="n">penalizes&lt;/span> &lt;span class="n">verbosity&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="where-this-does-not-apply">Where this does not apply&lt;/h2>
&lt;p>In keeping with this series&amp;rsquo; convention — the limits of the practice, and what it does not solve:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Same-family judge self-preference must be declared.&lt;/strong> Our primary judge, claude-sonnet-4.6, belongs to the same family as the Claude Opus models that performed skill distillation and adversarial auditing; every &amp;ldquo;strong&amp;rdquo; conclusion of the experiment comes from this same-family judge, carrying a &amp;ldquo;Claude recognizes Claude-style reasoning&amp;rdquo; self-preference risk. There are only two ways out: a third-party judge from a different family, or human-anchor re-verification.&lt;/li>
&lt;li>&lt;strong>The anchor&amp;rsquo;s domain is not the target domain.&lt;/strong> An English anchor only certifies the judge on English argumentative essays; it certifies nothing about judging Chinese physics reports — an external-validity gap. The production-side port hard-codes this warning in its docstring: the pairwise judge&amp;rsquo;s reliability in the Chinese domain is unverified, and it does not run in the real-time path by default.&lt;/li>
&lt;li>&lt;strong>High-band discrimination is unverified.&lt;/strong> Spearman 0.81 is driven mainly by the mid-to-low score range; yet pairwise comparison happens precisely in the &amp;ldquo;neither draft is bad&amp;rdquo; region — the judge&amp;rsquo;s ranking ability there is an untested assumption, and the upper-range collapse is a real threat to it.&lt;/li>
&lt;li>&lt;strong>An LLM judge does not replace a human anchor.&lt;/strong> When all preference evidence comes from LLM judges, systematic LLM-judge preferences cannot be ruled out; the experiment&amp;rsquo;s human blind evaluation (30 pairs) was still being collected at the time of writing, and is honestly listed as the most critical limitation.&lt;/li>
&lt;li>&lt;strong>Do not use a judge for deterministically detectable failures.&lt;/strong> Content loss and structural degradation are handled by a zero-token regression gate (see &lt;a href="https://kaguc.com/blog/deterministic-boundary/">Part 11&lt;/a>) — more correct, and one judge call cheaper per round. The judge belongs only at the preference layer, where deterministic criteria cannot reach.&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>arXivEdits — a dataset of genuine expert revisions (schema: &lt;code>sentence-1&lt;/code>/&lt;code>sentence-2&lt;/code> + &lt;code>intention&lt;/code>); the project filtered by intention for writing-quality edits, taking 200 pairs as strong/weak pairs.&lt;/li>
&lt;li>ASAP-AES 2.0 — the English essay benchmark with real human holistic scores (1–6); obtained via the Hugging Face mirror &lt;code>jatinmehra/Automated-Essay-Scoring-2.0&lt;/code>, with 120 essays stratified-sampled as the credibility anchor.&lt;/li>
&lt;li>First-hand records: every number in this article comes from the project&amp;rsquo;s experiment journal and machine-readable run ledger (journal / runs.tsv); per-essay (id, human, model) scores are persisted to &lt;code>results/qwk_scores.csv&lt;/code> to support recomputation and recalibration.&lt;/li>
&lt;/ul></description></item></channel></rss>