<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Engineering | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/engineering/</link><atom:link href="https://kaguc.com/tag/engineering/index.xml" rel="self" type="application/rss+xml"/><description>Engineering</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>Engineering</title><link>https://kaguc.com/tag/engineering/</link></image><item><title>27 days of AI-assisted development: a retrospective in numbers</title><link>https://kaguc.com/blog/ai-assisted-27-days/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/ai-assisted-27-days/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/ai-assisted-27-days-zh/">中文&lt;/a>. Part 14 of the Engineering LLM Applications series — the closing installment. The previous thirteen articles discussed mechanisms; this one returns to the 27 days that produced them.&lt;/em>&lt;/p>
&lt;h2 id="scope-and-accounting-rules">Scope and accounting rules&lt;/h2>
&lt;p>From 2026-06-10 to 07-06, one person plus a coding agent (Claude Code) built, from scratch, the AI writing agent this series has been citing throughout. Because the project enforced a paper-trail rule from day one — every development conversation produces a devlog entry — this retrospective does not rely on memory. The data comes from 94 devlogs, git history, a count of the test directory, and a line-by-line sum of the research experiment&amp;rsquo;s usage.jsonl. The article is organized as four ledgers: output, discipline, debt, and conclusions. Approximate figures state their accounting basis; anything the logs marked as &amp;ldquo;suspected cause&amp;rdquo; stays hedged here.&lt;/p>
&lt;h2 id="the-output-ledger">The output ledger&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Number&lt;/th>
&lt;th>Basis&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Time span&lt;/td>
&lt;td>27 days&lt;/td>
&lt;td>2026-06-10 → 2026-07-06&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Devlogs&lt;/td>
&lt;td>94 entries&lt;/td>
&lt;td>docs/devlog/, one per conversation; single-day peak of 27 (06-15)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backend&lt;/td>
&lt;td>28 modules / 8,352 lines&lt;/td>
&lt;td>line count over backend/app/*.py&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Clients&lt;/td>
&lt;td>Web frontend + Tauri desktop build&lt;/td>
&lt;td>PyInstaller-frozen backend + Tauri shell + bundled TinyTeX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tests&lt;/td>
&lt;td>351&lt;/td>
&lt;td>count of def test in backend/tests&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Research experiment&lt;/td>
&lt;td>4 days / 20 commits&lt;/td>
&lt;td>writing-judgment-distill, 06-18 → 06-21&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Experiment LLM usage&lt;/td>
&lt;td>3,511 calls / ~7.94M tokens&lt;/td>
&lt;td>summed from results/usage.jsonl: ~5.22M input + ~2.72M output&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Product pivots&lt;/td>
&lt;td>3&lt;/td>
&lt;td>manual workflow → local web app (06-10) → SaaS (06-12) → standalone desktop (06-22)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two notes. First, output came in bursts: of the 94 devlogs, 27 landed on 06-15 alone and 14 on 06-11 — at the granularity of &amp;ldquo;one conversation advances one feature&amp;rdquo;, parallel sprints became the norm. Second, the third pivot (SaaS → standalone) is worth recording as method: every change was flag-gated and off by default; on landing day, all 47 backend tests passed in the default configuration — zero regression in SaaS behavior. The test baseline then grew to 344 passed (devlog figures), with the repository ultimately counting 351 test functions.&lt;/p>
&lt;h2 id="the-discipline-ledger-which-conventions-paid-for-themselves">The discipline ledger: which conventions paid for themselves&lt;/h2>
&lt;p>Of the working conventions written into CLAUDE.md on day one, four were repeatedly proven worth their cost by day 27.&lt;/p>
&lt;p>&lt;strong>A devlog for every conversation.&lt;/strong> Fixed format: goal / what was done / key decisions / files changed / leftovers. The direct payoff is this article — a retrospective that needs no recollection. The larger payoff is attribution: the venv-pollution debt described below can be reconstructed as a complete causal chain from two log entries one hour apart. The research experiment ran the same regime — journal entries are append-only and machine-readable results go into runs.tsv — which is what allowed the summary document to state that &amp;ldquo;all numbers come from recorded experiments.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Git as the safety net: commit every state that runs.&lt;/strong> The very first conversation ended with git init and a commit (f705076, 115 files). None of the three pivots ever lost work: the standalone pivot branched from the current HEAD with zero disturbance to the working tree, preserving the experiment branch&amp;rsquo;s 20 commits intact. &amp;ldquo;If the AI breaks it, roll back&amp;rdquo; went from slogan to daily practice over the 27 days.&lt;/p>
&lt;p>&lt;strong>base_docs is read-only.&lt;/strong> Domain material, few-shot samples, and test data live in a read-only zone; generated artifacts go into separate directories. Through three pivots and dozens of generation rounds, the evidence base was never contaminated.&lt;/p>
&lt;p>&lt;strong>One feature per change.&lt;/strong> Twenty-seven devlogs in one day is not 27× productivity; it is 27 scope-narrowed conversations, paired with &amp;ldquo;one commit plus tests per feature.&amp;rdquo; This is the &lt;a href="https://kaguc.com/blog/deterministic-boundary/">deterministic boundary&lt;/a> applied isomorphically to the development process itself: narrow the scope of every change, and defend existing assets with deterministic gates — tests and git.&lt;/p>
&lt;h2 id="the-debt-ledger-verification-debt-and-environment-debt">The debt ledger: verification debt and environment debt&lt;/h2>
&lt;p>&lt;strong>Verification debt, case one: a batch verification that failed wholesale.&lt;/strong> On 07-05, to benchmark the agent pipeline against the state of the art, a 105-agent web-research workflow was run. Its adversarial verification stage failed as a whole batch when the subscription&amp;rsquo;s session quota was exhausted: all 25 claims came back unverified. The only remedy was manual: pick the 3 papers carrying the most weight — and most likely to have been fabricated by a retrieval agent — and check them line by line against the arXiv originals. One had its core numbers confirmed (revisions regress 16–27% of already-covered content); one was genuine but required two corrections to how it had been cited; one was architecturally real, with its specific numbers absent from the abstract and therefore left marked unverified. The machine produced 25 conclusions in one evening; human verification covered 3. That gap is the shape of verification debt.&lt;/p>
&lt;p>&lt;strong>Verification debt, case two: a conclusion frozen for want of a human anchor.&lt;/strong> The 4-day controlled experiment obtained highly direction-consistent results under LLM-judge scoring (see &lt;a href="https://kaguc.com/blog/distill-judgment-experiment/">part 12&lt;/a>); the 30-pair anonymized blind-evaluation sheet was exported, and the machine&amp;rsquo;s verdict on the same items was B=20 / A=3 / tie=7. But human scores were never collected. The summary document therefore had to settle for: H1 is &amp;ldquo;a strong candidate with method-level evidentiary support — not established.&amp;rdquo; The one step that 3,511 calls and ~7.94M tokens could not buy was precisely the only step independent of the machine.&lt;/p>
&lt;p>&lt;strong>Environment debt: one experiment&amp;rsquo;s contamination, detonating an hour later.&lt;/strong> On 06-23, minted code highlighting was trialed for the standalone build; the conclusion was that it is unworkable under frozen distribution, so the build reverted to the safe default. But the experiment&amp;rsquo;s pip installs had touched the shared venv. One hour later (by log timestamps), the packaged desktop app failed to start; one layer of the four-layer root-cause analysis was a missing pydantic_core — the log annotates it as &amp;ldquo;suspected cause: the minted detour&amp;rsquo;s pip installs overrode the pydantic version.&amp;rdquo; The recorded lesson, verbatim: clean up the venv after temporary experiments.&lt;/p>
&lt;h2 id="the-methodological-conclusion-the-bottleneck-moved-to-verification-and-judgment">The methodological conclusion: the bottleneck moved to verification and judgment&lt;/h2>
&lt;p>The output ledger shows AI assistance expanding the engineering scale one person can drive by an order of magnitude — a full-stack application, desktop distribution, 351 tests, and a controlled experiment advancing simultaneously within 27 days. That is a case judgment against the author&amp;rsquo;s own manual pace, not a controlled measurement. But the three debts point to a single conclusion: &lt;strong>once scale expands, the bottleneck moves from production to verification and judgment.&lt;/strong> Twenty-five claims needed a human reading the originals; H1 needed 30 human blind evaluations; the venv contamination needed a human-driven layer-by-layer diagnosis. Every conclusion the machine produces needs an anchor independent of the machine — deterministic tests, git history, arXiv originals, human scores. The mechanisms discussed across the first thirteen articles of this series are, in the end, ways of manufacturing such anchors at scale.&lt;/p>
&lt;h2 id="where-this-retrospective-does-not-apply">Where this retrospective does not apply&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>N=1.&lt;/strong> One developer, a greenfield project, no collaboration friction, no legacy code. These numbers are a record, not a statistic; extrapolating to teams or brownfield systems has no basis here.&lt;/li>
&lt;li>&lt;strong>Output metrics are not outcome metrics.&lt;/strong> Lines, tests, and devlogs measure production volume; the 27 days include no user data, so product value went unmeasured.&lt;/li>
&lt;li>&lt;strong>Devlog count is not workload.&lt;/strong> Devlogs count conversations; a 27-entry day reflects short, scope-narrowed sessions, not 27× throughput.&lt;/li>
&lt;li>&lt;strong>The token figures cover only the experiment&amp;rsquo;s metering.&lt;/strong> usage.jsonl records the research experiment&amp;rsquo;s API calls only; the coding agent&amp;rsquo;s own usage during main-app development is not metered there, so total cost cannot be extrapolated from these numbers.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Zero regression&amp;rdquo; was purchased in advance.&lt;/strong> Three pivots without incident rested on flag-gating plus a test baseline; without that baseline, the same speed produces silent breakage.&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>The three papers manually re-verified after the 105-agent workflow: &lt;em>Beyond Single-shot Writing: Deep Research Agents are Unreliable at Multi-turn Report Revision&lt;/em> (arXiv:2601.13217; core numbers checked against the original); &lt;em>PaperOrchestra: A Multi-Agent Framework for Automated AI Research Paper Writing&lt;/em> (arXiv:2604.05018; genuine, with two citation corrections); RP-ReAct (arXiv:2512.03560; architecture genuine, specific numbers not independently verified).&lt;/li>
&lt;li>Experiment data and statistical basis: the writing-judgment-distill SUBMISSION_SUMMARY and results/usage.jsonl.&lt;/li>
&lt;/ul></description></item><item><title>Controlled revision in reflection loops</title><link>https://kaguc.com/blog/reflection-gate/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/reflection-gate/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/reflection-gate-zh/">中文&lt;/a>. Part 10 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-why-draft--self-revise--n-often-makes-things-worse">The problem: why &amp;ldquo;draft → self-revise × N&amp;rdquo; often makes things worse&lt;/h2>
&lt;p>The standard closing act of an autonomous generation pipeline is a reflection loop: once the first draft exists, let the model critique and revise it for a few rounds. Intuition says more rounds can&amp;rsquo;t hurt. Our first implementation was a direct transcription of that intuition — critique produced a prose commentary, revise rewrote the whole document accordingly, and the new draft replaced the previous one unconditionally:&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">msgs&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;assistant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">draft&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">yield&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;draft&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;text&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">draft&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="c1"># keep this version unconditionally&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Empirical measurement says the intuition is wrong. arXiv:2601.13217 (&lt;em>Beyond Single-shot Writing: Deep Research Agents are Unreliable at Multi-turn Report Revision&lt;/em> — we read the arXiv original to verify it) measured multi-turn revision in deep research agents: when handling feedback, full-rewrite revision &lt;strong>regresses 16–27% of already-covered content on average&lt;/strong>, with citation quality degrading alongside. More alarming: &lt;strong>unguided &amp;ldquo;please reflect and revise yourself&amp;rdquo; is ineffective for almost every agent tested&lt;/strong> — only 1 of 5 improved (+3.6%) — and it causes &lt;strong>more collateral damage&lt;/strong> than targeted feedback. On the mechanism side, Self-Refine (arXiv:2303.17651, retrieval excerpt, not adversarially verified) is consistent: feedback must be specific and actionable, generic feedback measurably hurts, gains concentrate in the first 1–2 rounds, and tasks where errors are hard to self-detect see almost no benefit. The positive contrast is PaperOrchestra (arXiv:2604.05018): its refinement loop carries an explicit &amp;ldquo;accept only if better, revert and stop if worse&amp;rdquo; gate and reports a 79–81% win rate with 0% degradation (a body-text detail, not independently verified).&lt;/p>
&lt;p>Held against that evidence, our first implementation stepped on all three mines:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Evidence&lt;/th>
&lt;th>Corresponding rework&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Full-document rewrite&lt;/td>
&lt;td>Regresses 16–27% of covered content, citation degradation (2601.13217, manually verified)&lt;/td>
&lt;td>Rework 1 + Rework 2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open-ended prose feedback&lt;/td>
&lt;td>Generic feedback measurably hurts; unguided self-reflection nearly useless with more collateral damage&lt;/td>
&lt;td>Rework 1: locate to section + actionable step&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No accept/revert gate&lt;/td>
&lt;td>Only gated refinement achieves 79–81% / 0% degradation (PaperOrchestra, not independently verified)&lt;/td>
&lt;td>Rework 3: zero-token regression gate&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three sections below walk through the rework in order of increasing invasiveness. The context is a LaTeX report-generation agent (scan → explore → figures → write → reflect × N → bib → compile self-repair), but the mechanisms are format-agnostic.&lt;/p>
&lt;h2 id="rework-1-reflection-outputs-a-structured-edit-plan">Rework 1: reflection outputs a structured edit plan&lt;/h2>
&lt;p>The first cut lands on the shape of the feedback. The critique is no longer prose; it is forced into a JSON edit plan (prompt translated here — the production prompt is Chinese):&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">_REFLECT_SYS&lt;/span> &lt;span class="o">=&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;You are a reviewer of lab reports / course notes. Check the current draft &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;against the source material and identify only problems that genuinely need &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;fixing, locating each one to a specific section (give its anchor, e.g. n2); &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;use &lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">whole&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2"> for cross-section / overall-structure / abstract-and-keywords &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;issues. Each item carries an issue and an actionable action. Do not mention &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;what is already fine; no generic polishing. Output strictly JSON: &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;{&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">changes&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">:[{&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">section&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">n2 or whole&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">,&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">issue&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\&amp;#34;\&amp;#34;&lt;/span>&lt;span class="s2">,&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">action&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\&amp;#34;\&amp;#34;&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="s2">&amp;#34;if the draft is acceptable overall, output {&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">changes&lt;/span>&lt;span class="se">\&amp;#34;&lt;/span>&lt;span class="s2">:[]} and nothing else.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Four design points:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Every issue must be located to a specific section&lt;/strong> (the anchor assigned by the sectionizer, e.g. &lt;code>n2&lt;/code>); cross-section, overall-structure, and abstract/keywords issues are explicitly marked &lt;code>&amp;quot;whole&amp;quot;&lt;/code>. Location is not decoration — it decides which application path the item takes next.&lt;/li>
&lt;li>&lt;strong>Issue and action come as a pair.&lt;/strong> &amp;ldquo;The error analysis is shallow&amp;rdquo; alone is not enough; an executable action is required — the direct application of Self-Refine&amp;rsquo;s &amp;ldquo;feedback must be specific and actionable&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The empty array is allowed.&lt;/strong> &lt;code>{&amp;quot;changes&amp;quot;:[]}&lt;/code> means &amp;ldquo;acceptable overall&amp;rdquo; and terminates the reflection loop early. Revision flips from &amp;ldquo;change by default&amp;rdquo; to &amp;ldquo;no change by default, change only with cause&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Parse failure never breaks the flow.&lt;/strong> &lt;code>_parse_reflect_plan&lt;/code> returns an empty list for non-JSON or missing &lt;code>changes&lt;/code>, which the caller treats as &amp;ldquo;no changes needed&amp;rdquo;; plans are capped at 8 items; the title fallback when an anchor misses is &amp;ldquo;exact match first, then the longest contained title&amp;rdquo; — so &amp;ldquo;Results&amp;rdquo; cannot steal a match meant for &amp;ldquo;Results and Discussion&amp;rdquo;.&lt;/li>
&lt;/ol>
&lt;p>A companion change: reflection now runs on a fresh minimal context (system + source material + current draft) instead of dragging along the full exploration transcript; the material sits as a stable prefix and hits the cache across rounds.&lt;/p>
&lt;h2 id="rework-2-pure-section-level-issues-go-through-scoped-per-section-splicing">Rework 2: pure section-level issues go through scoped per-section splicing&lt;/h2>
&lt;p>Each plan item is routed by its location. Pure section-level issues no longer trigger a full rewrite; they reuse the mature machinery of the interactive editing side (sectionizer, scoped prompts, fragment health check, deterministic splice — the toolkit of &lt;a href="https://kaguc.com/blog/versioning-scoped-edit/">Part 07&lt;/a>), applied section by section. For each item, &lt;code>_scoped_reflect_apply&lt;/code> re-parses and re-resolves the target on the &lt;strong>current working text&lt;/strong> (offsets have moved after the previous splice), sends the model only the target section plus that item&amp;rsquo;s issue/action plus relevant material, and health-checks the returned fragment (non-empty, balanced braces, balanced environments, no &lt;code>\documentclass&lt;/code>). A fragment that fails the check causes that section to be skipped — &lt;strong>a bad fragment is never spliced back&lt;/strong>. A fragment that passes is spliced in, with every other section byte-identical. Only cross-section issues, abstract/keywords issues, or drafts that cannot be sectionized fall back to a whole-document minimal rewrite.&lt;/p>
&lt;p>This step has one implementation pitfall, found by an internal adversarial audit (rated [medium]):&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="c1"># The fragment must preserve the count of top-level \section commands within the&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># replaced span (usually =1; =0 for a subsection) — otherwise the splice changes&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the document&amp;#39;s section count, the positional anchors of subsequent scoped items&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># in the same round drift and edit the wrong section, and the &amp;#34;added sections&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># direction is not caught by regression_check (it only reports decreases).&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Count mismatch → skip this section; never splice a fragment that would shift things.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nb">len&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">findall&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">r&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\\&lt;/span>&lt;span class="s2">section\{&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_text&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nb">len&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">findall&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">r&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\\&lt;/span>&lt;span class="s2">section\{&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">section_src&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The model occasionally gets &amp;ldquo;helpful&amp;rdquo; and splits one section into two: the fragment itself is healthy and passes every check, but after splicing, the document&amp;rsquo;s section count goes up by one, the positional anchors of all subsequent plan items in the same round drift, and the wrong sections get edited — while the regression gate only reports section &lt;em>decreases&lt;/em>, leaving this path with no backstop. The conservation check closes it off before the splice.&lt;/p>
&lt;h2 id="rework-3-the-zero-token-regression-gate">Rework 3: the zero-token regression gate&lt;/h2>
&lt;p>Whether a round went through scoped splicing or the whole-document fallback, its output passes &lt;code>regression_check&lt;/code> before landing — six criteria, all deterministic, zero API calls:&lt;/p>
&lt;ul>
&lt;li>loss of the compilable skeleton (&lt;code>\documentclass&lt;/code> / &lt;code>\end{document}&lt;/code>);&lt;/li>
&lt;li>length collapse below 60% of the previous version (truncation, or &amp;ldquo;rest unchanged&amp;rdquo;-style elision);&lt;/li>
&lt;li>structural score decrease;&lt;/li>
&lt;li>shrinkage of the &lt;code>\cite&lt;/code> key set (with the lost keys reported);&lt;/li>
&lt;li>fewer sections;&lt;/li>
&lt;li>fewer figures or data tables.&lt;/li>
&lt;/ul>
&lt;p>Any trigger means regressed: revert to the previous version and stop reflecting. If the two versions are character-identical after whitespace stripping, the result is &lt;code>converged&lt;/code> — also an early stop, so convergence wastes no rounds.&lt;/p>
&lt;p>Why not use an LLM judge as the gate? The reason is written in the module&amp;rsquo;s own comments: this particular failure mode — losing content — &lt;strong>is deterministically detectable&lt;/strong>, whereas the pairwise judge&amp;rsquo;s reliability in the Chinese domain is unverified (the judge module&amp;rsquo;s own caveat) and would cost one extra real call per round. More correct, and cheaper. The judge&amp;rsquo;s proper place is offline evaluation, not an online gate; the validity question is the subject of &lt;a href="https://kaguc.com/blog/judge-validity/">Part 09&lt;/a>.&lt;/p>
&lt;p>The gate has its own edge case: in the pipeline, bibliography generation runs &lt;strong>after&lt;/strong> reflection, so during reflection the bib is always empty — which makes the &amp;ldquo;citations closed&amp;rdquo; check false for any draft containing &lt;code>\cite&lt;/code>, and a newly added citation would be misread as a structural-score drop, wrongly reverting a good edit. The fix: while the bib is empty, that check is excluded from the gate score, and citation loss is judged separately by &amp;ldquo;the key set must not shrink&amp;rdquo; — pinned by a dedicated regression test (empty bib produces no false positive).&lt;/p>
&lt;p>Gate decisions are surfaced as persisted step events (visible in the log page and across reconnects), with a structured &lt;code>reflect_gate&lt;/code> field attached to the trace so the experiment bench can observe whether reflection is actually improving quality and which round reverted.&lt;/p>
&lt;h2 id="the-reworked-loop-end-to-end">The reworked loop, end to end&lt;/h2>
&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[Current draft vN] --&amp;gt; B[Reviewer on fresh minimal context: JSON edit plan]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|changes empty| S[Judged acceptable, early stop]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|pure section-level| C[Scoped per-section edit + splice, other sections byte-identical]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|contains whole items| D[Whole-document minimal rewrite]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt; G{Zero-token regression_check}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> D --&amp;gt; G
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt;|degraded| X[Revert to vN, stop reflecting]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt;|converged| S
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt;|pass| V[Commit as vN+1, next round]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tests arrived in batches with the rework: when the gate landed, &lt;code>test_eval_report&lt;/code> +7 (accept / revert / converge / lost section / lost citation / length collapse / empty-bib no false positive) and &lt;code>test_run_agent_mock&lt;/code> +1 (degraded revision → revert to previous version and stop), with 303 backend tests green; 306 after scoped reflection landed; 322 by the end of this round of work (plus 19 on the experiment bench). Generation quality itself cannot be tested offline — but every deterministic component of the loop can: plan parsing, routing, splicing, the gate.&lt;/p>
&lt;h2 id="lessons-from-the-research-process-itself">Lessons from the research process itself&lt;/h2>
&lt;p>The evidence chain behind this rework deserves its own post-mortem. To benchmark against the state of the art, we ran a 105-agent networked research workflow; the adversarial-verification stage &lt;strong>failed wholesale&lt;/strong> when the subscription session quota was exhausted, leaving all 25 claims stuck at unverified. So we manually re-verified the 3 most load-bearing papers — the ones a retrieval agent would most plausibly fabricate — by reading the arXiv originals. 2601.13217: real, core numbers confirmed. PaperOrchestra: real, but with two corrections — it is not a Google paper, and its abstract describes itself as flexible rather than rigidly fixed, so it can only support &amp;ldquo;structured multi-agent division of labor beats autonomous loops&amp;rdquo; (win rate +50–68% / overall quality +14–38%), not &amp;ldquo;the more rigid the better&amp;rdquo;. RP-ReAct (arXiv:2512.03560): the architecture is real, but the specific scores in the retrieval claim about &amp;ldquo;being beaten by vanilla ReAct on simple tasks&amp;rdquo; do not appear in the abstract — plausibly extracted or confabulated from the body text by the retrieval agent, so they are marked unverified. One more calibration correction: the retrieval claim said &amp;ldquo;break rate 21–31%&amp;rdquo;, which is the body-text framing; the abstract says 16–27%, and this article uses the abstract&amp;rsquo;s figure.&lt;/p>
&lt;p>The lesson is isomorphic to the article&amp;rsquo;s thesis: &lt;strong>a retrieval agent&amp;rsquo;s output needs an accept/revert gate of its own&lt;/strong> — manual verification is the regression_check of the research workflow.&lt;/p>
&lt;h2 id="when-not-to-do-this">When not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>The gate&amp;rsquo;s criteria are proxies: they prevent getting worse, they do not certify getting better.&lt;/strong> Section counts, citation sets, and length all measure structural preservation; content that is wrong but structurally intact passes the gate. Preference-level quality still requires offline evaluation and judges — this gate does not replace them.&lt;/li>
&lt;li>&lt;strong>Scoped reflection presupposes sectionizable structure.&lt;/strong> A draft that cannot be parsed goes straight down the whole-document fallback — that fallback is not a defect, it is an acknowledgment of the boundary.&lt;/li>
&lt;li>&lt;strong>When the structure itself is wrong, scoped editing is powerless.&lt;/strong> Problems requiring global reorganization (moving sections, merging sections, changing the narrative line) must be marked whole and take the full rewrite; forcing them through per-section edits produces drafts that are locally fluent and globally incoherent.&lt;/li>
&lt;li>&lt;strong>Do not bolt this gate onto loops that already have an external signal.&lt;/strong> The compile self-repair loop has the compiler as its referee and stops on no-progress error fingerprints — a different kind of closed loop, and already the right design; adding a structural gate to it is redundant construction.&lt;/li>
&lt;li>&lt;strong>Keep N small.&lt;/strong> Self-Refine reports gains concentrated in the first 1–2 rounds (not adversarially verified), and 2601.13217 shows the break rate is the bottleneck of multi-turn revision — budget spent on a fifth reflection round is most likely paying for a revert.&lt;/li>
&lt;/ol>
&lt;p>This article pairs with &lt;a href="https://kaguc.com/blog/deterministic-boundary/">Part 11, &amp;ldquo;The deterministic boundary&amp;rdquo;&lt;/a>: that article promotes the gate and the scoped machinery into a unified architectural principle; this one is the complete rework record of that principle applied to a single closed loop — the reflection loop.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;em>Beyond Single-shot Writing: Deep Research Agents are Unreliable at Multi-turn Report Revision&lt;/em> (arXiv:2601.13217) — full-rewrite revision regresses 16–27% of covered content on average with citation degradation; unguided self-reflection is nearly useless and causes more collateral damage; its named remedies: structured edit plans and a dedicated Reviser. Verified against the original.&lt;/li>
&lt;li>&lt;em>PaperOrchestra: A Multi-Agent Framework for Automated AI Research Paper Writing&lt;/em> (arXiv:2604.05018) — structured multi-agent pipelines beat autonomous baselines by +50–68% / +14–38% (verified); its accept-or-revert refinement gate reports a 79–81% win rate with 0% degradation (body-text detail, not independently verified).&lt;/li>
&lt;li>&lt;em>Self-Refine: Iterative Refinement with Self-Feedback&lt;/em> (arXiv:2303.17651) — feedback must be specific and actionable, generic feedback measurably hurts; gains concentrate in the first 1–2 rounds; tasks where errors are hard to self-detect need external signals. Retrieval excerpt, not adversarially verified.&lt;/li>
&lt;li>&lt;em>Reason-Plan-ReAct&lt;/em> (arXiv:2512.03560) — plan/execute decoupled architecture (verified); the specific scores for &amp;ldquo;beaten by vanilla ReAct on simple tasks&amp;rdquo; are absent from the abstract and unverified.&lt;/li>
&lt;/ul></description></item><item><title>Distilling judgment, not structure: a controlled experiment</title><link>https://kaguc.com/blog/distill-judgment-experiment/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/distill-judgment-experiment/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/distill-judgment-experiment-zh/">中文&lt;/a>. Part 12 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-hypothesis-can-distilled-judgment-beat-a-distilled-template">The hypothesis: can distilled judgment beat a distilled template?&lt;/h2>
&lt;p>Hand expert exemplars to a model and ask it to induce a reusable writing skill (a prompt), and there are two routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Path A (structural template)&lt;/strong>: extract &amp;ldquo;how many parts, what goes into each, in what order, in what format&amp;rdquo;;&lt;/li>
&lt;li>&lt;strong>Path B (judgment logic)&lt;/strong>: contrastive rationale extraction — show the model strong and weak versions of the same task and force it to articulate &lt;em>why the strong one is better, what it assumes about the reader, how a novice gets it wrong&lt;/em>; aggregate the output into judgment rules + do/don&amp;rsquo;ts + a reader model.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>H1: the Path-B skill beats the Path-A skill in pairwise preference judging.&lt;/strong> Everything is inference-only — no training, no fine-tuning, LLM APIs all the way down. Most prompt-engineering practice defaults to Path A; if H1 holds, there are many places where the recipe should change. This article is a full experiment report, including three methodological corrections that each came close to voiding the conclusion.&lt;/p>
&lt;h2 id="design-control-everything-except-one-variable">Design: control everything except one variable&lt;/h2>
&lt;p>All data comes from public benchmarks. Exemplars and strong/weak pairs are drawn from ASAP-AES 2.0 (English argumentative essays, human holistic scores 1–6): exemplars are score-6 essays; strong/weak pairs contrast score 5–6 against score 1–2 whole essays. A further 120 human-scored essays (20 per band) serve as a judge-credibility anchor, and the ablation uses arXivEdits same-sentence before/after expert rewrites.&lt;/p>
&lt;p>The control discipline: A and B share the same exemplar input, the same generation model (DeepSeek-v4-pro, T=0.7), the same tasks, the same length constraint (roughly 250–400 words, no padding), and the same judges — the only variable is the distilled skill. Three bias controls on the judging side: position swapping (a win counts only if both presentation orders agree, otherwise it is a tie), identity masking (judges see only &amp;ldquo;Essay One / Essay Two&amp;rdquo;), and length neutrality (the rubric explicitly ignores length and penalizes verbosity). Dual judges: Claude-Sonnet-4.6 (primary) + Qwen3.7-max (cross-check).&lt;/p>
&lt;p>The two distilled prompts are distinguishable at a glance. skill_A is position-and-proportion instructions throughout; a typical entry:&lt;/p>
&lt;blockquote>
&lt;p>Introduction (1 paragraph) … A clear thesis statement that takes a definite position. … Relative length: &lt;strong>3–5 sentences.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>Every entry in skill_B carries a &amp;ldquo;for whom, and why&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>When you acknowledge a counterargument, respond with a genuine rebuttal – not dismissal – &lt;strong>because your reader expects fairness and intellectual seriousness.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;h2 id="statistical-lesson-1-effective-n--1">Statistical lesson 1: effective N ≈ 1&lt;/h2>
&lt;p>The first round distilled a single pair (skill_A, skill_B), generated on 30 tasks, judged pairwise, and ran a binomial test treating the 30 pairings as independent samples: p ≈ 7×10⁻⁹. A beautiful number built on a broken inference: those 30 pairings share one and the same skill pair — only the tasks vary. What the test measures is &amp;ldquo;how stable this one skill pair is across 30 tasks&amp;rdquo;. H1, however, is a claim about &lt;em>methods&lt;/em>, and the independent unit for that claim is the skill — of which there was exactly one pair. &lt;strong>Effective N ≈ 1.&lt;/strong>&lt;/p>
&lt;p>The redesign: first expand the exemplar pool from 12 to ≥24 essays, then use distinct random seeds to draw 6-exemplar subsets (Path B draws matching strong/weak-pair subsets), distilling &lt;strong>K=10 independent skill pairs&lt;/strong>. Each pair generates and is judged on a shared task set (15 pairings per pair entered the statistics as executed), yielding one preference score w_k per pair; the test then runs at the skill level — Wilcoxon signed-rank on whether the K values of w_k are systematically above 0.5, with confidence intervals from a cluster bootstrap that resamples by skill. At the redesign&amp;rsquo;s budgeted scale this comes to 1200+ LLM calls across generation, judging, and distillation — roughly 5–6× the first round. Statistical honesty costs money.&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 LR
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> P[Exemplar pool ≥24] --&amp;gt;|seed k draws 6 exemplars + pair subset| D[Distill K=10 pairs&amp;lt;br/&amp;gt;skill_A_k and skill_B_k]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> D --&amp;gt; G[Shared tasks ×2 generations per pair&amp;lt;br/&amp;gt;same model · T=0.7 · 250–400 words]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt; J[Dual-judge pairwise eval&amp;lt;br/&amp;gt;position swap · masking · length-neutral]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> J --&amp;gt; W[One preference score w_k&amp;lt;br/&amp;gt;per pair per judge]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> W --&amp;gt; S[Skill-level Wilcoxon&amp;lt;br/&amp;gt;+ cluster bootstrap CI]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="statistical-lesson-2-metrics-degenerate-silently">Statistical lesson 2: metrics degenerate silently&lt;/h2>
&lt;p>A measurement check before the redesign saved the entire test: &lt;strong>across 55 decided pairings under two judges and two rounds, A had won zero times&lt;/strong> — all the variation lay between &amp;ldquo;B wins&amp;rdquo; and &amp;ldquo;tie&amp;rdquo;. Define w_k = B/(B+A) as intuition suggests, and with A ≈ 0 almost every w_k collapses to exactly 1.0 — K samples with zero variance, and the Wilcoxon test degenerates into measuring nothing. The fix is a tie-inclusive preference score:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">w_k = (B wins + 0.5 × ties) / T
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ties pull w_k toward 0.5 and restore real variance: on the original single pair this measures ≈0.97 under Claude and ≈0.73 under Qwen — the effect-size gap between the two judges is preserved and becomes testable.&lt;/p>
&lt;h2 id="results-direction-rock-stable-effect-size-judge-dependent">Results: direction rock-stable, effect size judge-dependent&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Judge&lt;/th>
&lt;th>w_k mean&lt;/th>
&lt;th>median&lt;/th>
&lt;th>std&lt;/th>
&lt;th>95% CI (cluster bootstrap)&lt;/th>
&lt;th>w_k&amp;gt;0.5&lt;/th>
&lt;th>Wilcoxon two-tailed p&lt;/th>
&lt;th>old &amp;ldquo;per-pairing&amp;rdquo; p (overestimate, do not cite)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude (primary)&lt;/td>
&lt;td>&lt;strong>0.830&lt;/strong>&lt;/td>
&lt;td>0.833&lt;/td>
&lt;td>0.143&lt;/td>
&lt;td>[0.747, 0.913]&lt;/td>
&lt;td>&lt;strong>10/10&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0020&lt;/strong>&lt;/td>
&lt;td>7.95e-24&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen (cross)&lt;/td>
&lt;td>0.617&lt;/td>
&lt;td>0.617&lt;/td>
&lt;td>0.086&lt;/td>
&lt;td>[0.567, 0.667]&lt;/td>
&lt;td>9/10&lt;/td>
&lt;td>0.0039&lt;/td>
&lt;td>2.06e-06&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Four notes that must be read alongside the table:&lt;/p>
&lt;ol>
&lt;li>The old per-pairing p values are flagged &lt;em>in the statistics script&amp;rsquo;s own output&lt;/em>: &amp;ldquo;&lt;strong>overestimate: K×T pairings are non-independent; shown for contrast only, do not cite&lt;/strong>&amp;rdquo;. Landing a known-misleading number on disk together with its warning is more reliable than clarifying it verbally afterwards.&lt;/li>
&lt;li>&lt;strong>p=0.0020 is a floor, not an effect size.&lt;/strong> When all K=10 pairs point the same way, the exact two-tailed Wilcoxon p bottoms out at 2/2¹⁰ ≈ 0.0020 — equivalent to a sign test. It encodes only &amp;ldquo;10 pairs, one direction&amp;rdquo;, not &lt;em>by how much&lt;/em>: w_k values of all-0.51 and all-0.99 yield the identical p, and p&amp;lt;0.001 is unreachable before K≥17. Effect-size evidence lives in the CIs.&lt;/li>
&lt;li>&lt;strong>Qwen&amp;rsquo;s &amp;ldquo;significance&amp;rdquo; carries less weight&lt;/strong>: tie-dominated, swap-consistency mostly 0.2–0.5, CI lower bound 0.567 hugging 0.5. But a decided-only sensitivity analysis (ties removed) still shows 9/10 above 0.5 with zero net losses — the significance is not manufactured by tie-weighting.&lt;/li>
&lt;li>&lt;strong>A is not winless&lt;/strong>: A won individual pairings in 4 pairs under Claude and 8 under Qwen — but B suffered a net loss in no pair under either judge.&lt;/li>
&lt;/ol>
&lt;h2 id="ablation-does-the-judgment-signal-survive-same-sentence-pairs">Ablation: does the judgment signal survive same-sentence pairs?&lt;/h2>
&lt;p>B&amp;rsquo;s contrast source is cross-topic whole-essay pairs — its wins could be contaminated by topic/content differences rather than pure &amp;ldquo;judgment&amp;rdquo;. Swapping the contrast source for arXivEdits same-sentence rewrite pairs (near-zero topic difference) yields B_sent, run over the same 10-pair protocol:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Judge&lt;/th>
&lt;th>B_sent w_k mean&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;th>pairs &amp;gt;0.5&lt;/th>
&lt;th>vs A (Wilcoxon p)&lt;/th>
&lt;th>B vs B_sent&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude&lt;/td>
&lt;td>&lt;strong>0.720&lt;/strong>&lt;/td>
&lt;td>[0.663, 0.773]&lt;/td>
&lt;td>10/10&lt;/td>
&lt;td>&lt;strong>0.0020&lt;/strong> (significant)&lt;/td>
&lt;td>+0.110, p=0.186 (n.s.)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen&lt;/td>
&lt;td>0.567&lt;/td>
&lt;td>[0.493, 0.640]&lt;/td>
&lt;td>6/10&lt;/td>
&lt;td>0.109 (not significant)&lt;/td>
&lt;td>+0.050, p=0.281 (n.s.)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading stays conservative: under Claude, B_sent still significantly beats A and is statistically indistinguishable from B, suggesting the advantage stems from judgment rather than topic. But Qwen does not reproduce the ablation, and a genre confound remains — arXivEdits are scientific-paper sentences while the generation task is argumentative essays, so &amp;ldquo;same-sentence&amp;rdquo; and &amp;ldquo;genre&amp;rdquo; changed simultaneously; the clean single-factor control has not been run.&lt;/p>
&lt;h2 id="judgment-purity-an-adversarial-audit-revises-the-self-assessment-down">Judgment purity: an adversarial audit revises the self-assessment down&lt;/h2>
&lt;p>One question could still overturn the whole narrative: are skill_B&amp;rsquo;s entries genuine judgment rules, or structural descriptions in new clothing? LLM self-assessment cannot be trusted — the pilot&amp;rsquo;s sharpness self-check reported 100%. Instead, an 11-agent adversarial audit (executed by Claude Opus 4.8: classify every entry as judgment/structure, cross-compare against the paired skill_A for re-skinning, and specifically attack &amp;ldquo;is the &lt;em>because&lt;/em> a tautology?&amp;rdquo;) spot-checked 5 pairs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pair&lt;/th>
&lt;th>Initial self-ratio&lt;/th>
&lt;th>After adversarial review&lt;/th>
&lt;th>Overturned entries (re-skinning suspects)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>87%&lt;/td>
&lt;td>&lt;strong>80%&lt;/strong>&lt;/td>
&lt;td>context-in-opening ≈ A&amp;rsquo;s Intro recipe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>82%&lt;/td>
&lt;td>&lt;strong>73%&lt;/strong>&lt;/td>
&lt;td>&amp;ldquo;vivid example&amp;rdquo; ≈ show-don&amp;rsquo;t-tell tautology&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>73%&lt;/td>
&lt;td>&lt;strong>67%&lt;/strong>&lt;/td>
&lt;td>ordering by persuasiveness ≈ structural organization&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>82%&lt;/td>
&lt;td>&lt;strong>64%&lt;/strong>&lt;/td>
&lt;td>rebuttal / closing ≈ A&amp;rsquo;s counterargument / conclusion sections&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>100%&lt;/td>
&lt;td>&lt;strong>90%&lt;/strong>&lt;/td>
&lt;td>point ordering = an organization rule&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>After adversarial review the mean drops to &lt;strong>0.746&lt;/strong>, yet all 5/5 pairs remain judgment-dominated and substantively distinct from skill_A (which is position/format instructions throughout, with zero reasoning). Verdict: &amp;ldquo;&lt;strong>pass (with conditions)&lt;/strong>&amp;rdquo; — 0.75 is an upper bound that includes redundant clusters, and pair 4 is the weakest link (0.64). The overturned entries share one pattern: a structural action glued to a near-vacuous &lt;em>because&lt;/em>. Note that this remains automated AI review, not independent human verification. (Adversarial review as an engineering practice is the subject of Part 13, &lt;a href="https://kaguc.com/blog/adversarial-review/">Multi-agent adversarial review as an engineering practice&lt;/a>.)&lt;/p>
&lt;h2 id="seven-honest-limitations">Seven honest limitations&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>No human anchor collected (the most fatal).&lt;/strong> A 30-pair blind-evaluation list has been exported (the machine ruling on the same pairings: B=20 / A=3 / tie=7), but human scores are not yet in. Until then, every piece of preference evidence comes from LLM judges, and a systematic LLM-judge preference cannot be ruled out.&lt;/li>
&lt;li>&lt;strong>Judge–distiller family overlap.&lt;/strong> The primary judge, Claude-Sonnet, shares a model family with the Claude Opus that performed skill distillation and the purity audit; every &amp;ldquo;strong&amp;rdquo; result happens to come from that same-family judge — a self-preference risk.&lt;/li>
&lt;li>&lt;strong>p bottomed out; effective N still optimistic.&lt;/strong> p=0.0020 encodes only &amp;ldquo;10/10 same direction&amp;rdquo;; and the 10 subsets, drawn from a finite pool, necessarily overlap — &amp;ldquo;effective N=10&amp;rdquo; is not strictly i.i.d. and remains an overestimate.&lt;/li>
&lt;li>&lt;strong>Effect size is judge-sensitive.&lt;/strong> Claude 0.83, Qwen 0.62: &amp;ldquo;B does not lose to A&amp;rdquo; is solid; &amp;ldquo;how much better B is&amp;rdquo; is not settled.&lt;/li>
&lt;li>&lt;strong>Judge range collapse.&lt;/strong> QWK=0.456 (Spearman 0.813), and the confusion matrix shows the judge never awards a 5 or 6 — discrimination at the high end is unverified, precisely the region where B vs A (&amp;ldquo;both essays are decent&amp;rdquo;) is being compared. The systematic treatment of judge validity is Part 9, &lt;a href="https://kaguc.com/blog/judge-validity/">Validity engineering for LLM-as-judge&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Single-judge ablation + genre confound.&lt;/strong> The same-sentence ablation is significant only under Claude, and it mixes in the scientific-sentence → argumentative-essay genre variable.&lt;/li>
&lt;li>&lt;strong>Mechanism and external validity.&lt;/strong> The secondary signal — rhetorical move coverage, A 0.89 vs B 0.86 (a weak negative conclusion: no evidence that B wins by stacking moves) — comes from the pilot, was not re-measured in the main experiment, and carries no statistical test; and only one generation model and one genre were tested.&lt;/li>
&lt;/ol>
&lt;p>The conclusion therefore freezes as: &lt;strong>under the LLM-as-judge lens, &amp;ldquo;learn judgment &amp;gt; learn structure&amp;rdquo; receives preliminary support that is method-level, directionally highly consistent, and purity-checked (with conditions); H1 is a well-evidenced candidate, not an established result.&lt;/strong> Establishing it requires human evaluation, a third-party judge, and a clean same-sentence same-genre ablation.&lt;/p>
&lt;h2 id="when-not-to-do-this">When not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>If a deterministic metric exists, skip the pairwise judges.&lt;/strong> This heavyweight statistical apparatus is only warranted where quality can &lt;em>only&lt;/em> be measured by preference comparison; wherever an assertion can be written, one line of assertion beats 800 judging calls.&lt;/li>
&lt;li>&lt;strong>Path B presupposes strong/weak pairs.&lt;/strong> Contrastive distillation extracts rationale by confronting strong and weak versions; in domains with no natural quality banding (such as human scores) and no credible synthetic degradation, Path B has nothing to contrast.&lt;/li>
&lt;li>&lt;strong>If the budget cannot afford K pairs, do not claim a method-level conclusion.&lt;/strong> Single-pair results support only &amp;ldquo;B beat A on this pair&amp;rdquo;; treating the task count as the sample size was this experiment&amp;rsquo;s earliest mistake — and the easiest one to make.&lt;/li>
&lt;li>&lt;strong>Structural templates are not useless.&lt;/strong> Path A matches B on rhetorical move coverage (0.89 vs 0.86); when the task&amp;rsquo;s bottleneck is format compliance rather than quality judgment, Path A is cheaper and steadier.&lt;/li>
&lt;/ol></description></item><item><title>Document versioning and scoped editing</title><link>https://kaguc.com/blog/versioning-scoped-edit/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/versioning-scoped-edit/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/versioning-scoped-edit-zh/">中文&lt;/a>. Part 7 of the Engineering LLM Applications series. The evidence base is the same as in earlier parts: the implementation, devlogs, and test suite of a production-grade AI writing agent (FastAPI + React + Tauri).&lt;/em>&lt;/p>
&lt;h2 id="the-problem-the-model-edits-the-document--what-guards-the-document">The problem: the model edits the document — what guards the document?&lt;/h2>
&lt;p>A typical incident from live testing: the user types feedback like &amp;ldquo;polish this&amp;rdquo;, &amp;ldquo;fix the bibliography&amp;rdquo;, &amp;ldquo;change Table 1&amp;rdquo; into the chat box, and some of these &lt;strong>silently rewrite the entire document&lt;/strong> — the result lands directly as a new version, and the user discovers only afterwards that the change went far beyond what they asked for. In this class of application, the document is the user&amp;rsquo;s core asset, and every model output can touch it. Translating the problem from &amp;ldquo;model behavior&amp;rdquo; into &amp;ldquo;data model&amp;rdquo;, the requirements converge on four:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Every version recoverable&lt;/strong> — the state before any rewrite can be brought back;&lt;/li>
&lt;li>&lt;strong>Every edit rejectable&lt;/strong> — model output is a proposal first, and becomes fact only when the user accepts;&lt;/li>
&lt;li>&lt;strong>Every edit addressable&lt;/strong> — &amp;ldquo;change only this section&amp;rdquo; requires a deterministic answer to &amp;ldquo;which bytes are this section&amp;rdquo;;&lt;/li>
&lt;li>&lt;strong>Review cost bounded&lt;/strong> — the user must be able to see what changed before deciding.&lt;/li>
&lt;/ol>
&lt;p>None of these can be guaranteed by prompting. The rest of this article walks through our implementation layer by layer: the version chain, the proposal ledger, the sectionizer, and the division of diff labor.&lt;/p>
&lt;h2 id="the-version-chain-restore-forks-never-overwrites">The version chain: restore forks, never overwrites&lt;/h2>
&lt;p>The storage layer is one &lt;code>document_versions&lt;/code> table (SQLite, single-user local application):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">document_versions&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">AUTOINCREMENT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sessions&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DELETE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">CASCADE&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">version_no&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- LaTeX source
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">change_summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">parent_version_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three design points:&lt;/p>
&lt;p>&lt;strong>Full snapshot per version, no deltas.&lt;/strong> &lt;code>content&lt;/code> stores the entire source. The cost is storage redundancy; the payoff is that reading any version requires zero replay and both ends of any diff are always materialized. For LaTeX documents in the tens-of-kilobytes range, this trade-off is not close.&lt;/p>
&lt;p>&lt;strong>&lt;code>version_no&lt;/code> is numbered per session.&lt;/strong> &lt;code>add_version&lt;/code> takes &lt;code>MAX(version_no)+1&lt;/code> within the session: the user always sees this document&amp;rsquo;s v1/v2/v3, never a global v847; the global autoincrement &lt;code>id&lt;/code> exists only for foreign keys and lineage. A dedicated test pins this: interleave versions across two sessions, and the second session&amp;rsquo;s first version is still 1.&lt;/p>
&lt;p>&lt;strong>&lt;code>parent_version_id&lt;/code> records lineage; restore = fork.&lt;/strong> The restore endpoint in its entirety:&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="nd">@app.post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;/api/versions/&lt;/span>&lt;span class="si">{version_id}&lt;/span>&lt;span class="s2">/restore&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">def&lt;/span> &lt;span class="nf">restore_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">version_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">...&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;Restore/fork: create a new latest version from a historical
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> version&amp;#39;s content (parent points at it).&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">v&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">version_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ver&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">v&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&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="sa">f&lt;/span>&lt;span class="s2">&amp;#34;restored from v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;version_no&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 class="p">,&lt;/span> &lt;span class="n">v&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Restore deletes and overwrites nothing: it creates a new version from the restored version&amp;rsquo;s &lt;strong>content&lt;/strong>, with &lt;code>parent&lt;/code> pointing at the restored version. The test assertion nails the semantics down: restoring v1 after v2 exists produces v3 — &lt;code>content&lt;/code> equals v1&amp;rsquo;s content, &lt;code>version_no&lt;/code> only ever increases, &lt;code>parent_version_id&lt;/code> points at v1. &amp;ldquo;Undoing a restore&amp;rdquo; is just another restore; every operation is an append.&lt;/p>
&lt;p>There is no &lt;code>UPDATE content&lt;/code> path anywhere in the backend; manual edits also go through &lt;code>add_version&lt;/code>. Immutability is the foundation everything else stands on: diffs get stable endpoints, and proposals get a well-defined base.&lt;/p>
&lt;h2 id="the-proposal-ledger-edits-land-in-the-ledger-first-the-tree-only-on-accept">The proposal ledger: edits land in the ledger first, the tree only on accept&lt;/h2>
&lt;p>The first implementation used &amp;ldquo;plan A&amp;rdquo;: candidates were not persisted. &lt;code>edit-section&lt;/code> computed but did not store; the frontend previewed the diff and called the save endpoint itself — zero new tables, no contact with the version tree. Its limits showed quickly: a page refresh lost the candidate, multiple candidates could not coexist, and the server had no audit trail. Hence the upgrade to &amp;ldquo;plan B&amp;rdquo; — the &lt;code>proposed_edits&lt;/code> ledger:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">proposed_edits&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">session_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">sessions&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DELETE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">CASCADE&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">base_version_id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- which version the edit is based on
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">char_start&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">char_end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">old_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">new_text&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">status&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;pending&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- pending/accepted/rejected
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">scope&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;section&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- section/whole/bib
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every edit the model produces — section-level, whole-document (&lt;code>scope='whole'&lt;/code>, range &lt;code>[0, len]&lt;/code>), bibliography — first becomes a &lt;code>pending&lt;/code> candidate; the version tree is touched only on accept, and accept reuses the same &lt;code>add_version&lt;/code>:&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 LR
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> A[Edit request] --&amp;gt; B{Scope}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|section / bib| C[edit-section]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt;|whole| W[edit-whole]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt; P[proposed_edits: pending]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> W --&amp;gt; P
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> P --&amp;gt;|reject| X[rejected: version tree untouched]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> P --&amp;gt;|accept| G{base still latest?}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt;|no| E[409 candidate stale]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt;|yes| S[splice] --&amp;gt; V[add_version: new version]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The accept path carries two 409s: &lt;strong>candidate already processed&lt;/strong> (accepted/rejected cannot be re-processed), and &lt;strong>base is stale&lt;/strong> (&lt;code>base_version_id&lt;/code> no longer the latest version). The second is the critical one: a candidate&amp;rsquo;s character range is computed against its base version; splicing against a base that is no longer the latest would silently drop every change between base and head. An adversarial review had found the same failure on the frontend — editing on an old version and accepting would overwrite subsequent versions — the frontend gained an &amp;ldquo;edit only on the latest version&amp;rdquo; gate, and this 409 is the server-side backstop.&lt;/p>
&lt;p>Whether every edit should require confirmation got its own round of sourced interaction research. The conclusion first corrected the risk framing: a full rewrite is in fact &lt;strong>already revertible&lt;/strong> (the version chain exists); the real risks of a silent default are scope surprise, cost, and expensive after-the-fact review — not data loss. So the confirmation gate should be light (a banner plus a plan card), not blocking modals everywhere; small section-level edits keep one-click accept. An industry counter-example is on record too: Cursor weakened its diff-approval-style confirmation and the community treated it as a regression (taken from our research notes, not independently verified). The direction matches the HITL literature: confirm only irreversible actions and prefer undo (NN/g); when unsure, narrow the scope of action and disambiguate before acting (Microsoft HAX G10).&lt;/p>
&lt;h2 id="the-deterministic-sectionizer-equal-length-masking-and-dual-anchors">The deterministic sectionizer: equal-length masking and dual anchors&lt;/h2>
&lt;p>Where do a candidate&amp;rsquo;s &lt;code>char_start/char_end&lt;/code> come from? A deterministic sectionizer (~260 lines, zero LLM calls, zero third-party dependencies) parses LaTeX source into a node tree along &lt;code>\section&lt;/code> / &lt;code>\subsection&lt;/code> / &lt;code>\subsubsection&lt;/code>. A node&amp;rsquo;s editable span runs from its heading command to the next heading whose level is not deeper than its own — editing a subsection replaces only that subsection; editing a parent section takes its children with it. Three design points:&lt;/p>
&lt;p>&lt;strong>Mask, don&amp;rsquo;t shift.&lt;/strong> A &lt;code>\section&lt;/code> inside a comment or a verbatim environment (&lt;code>verbatim&lt;/code> / &lt;code>lstlisting&lt;/code> / &lt;code>minted&lt;/code>, …) is a false heading; deleting those regions before parsing would destroy every character offset. The fix replaces them with &lt;strong>equal-length whitespace&lt;/strong>:&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">_mask&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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;Replace comments and verbatim environments with equal-length spaces
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> (all character offsets preserved), so a \section inside them is not
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> treated as a real heading — while splice still uses original offsets.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Headings are located on the masked text; titles and content are read from the original at the same offsets. Parsing and splicing share one coordinate system — no translation step, no translation bugs.&lt;/p>
&lt;p>&lt;strong>Dual anchors.&lt;/strong> Each node carries both a positional anchor (&lt;code>n0&lt;/code>, &lt;code>n1&lt;/code>, … in order of appearance; re-parsing the same content always yields the same result) and a content-fingerprint key (&lt;code>L1:ErrorAnalysis&lt;/code>, derived from level + title, with an ordinal suffix when titles repeat). Positional anchors drift when sections are inserted or removed; content keys follow the title and are more stable across versions, so they take precedence during resolution. Abstract and keywords have no &lt;code>\section&lt;/code> to address, so pseudo-nodes are constructed from the &lt;code>\abstract{...}&lt;/code> / &lt;code>\keyword{...}&lt;/code> commands — &amp;ldquo;fix the abstract&amp;rdquo; also gets scoped editing instead of collapsing into a full rewrite.&lt;/p>
&lt;p>&lt;strong>Parse failure degrades, never raises.&lt;/strong> If no section structure parses, the sectionizer returns &lt;code>editable=False&lt;/code> and the caller falls back to whole-document revision. The sectionizer&amp;rsquo;s job is to narrow scope, not to add a failure point.&lt;/p>
&lt;p>The robustness of this layer was largely ground out by adversarial review; three real defects, each now pinned by a regression test: the optional-argument heading form &lt;code>\section[short]{long}&lt;/code> was missed (the section merged into its predecessor and was silently lost on accept); &lt;code>\section&lt;/code> inside comments and verbatim environments produced ghost nodes (corrupting the splice); and the stale-version overwrite problem described above.&lt;/p>
&lt;h2 id="splice-fragment-health-checks-and-a-lesson-from-the-reflection-loop">Splice, fragment health checks, and a lesson from the reflection loop&lt;/h2>
&lt;p>A model-returned section fragment travels back into the master document through three all-code steps: &lt;code>strip_fragment&lt;/code> cleanup (remove Markdown fences; if the model disobeyed and returned a full document, cut out the body — the general approach to output cleanup is &lt;a href="https://kaguc.com/blog/output-sanitizing/">Part 3&lt;/a> of this series); &lt;code>check_fragment&lt;/code> health checks (balanced braces, balanced environments, no &lt;code>\documentclass&lt;/code> — soft signals that warn rather than block); and finally a one-line splice:&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">splice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">char_start&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">char_end&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_text&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">content&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="n">char_start&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">new_text&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">content&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">char_end&lt;/span>&lt;span class="p">:]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Bytes outside the range are unchanged — that promise is written directly into the test assertions.&lt;/p>
&lt;p>One lesson deserves its own paragraph. The autonomous reflection loop&amp;rsquo;s scoped splicing used positional anchors: if a fragment carried one extra top-level &lt;code>\section&lt;/code>, the section count went up by one after splicing, the positional anchors of every subsequent edit in the same round drifted, and the wrong sections got edited — while the regression gate of the time only caught &amp;ldquo;fewer sections&amp;rdquo;, not &amp;ldquo;more&amp;rdquo;. Silent mis-edits. The fix adds a conservation law to the splice: &lt;strong>a fragment must preserve the count of top-level &lt;code>\section&lt;/code> commands within the replaced span, otherwise that section is skipped&lt;/strong>. This too came out of adversarial review, and is now a permanent test.&lt;/p>
&lt;h2 id="the-division-of-diff-labor-the-backend-guarantees-invariants-line-level-rendering-goes-to-the-frontend">The division of diff labor: the backend guarantees invariants, line-level rendering goes to the frontend&lt;/h2>
&lt;p>With versions and candidates in place, &amp;ldquo;see what changed&amp;rdquo; was deliberately &lt;em>not&lt;/em> built as a backend service:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Responsibility&lt;/th>
&lt;th>What it does not do&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>document_versions&lt;/code>&lt;/td>
&lt;td>Immutable versions, queryable lineage&lt;/td>
&lt;td>No diff computation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>proposed_edits&lt;/code>&lt;/td>
&lt;td>&lt;code>old_text&lt;/code> / &lt;code>new_text&lt;/code> archived as a pair&lt;/td>
&lt;td>No presentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>split_aligned&lt;/code>&lt;/td>
&lt;td>Split a whole-document change per section when skeletons align&lt;/td>
&lt;td>Returns &lt;code>None&lt;/code> on structural change&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Frontend &lt;code>diffLines&lt;/code> (jsdiff)&lt;/td>
&lt;td>Line-level highlight rendering&lt;/td>
&lt;td>Never mutates data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>split_aligned&lt;/code> is the only place the backend &amp;ldquo;computes a diff&amp;rdquo;, and its conditions are deliberately tight: the top-level &lt;code>\section&lt;/code> skeletons of base and new must have the &lt;strong>same titles, same order, same count&lt;/strong>, and the &lt;strong>preamble must be byte-identical&lt;/strong> — only then is the whole-document change split into a list of independently acceptable per-section candidates. When skeletons align, the section ranges are non-overlapping, so accepting one section replaces only that section and leaves everything else byte-for-byte untouched. If any condition fails, it returns &lt;code>None&lt;/code> and the change falls back to whole-document all-or-nothing. The preamble check looks strict, but the reason is concrete: if a change lands outside every section, per-section review would miss it.&lt;/p>
&lt;p>Line-level diffing goes to jsdiff&amp;rsquo;s &lt;code>diffLines&lt;/code> on the frontend. Display granularity is a UI concern that iterates with the design; the backend promises only testable invariants — immutable versions, bytes-outside-range unchanged, and the safety conditions for splitting.&lt;/p>
&lt;h2 id="quantified-how-tests-pin-these-semantics-down">Quantified: how tests pin these semantics down&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Semantics&lt;/th>
&lt;th>The test / data that pins it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Restore = fork&lt;/td>
&lt;td>After restore: &lt;code>content&lt;/code> equals the source version, &lt;code>version_no&lt;/code> only increases, &lt;code>parent&lt;/code> points at the restored version&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>version_no&lt;/code> per session&lt;/td>
&lt;td>Interleaved versions across two sessions; the new session&amp;rsquo;s first version is still 1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Candidate lifecycle&lt;/td>
&lt;td>409 on re-processing; 409 on stale base&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sectionizer robustness&lt;/td>
&lt;td>29 P0/P1 tests (including 4 added after review); full suite at the time: 173 passed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Per-section review&lt;/td>
&lt;td>6 new &lt;code>split_aligned&lt;/code> tests; full suite at the time: 266 passed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ownership isolation&lt;/td>
&lt;td>Non-owners get 404 on version reads and restore&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All of these run with a mocked LLM, fully offline — the data-model layer is precisely the part of an LLM application that conventional assertions &lt;em>can&lt;/em> test to death.&lt;/p>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Full snapshots have a ceiling.&lt;/strong> Storing the whole document per version is right for tens-of-kilobyte LaTeX; at megabyte scale or high save frequency (real-time collaborative editing), switch to delta storage or CRDTs — this model is not designed for that regime.&lt;/li>
&lt;li>&lt;strong>The ledger is for human-in-the-loop products.&lt;/strong> If the product is a fully automated pipeline where nobody reviews candidates, the ledger is just an unread queue; what belongs there instead is a deterministic regression gate before anything lands (see &lt;a href="https://kaguc.com/blog/deterministic-boundary/">Part 11&lt;/a>). Likewise, wrapping every low-stakes edit in confirmation is the &amp;ldquo;crying wolf&amp;rdquo; anti-pattern NN/g warns about — we keep one-click accept for small section edits and place the confirmation gate only on whole-document changes.&lt;/li>
&lt;li>&lt;strong>The sectionizer presupposes parseable structure.&lt;/strong> On unstructured text, &lt;code>editable=False&lt;/code>: the version chain still works, but scoped editing degrades to whole-document revision.&lt;/li>
&lt;li>&lt;strong>&lt;code>split_aligned&lt;/code>&amp;rsquo;s conservatism is a feature, not a defect.&lt;/strong> When the skeleton changes it refuses to split: structural changes should not be accepted piecemeal — a half-accepted reorganization is more dangerous than a whole-document replacement.&lt;/li>
&lt;li>&lt;strong>The version tree is not a branching workflow.&lt;/strong> Lineage is fully recorded, but the presentation is linear with a single tip; exploring several directions in parallel and merging them is unsupported. For git-like branch semantics, this model provides only the foundation.&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;p>The following sources come from the sourced research notes (devlog) of our interaction study:&lt;/p>
&lt;ul>
&lt;li>Eric Horvitz, &lt;em>Principles of Mixed-Initiative User Interfaces&lt;/em> (CHI &amp;lsquo;99) — the principled framework for mixed-initiative interaction.&lt;/li>
&lt;li>Microsoft HAX Guidelines — G9 (support efficient undo), G10 (scope services when in doubt / disambiguate before acting), G16 (convey the consequences of actions).&lt;/li>
&lt;li>Nielsen Norman Group guidance on confirmation dialogs — confirm only irreversible actions, prefer undo, avoid crying wolf.&lt;/li>
&lt;li>Google PAIR, &lt;em>People + AI Guidebook&lt;/em> — users resist full autonomy on high-control artifacts; explanation serves understanding.&lt;/li>
&lt;li>Cursor community regression feedback on weakened diff approval — taken from our research notes, not independently verified.&lt;/li>
&lt;/ul></description></item><item><title>Executing model-generated code: a two-layer sandbox</title><link>https://kaguc.com/blog/code-sandbox/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/code-sandbox/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/code-sandbox-zh/">中文&lt;/a>. Part 5 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-why-model-written-code-must-be-executed-at-all">The problem: why model-written code must be executed at all&lt;/h2>
&lt;p>One class of content in automated reporting cannot avoid code execution: experiments where only raw data exists — &lt;code>.mat&lt;/code> field-sweep files, &lt;code>csv&lt;/code> band structures — while the existing figures are MATLAB &lt;code>.fig&lt;/code> files that cannot be embedded in a PDF. The LLM can read the data format but cannot draw: turning data into a figure requires &lt;em>computation plus rendering&lt;/em>, and only code does that. So the design is the same as Code Interpreter: the LLM writes a piece of matplotlib code, the backend executes it in a subprocess, out comes a PNG, and compilation embeds it into the report.&lt;/p>
&lt;p>On the agent side this is a single tool, &lt;code>make_figure&lt;/code>, with exactly two parameters: &lt;code>{name, code}&lt;/code>. The agent calls it from its &lt;a href="https://kaguc.com/blog/agent-loop/">tool loop&lt;/a> once it understands the data; on failure it receives the error and may revise the code and retry; on success the report references the figure via &lt;code>\includegraphics{用图/&amp;lt;name&amp;gt;}&lt;/code>. The system prompt sets the rule alongside: if there is data but no figure, plot it — never fabricate images.&lt;/p>
&lt;p>The cost is equally direct: this executes arbitrary Python generated by a probabilistic component on the backend — a built-in RCE entry point. This article dissects the two-layer sandbox designed for it, and the measured results.&lt;/p>
&lt;h2 id="threat-model-the-model-cannot-tell-data-from-instructions">Threat model: the model cannot tell data from instructions&lt;/h2>
&lt;p>The attack chain is clear: an untrusted data folder → file contents enter the prompt → prompt injection → the LLM writes malicious code → the backend executes it. The root cause is that an LLM processes data and instructions in the same token stream (in-band signaling) — structurally the same defect as SQL injection (data mixed into queries) and buffer overflows (data mixed into control flow). It cannot be fixed at the model layer: alignment training lowers the probability but offers no guarantee. The industry&amp;rsquo;s consensus path is therefore to &lt;em>assume the injection will succeed and control the damage&lt;/em>.&lt;/p>
&lt;p>The engineering corollary: the security design must not depend on the assumption that &amp;ldquo;the model won&amp;rsquo;t write bad code.&amp;rdquo; The gates must sit after the code leaves the model and before it has any effect. We built two layers: &lt;strong>static interception before execution&lt;/strong> and &lt;strong>runtime isolation&lt;/strong> — the first blocks known-dangerous patterns before they run, the second assumes the first will leak and minimizes what leaked code can do.&lt;/p>
&lt;h2 id="layer-1-the-ast-allowlist--static-interception-before-execution">Layer 1: the AST allowlist — static interception before execution&lt;/h2>
&lt;p>&lt;code>ast_check&lt;/code> parses the model&amp;rsquo;s code into an AST (a parse failure is rejected as a syntax error) and walks every node; any violation rejects the whole submission:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Import allowlist&lt;/strong> (17 modules): only data/plotting modules — numpy/scipy/matplotlib/pandas/math/csv/json and the like — with both &lt;code>import&lt;/code> and &lt;code>from ... import&lt;/code> compared by top-level package name. The stance is allowlist, not blocklist: the criterion is &amp;ldquo;what does a plotting task need,&amp;rdquo; not &amp;ldquo;what would an attacker use.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Dangerous-name blocklist&lt;/strong> (37 names, checked on bare &lt;code>Name&lt;/code> nodes, not just imports): os/sys/subprocess/socket/ctypes/importlib/pickle/pathlib/glob… — any appearance rejects. This rule is not redundant: the execution wrapper pre-imports &lt;code>os&lt;/code> to implement file navigation, so model code can call &lt;code>os.remove&lt;/code> without ever writing &lt;code>import os&lt;/code>. &lt;em>Usage&lt;/em> must be intercepted, not merely &lt;em>importing&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Forbidden builtins&lt;/strong> (17): eval/exec/compile/&lt;code>__import__&lt;/code>/open/getattr/globals… — banned both as calls and as bare-name references, so aliasing like &lt;code>e = eval&lt;/code> is rejected too.&lt;/li>
&lt;li>&lt;strong>Dunder escapes&lt;/strong>: any &lt;code>__xx__&lt;/code> attribute access is rejected, closing the classic &lt;code>().__class__.__subclasses__()&lt;/code> escape chain:&lt;/li>
&lt;/ol>
&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">elif&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">node&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ast&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Attribute&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;__&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">endswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;__&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">return&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;禁止访问 &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Rejection is not a dead end. The error returned to the agent is instructional: it states which libraries are allowed, that data is read via &lt;code>srcpath('filename')&lt;/code>, that output is saved via &lt;code>plt.savefig(OUT)&lt;/code>, and what is banned. The agent takes this deterministic signal, rewrites, and retries — the same closed-loop pattern as &lt;a href="https://kaguc.com/blog/compile-self-repair/">compile self-repair&lt;/a>: feed the output of a deterministic check back to the model.&lt;/p>
&lt;h2 id="layer-2-runtime-isolation--nothing-left-to-damage-after-a-leak">Layer 2: runtime isolation — nothing left to damage after a leak&lt;/h2>
&lt;p>Layer 1 is static analysis over text and can, in principle, miss. Layer 2 assumes it already has, and shrinks the execution environment to the minimum:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Measure&lt;/th>
&lt;th>Damage it targets&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Environment built from scratch: only the allowlisted variables PATH/MPLBACKEND/MPLCONFIGDIR/HOME/LANG&lt;/td>
&lt;td>API keys and proxy settings drop out automatically instead of leaking via the environment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>_GUARD&lt;/code> preamble injected before user code: &lt;code>os.system&lt;/code> replaced with a lambda raising PermissionError, &lt;code>sys.modules['subprocess']&lt;/code> set to None, &lt;code>socket.socket&lt;/code> disabled&lt;/td>
&lt;td>Command execution and network egress after a static miss&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Isolated cwd: a fresh &lt;code>mkdtemp&lt;/code> directory per run, deleted afterwards&lt;/td>
&lt;td>Writing into host directories&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>python -I&lt;/code>: isolated mode&lt;/td>
&lt;td>Injection via PYTHONPATH / user site-packages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>90-second timeout&lt;/td>
&lt;td>Infinite loops and resource exhaustion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Source-data directory mounted read-only by compose (container deployment)&lt;/td>
&lt;td>Tampering with source data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Building the environment from scratch has a platform cost: on Windows, a CPython subprocess needs SystemRoot and related system variables (crypto RNG, system DLLs), and matplotlib needs a writable MPLCONFIGDIR/TEMP — these must be explicitly back-filled, or figure generation fails unconditionally in the Windows distribution.&lt;/p>
&lt;p>There is also an easily missed companion design: &lt;strong>every ban must ship with a replacement&lt;/strong>. The allowlist bans os/glob/pathlib, so the model cannot list directories or join paths — offering no substitute would force it into violations. The wrapper therefore injects three navigation primitives, all confined to the source-data directory SRC: &lt;code>srcpath('relative-name')&lt;/code> for paths, &lt;code>listdir(sub='')&lt;/code> for directory listings, and &lt;code>walkfiles()&lt;/code> for a recursive list of all relative filenames; the tool description spells out both constraints and usage so the model writes compliant code on the first attempt. On failure, only the last 1500 characters of stderr are returned — error messages are tokens too.&lt;/p>
&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="s2">&amp;#34;agent calls make_figure {name, code}&amp;#34;&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="s2">&amp;#34;Layer 1: ast_check static allowlist&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">B&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">reject&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">E&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Instructional error: allowed and banned items spelled out&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">E&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">F&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agent rewrites code, retries&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">F&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">B&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="k">pass&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">W&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Wrapper: pre-import np/plt/loadmat, inject SRC/OUT/srcpath/listdir/walkfiles + _GUARD&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">W&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">R&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Layer 2: isolated subprocess&amp;lt;br/&amp;gt;from-scratch env · isolated cwd · python -I · 90s timeout&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">R&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="s2">&amp;#34;OUT exists and non-empty&amp;#34;&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">P&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PNG into session figure dir, embedded at compile time&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">R&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">failure&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">timeout&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">E&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="measured-seven-classes-of-malicious-samples-all-blocked-before-execution">Measured: seven classes of malicious samples, all blocked before execution&lt;/h2>
&lt;p>Verified by real runs inside the container. Two legitimate paths passed: basic matplotlib plotting, and plotting real data read via &lt;code>srcpath&lt;/code>. Seven classes of malicious samples were all rejected by the AST layer before execution:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Malicious sample&lt;/th>
&lt;th>Rule hit&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>os.system&lt;/code> command execution&lt;/td>
&lt;td>Dangerous-name blocklist (os is pre-imported; bare names are intercepted, not imports)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>os.remove&lt;/code> deleting files (no import — uses the pre-imported os)&lt;/td>
&lt;td>Same as above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>import socket&lt;/code> for egress&lt;/td>
&lt;td>Import allowlist&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>eval&lt;/code> dynamic execution&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>__import__&lt;/code> bypassing the import statement&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dunder escape (&lt;code>__class__&lt;/code>/&lt;code>__subclasses__&lt;/code>)&lt;/td>
&lt;td>Dunder attribute interception&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>open&lt;/code> reading /etc/passwd&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Layer 2 was never reached by this sample set — which is exactly as intended: a backstop&amp;rsquo;s value lies in unknown samples, not in the test set.&lt;/p>
&lt;h2 id="two-unexpected-payoffs-the-security-constraint-pays-back-at-packaging-time">Two unexpected payoffs: the security constraint pays back at packaging time&lt;/h2>
&lt;p>For standalone distribution the backend is frozen into a binary with PyInstaller. Two interactions with the sandbox could have been deep pits; the allowlist made both shallow.&lt;/p>
&lt;p>&lt;strong>Payoff 1: a finite, known import set makes &lt;code>collect_all&lt;/code> a precise fix.&lt;/strong> PyInstaller collects dependencies by static analysis, but the plotting code inside the sandbox is a string that exists only at runtime — whatever it imports is invisible to the packager. With an unbounded import set the problem is unsolvable; the AST allowlist, however, pins it down to a finite known set, so running &lt;code>collect_all&lt;/code> over numpy/scipy/matplotlib and the rest captures everything. Measured on Windows: the frozen onedir output is ~260MB (30MB exe), with selftest reporting imports 10/10 and savefig 18250 bytes — the scientific stack is fully usable inside the frozen bundle.&lt;/p>
&lt;p>&lt;strong>Payoff 2: self-dispatch around the frozen &lt;code>sys.executable&lt;/code>.&lt;/strong> In development the execution command is &lt;code>[sys.executable, &amp;quot;-I&amp;quot;, script]&lt;/code>; after freezing, &lt;code>sys.executable&lt;/code> is this binary rather than python, and &lt;code>exe -I script&lt;/code> would be interpreted as a server launch and fail. The fix: when &lt;code>run_plot&lt;/code> detects &lt;code>is_compiled()&lt;/code>, it instead sets the environment variable &lt;code>PDFAGENT_PYRUN=&amp;lt;script path&amp;gt;&lt;/code> and launches another copy of the same exe; the entry point &lt;code>run_server._pyrun()&lt;/code> sees the variable, executes the script via &lt;code>runpy.run_path&lt;/code>, and exits without starting the server. Process isolation, the scrubbed environment, the timeout, the AST allowlist, and &lt;code>_GUARD&lt;/code> all remain — only the &lt;code>-I&lt;/code> flag is lost. The dispatch is pinned by the two cases in &lt;code>test_pyrun.py&lt;/code> (with the variable set, the script runs; without it, no-op).&lt;/p>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>This is not a full sandbox.&lt;/strong> There is no microVM and no container-level isolation; the subprocess shares the host&amp;rsquo;s kernel and filesystem permissions — a combined attack that bypasses the AST layer &lt;em>and&lt;/em> evades &lt;code>_GUARD&lt;/code> exists in theory. The project states its position in the module docstring: it targets the local single-user scenario (the code plots the user&amp;rsquo;s own local data — equivalent to the user running a script by hand), where two layers suffice; for untrusted folders or multi-user service, the real fix is one-shot, network-less container execution.&lt;/li>
&lt;li>&lt;strong>The allowlist sacrifices expressiveness.&lt;/strong> getattr and open are banned; h5py and similar libraries are not on the list, so HDF5-class data requires explicitly extending the allowlist — and re-assessing the new library&amp;rsquo;s capability surface (can it make network requests, can it write files). Every notch the allowlist widens, the weaker Layer 1&amp;rsquo;s guarantee becomes.&lt;/li>
&lt;li>&lt;strong>Domain restriction is the precondition.&lt;/strong> The approach works because the task domain is narrow: &amp;ldquo;data plotting&amp;rdquo; needs a small, stable module set. For general-purpose code execution — an agent freely writing arbitrary tool scripts — the allowlist would widen until meaningless; go straight to container/microVM isolation, with the AST layer at most as a heuristic pre-check.&lt;/li>
&lt;li>&lt;strong>Layer 2 couples to platform details.&lt;/strong> Building the environment from scratch means discovering every implicit platform dependency yourself: Windows system variables, matplotlib&amp;rsquo;s writable config directory. Projects distributing cross-platform should budget for verification by real runs, not by reasoning.&lt;/li>
&lt;/ol></description></item><item><title>From chat to agent: tool loops and collapse breakers</title><link>https://kaguc.com/blog/agent-loop/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/agent-loop/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/agent-loop-zh/">中文&lt;/a>. Part 4 of the Engineering LLM Applications series. The series is grounded in the implementation and measurements of a production-grade AI writing agent (FastAPI + React + Tauri, 351 test cases).&lt;/em>&lt;/p>
&lt;h2 id="the-problem-handing-the-loop-to-the-model">The problem: handing the loop to the model&lt;/h2>
&lt;p>The first three articles of this series were about single-call engineering: controlling input, assembling prompts, sanitizing output. The watershed of an agent is that &lt;strong>what to do next&lt;/strong> is also handed to the model: give it a tool set, execute its calls, feed the results back into the message list, and call again — until the task is done.&lt;/p>
&lt;p>The concrete scenario for this article: give the agent a data folder whose file names and contents are unknown in advance, and have it read files on its own, figure out what experiment this is, and write the report. The naive implementation is a &lt;code>while True&lt;/code> tool loop, and in production it fails in three ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Untrusted paths.&lt;/strong> The model decides which file to read; a single &lt;code>../&lt;/code> in an argument reads outside the data directory.&lt;/li>
&lt;li>&lt;strong>No natural endpoint.&lt;/strong> &amp;ldquo;How much exploration is enough&amp;rdquo; is the model&amp;rsquo;s judgment call; there is no upper bound on iterations, and therefore none on cost.&lt;/li>
&lt;li>&lt;strong>Loop collapse.&lt;/strong> The most insidious class: in long contexts, the model degenerates into calling the same tool with identical arguments. A public case, qwen-code issue #4695, records 43 consecutive &lt;code>git status&lt;/code> calls consuming 8.9M tokens in one session; the deepseek-v4-pro tier we use is also prone to collapsing into this pattern in long contexts. SDK retries cannot help — retries only cover network errors and rate limits, while each of these calls &amp;ldquo;succeeds&amp;rdquo; at the API layer. The breaker has to live on the client side.&lt;/li>
&lt;/ol>
&lt;p>The rest of this article walks through five mechanisms in build order. The implementation uses standard OpenAI-compatible tool calling (DeepSeek by default; any compatible endpoint can be swapped in); the entry point &lt;code>run_agent&lt;/code> is a generator that yields events one by one for SSE streaming.&lt;/p>
&lt;h2 id="mechanism-1-a-minimal-tool-set-with-a-path-guard">Mechanism 1: a minimal tool set with a path guard&lt;/h2>
&lt;p>The exploration phase has five tools: &lt;code>list_dir&lt;/code>, &lt;code>read_file&lt;/code>, &lt;code>read_image&lt;/code> (a local vision model extracts readings from screenshots and figures), &lt;code>make_figure&lt;/code> (executes model-written matplotlib code to produce plots), and &lt;code>finish_exploration&lt;/code>. Three design points:&lt;/p>
&lt;p>&lt;strong>Segmented continuation instead of stuffing everything in at once.&lt;/strong> &lt;code>read_file&lt;/code> clips text at &lt;code>READ_CHARS = 7000&lt;/code> characters and, when it clips, appends one line: &amp;ldquo;truncated — use offset/limit to continue reading by lines&amp;rdquo;. Truncation is not an error; it is a decision point for the model: there is more, and whether it is worth reading further is the model&amp;rsquo;s trade-off to make. It also imposes a constraint on the loop-breaker fingerprint later — continuation reads with different offsets are legitimate behavior and must not be flagged.&lt;/p>
&lt;p>&lt;strong>Explicit phase transition.&lt;/strong> &lt;code>finish_exploration&lt;/code> turns &amp;ldquo;exploration is done&amp;rdquo; from an implicit signal (the model simply stops calling tools) into an explicit call: the schema requires &lt;code>experiment&lt;/code> / &lt;code>goal&lt;/code> / &lt;code>key_findings&lt;/code>, with three optional slots for assignment requirements, methodology notes, and a reference report. The phase transition leaves a structured record — and gives the breaker a well-defined exit to steer toward.&lt;/p>
&lt;p>&lt;strong>Every path entry goes through &lt;code>_safe&lt;/code>.&lt;/strong>&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">_safe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">root&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">None&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">root&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">root&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">base_docs_root&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">root&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">t&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">normpath&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">root&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rel&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;&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">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">t&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">root&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">root&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sep&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">root&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">t&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After &lt;code>normpath&lt;/code> joining, the resolved path must still be inside the root; an escape returns &lt;code>(None, None)&lt;/code>, and the tool layer turns that into a &amp;ldquo;path does not exist or is out of bounds&amp;rdquo; string &lt;strong>returned as the tool result&lt;/strong> — the model sees it and can correct itself, and the main flow never raises. The &lt;code>make_figure&lt;/code> tool description also spells out hard sandbox rules (no importing os/sys/subprocess and the like, no open()/eval/exec, no double-underscore attributes); its two-layer sandbox implementation is the subject of &lt;a href="https://kaguc.com/blog/code-sandbox/">the next article&lt;/a>.&lt;/p>
&lt;h2 id="mechanism-2-a-zero-llm-scan-before-exploration-starts">Mechanism 2: a zero-LLM scan before exploration starts&lt;/h2>
&lt;p>Before the model begins exploring, &lt;code>scan_folder&lt;/code> performs a deterministic recursive scan: files are coarsely bucketed by extension into documents / images / data / other, returning the bucketed listing and counts — zero LLM calls, zero tokens. It serves two purposes: an instant whole-folder snapshot for the user (the detail of the first step event), and deterministic input for the later material-gap check. The principle is the same as in &lt;a href="https://kaguc.com/blog/deterministic-boundary/">Part 11&lt;/a>: &amp;ldquo;what is in the folder&amp;rdquo; is a question code can answer, and the model&amp;rsquo;s exploration budget should be spent on &amp;ldquo;which files to read and what to make of them&amp;rdquo;. One cost note in passing: the exploration tool loop runs on the cheap model tier (reading files and deciding what to read is simple work); drafting and reflection use the main model.&lt;/p>
&lt;h2 id="mechanism-3-two-lines-of-defense--a-budget-cap-and-a-collapse-breaker">Mechanism 3: two lines of defense — a budget cap and a collapse breaker&lt;/h2>
&lt;p>&lt;code>MAX_TOOL_ITERS = 20&lt;/code> is a budget cap, not a defense: a collapsed agent will burn all 20 iterations on repeated calls and produce nothing. The real defense is &lt;code>_LoopBreaker&lt;/code>, built around a call fingerprint:&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">_tool_fingerprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&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;Tool-call fingerprint = name + full arguments (sorted JSON).&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">name&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;|&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ensure_ascii&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sort_keys&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="ne">Exception&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">name&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;|&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three design constraints: &lt;strong>full arguments&lt;/strong> — &lt;code>offset=0&lt;/code> and &lt;code>offset=100&lt;/code> are two different fingerprints, so legitimate segmented reads are never flagged (a unit test pins this property); &lt;strong>sorted serialization&lt;/strong> — argument key order does not affect the verdict; &lt;strong>never raises&lt;/strong> — serialization failure falls back to &lt;code>str()&lt;/code>. Wrap-up tools like &lt;code>finish_exploration&lt;/code> are excluded from counting.&lt;/p>
&lt;p>The thresholds come in two tiers. Third occurrence of the same fingerprint (&lt;code>_LOOP_WARN&lt;/code>): inject one wrap-up prompt — &amp;ldquo;You are repeatedly calling the same tool with the same arguments. What you have read is sufficient; call finish_exploration immediately and state your conclusions&amp;rdquo; — with &lt;code>nudge_once&lt;/code> guaranteeing a single injection per run, because the injection itself occupies context and repeated injections would accelerate the collapse. Fifth occurrence (&lt;code>_LOOP_STOP&lt;/code>): hard-break out of the exploration loop. The break is not a crash: the agent yields a &amp;ldquo;repeated identical tool call detected — exploration cut off, writing from what has been read&amp;rdquo; event and proceeds to the drafting phase with the material accumulated so far. A degraded deliverable plus a human reviewer beats zero output after the budget is exhausted.&lt;/p>
&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">Call&lt;/span> &lt;span class="n">model&lt;/span> &lt;span class="n">with&lt;/span> &lt;span class="n">TOOLS&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">tool_calls&lt;/span> &lt;span class="n">returned&lt;/span>&lt;span class="err">?&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">none&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">N&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Inject&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">keep&lt;/span> &lt;span class="n">exploring&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">call&lt;/span> &lt;span class="n">finish_exploration&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">A&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">finish_exploration&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">W&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Record&lt;/span> &lt;span class="n">understanding&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">enter&lt;/span> &lt;span class="n">drafting&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">other&lt;/span> &lt;span class="n">tools&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">LoopBreaker&lt;/span> &lt;span class="n">fingerprint&lt;/span> &lt;span class="n">count&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">ok&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">E&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Execute&lt;/span> &lt;span class="k">tool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">feed&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="n">back&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">A&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="n">rd&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">once&lt;/span> &lt;span class="n">only&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">G&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Execute&lt;/span> &lt;span class="k">tool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">inject&lt;/span> &lt;span class="n">wrap&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">up&lt;/span> &lt;span class="n">prompt&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">A&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="n">th&lt;/span> &lt;span class="n">time&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">Hard&lt;/span> &lt;span class="k">break&lt;/span> &lt;span class="n">after&lt;/span> &lt;span class="n">this&lt;/span> &lt;span class="nb">round&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">W&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole loop still sits inside &lt;code>for _ in range(MAX_TOOL_ITERS)&lt;/code> — the two defenses hold independently. The breaker has an end-to-end test: a mock client that, whenever tools are passed, always returns the same &lt;code>read_file(a.txt)&lt;/code> call (simulating total collapse); the test asserts that the break event must appear and that the number of create calls is ≤ 6 — far below the cap of 20. The threshold sequence (first two ok, third nudge, fifth stop) and the fingerprint properties are pinned by separate unit tests.&lt;/p>
&lt;h2 id="mechanism-4-the-inverse-failure-in-drafting--tool-markup-without-tools">Mechanism 4: the inverse failure in drafting — tool markup without tools&lt;/h2>
&lt;p>After exploration, drafting begins, and this phase passes no tools. But the model may still emit its internal tool-call markup (DeepSeek&amp;rsquo;s DSML) as body text — the generation &amp;ldquo;succeeds&amp;rdquo; on the surface, and the resulting report is garbage. &lt;code>_complete_report&lt;/code> validates output with three deterministic criteria: contains &lt;code>\documentclass&lt;/code>, contains no &lt;code>DSML&lt;/code>, contains no &lt;code>tool_calls&lt;/code>. On failure it injects a reinforcement instruction (&amp;ldquo;You have no tools available right now… output the complete LaTeX source directly&amp;rdquo;) and retries, up to 3 times. This is the output-sanitizing approach of &lt;a href="https://kaguc.com/blog/output-sanitizing/">Part 3&lt;/a> extended into the agent context: a structurally invalid output is not patched textually — the constraints are tightened and the call is retried.&lt;/p>
&lt;h2 id="mechanism-5-deterministic-progress--pre-enumerated-milestones-and-explicit-stages">Mechanism 5: deterministic progress — pre-enumerated milestones and explicit stages&lt;/h2>
&lt;p>A streaming agent&amp;rsquo;s progress bar cannot run on guesswork. This pipeline is &amp;ldquo;process as configuration&amp;rdquo;: scan → explore → [figures] → [gap] → draft → reflect×N → bib → [compile], with the optional steps switched by the template. So &lt;code>_plan_pipeline&lt;/code> enumerates the milestone sequence before the run starts, &lt;strong>from configuration alone&lt;/strong>; M = len(plan), every milestone event carries index/total, and the progress bar has a fixed denominator from the first second. Two consistency details:&lt;/p>
&lt;ul>
&lt;li>The gates for optional steps must match the runtime decisions verbatim: &lt;code>has_xelatex&lt;/code> is probed exactly once, and the plan and the runtime compile gate reuse the same result, so the two can never diverge;&lt;/li>
&lt;li>the stage name is passed explicitly rather than looked up from &lt;code>_plan[_pi-1]&lt;/code>: the reflection loop may break early on convergence, and a skipped milestone would make the positional lookup mislabel the subsequent bib / compile milestones as reflect.&lt;/li>
&lt;/ul>
&lt;p>One known small deviation is documented honestly in the code comments: the gap step also depends at runtime on whether material exists and may actually be skipped — the plan counts it anyway, and the completion event backfills to 100%.&lt;/p>
&lt;h2 id="measurements">Measurements&lt;/h2>
&lt;p>Real-run validation (devlog record, DeepSeek v4-pro, an electromagnetics field-scan dataset): the agent ran &lt;code>list_dir&lt;/code> automatically and made multiple rounds of &lt;code>read_file&lt;/code> (a PDF, band.csv, scan parameter files, and more), and its autonomous identification of the experiment was correct; the draft was 16,773 characters, and after one reflection-revision round the final report was 20,742 characters across 9 sections, with the full run taking 745 seconds. That validation ran on the initial version whose tool set was only list_dir / read_file / finish_exploration; read_image and make_figure were added later.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mechanism&lt;/th>
&lt;th>Alternative&lt;/th>
&lt;th>Effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>_safe&lt;/code> path guard&lt;/td>
&lt;td>Trust model-supplied paths&lt;/td>
&lt;td>Escapes become tool-level errors the model can self-correct; nothing raises&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>scan_folder&lt;/code> pre-scan&lt;/td>
&lt;td>Let the model map the folder itself&lt;/td>
&lt;td>Zero-token bucketed snapshot of every file&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>MAX_TOOL_ITERS = 20&lt;/code>&lt;/td>
&lt;td>Unbounded loop&lt;/td>
&lt;td>Hard cost ceiling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>_LoopBreaker&lt;/code>&lt;/td>
&lt;td>Iteration cap / SDK retries alone&lt;/td>
&lt;td>≤ 6 create calls in the collapse scenario (end-to-end assertion), saving 14+ wasted rounds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSML detection and retry&lt;/td>
&lt;td>Accept the &amp;ldquo;successful&amp;rdquo; output&lt;/td>
&lt;td>Valid LaTeX within at most 3 retries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>_plan_pipeline&lt;/code> + explicit stage&lt;/td>
&lt;td>Guess progress from event counts&lt;/td>
&lt;td>Denominator known before the run; early convergence never mislabels stages&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>An exact fingerprint catches total collapse, not wandering.&lt;/strong> Low-value calls to the same tool with varying arguments (reading irrelevant files one after another) never trip the breaker; that class is bounded by the budget cap and prompt quality. Fuzzing the fingerprint (ignoring some arguments) would widen coverage but misfire on legitimate continuation reads — we chose narrow and precise.&lt;/li>
&lt;li>&lt;strong>The nudge works only while the model still follows instructions.&lt;/strong> The wrap-up prompt helps with mild circling; once the context has collapsed severely, the model no longer responds to injections and only the hard break matters — which is exactly the rationale for the two-tier thresholds.&lt;/li>
&lt;li>&lt;strong>A degraded deliverable requires a human in the loop.&lt;/strong> A report written from partial material is only worth something if someone reviews it downstream. If the agent&amp;rsquo;s output is executed automatically and partial information can cause harm, the breaker policy should be fail-the-whole-run rather than degrade.&lt;/li>
&lt;li>&lt;strong>Pre-enumerated progress requires a fixed pipeline.&lt;/strong> &lt;code>_plan_pipeline&lt;/code> works because the pipeline structure is settled before the run starts; an open-ended agent whose plan is generated dynamically by the model has no a-priori milestone sequence, and progress degrades to event counting.&lt;/li>
&lt;li>&lt;strong>The thresholds are not universal constants.&lt;/strong> 20 / 3 / 5 were tuned to the scale of &amp;ldquo;read a folder, write a report&amp;rdquo; and have not been validated on other task shapes.&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>qwen-code issue #4695 — a public case of tool-loop collapse (43 repeated &lt;code>git status&lt;/code> calls / 8.9M tokens in one session).&lt;/li>
&lt;/ul></description></item><item><title>Making model output usable: sanitizing and automatic repair</title><link>https://kaguc.com/blog/output-sanitizing/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/output-sanitizing/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/output-sanitizing-zh/">中文&lt;/a>. Part 3 of the Engineering LLM Applications series. The evidence base is a production-grade AI writing agent that generates LaTeX lab reports; every error message and number in this article comes from its source code and real debugging records.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-the-distance-between-looks-correct-and-usable">The problem: the distance between &amp;ldquo;looks correct&amp;rdquo; and &amp;ldquo;usable&amp;rdquo;&lt;/h2>
&lt;p>You ask the model to generate a LaTeX lab report. The source it returns is structurally complete, every command spelled right, nothing visibly wrong; you feed it to xelatex and get &lt;code>no legal \end found&lt;/code> — even though &lt;code>\end{document}&lt;/code> is plainly there at the end of the file. The real cause hides in the header: the model wrote &lt;code>\def\partnerID{% TODO...}&lt;/code>, and the &lt;code>%&lt;/code> inside the &lt;code>\def&lt;/code> line comments out the closing &lt;code>}&lt;/code>. A runaway definition swallows everything after &lt;code>\begin{document}&lt;/code>. The error location and the cause location are an entire document apart.&lt;/p>
&lt;p>This is the most typical gap you hit when starting out with LLM applications: &lt;strong>model output and usable artifact are not the same thing&lt;/strong>. Taking &amp;ldquo;generate LaTeX → produce a PDF&amp;rdquo; as the running example, at least three failure layers sit in between:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Won&amp;rsquo;t compile&lt;/strong>: syntax looks fine, but it trips corner-case behavior of the engine-and-package combination (the &lt;code>%&lt;/code> above; &lt;code>\mathbf&lt;/code> below);&lt;/li>
&lt;li>&lt;strong>Compiles but unusable&lt;/strong>: compilation &amp;ldquo;succeeds&amp;rdquo;, yet the artifact has 0 pages, or figures are missing and every reference renders as [?];&lt;/li>
&lt;li>&lt;strong>Usable but unsafe&lt;/strong>: under &lt;code>-shell-escape&lt;/code>, one &lt;code>\write18&lt;/code> in the generated body is arbitrary command execution.&lt;/li>
&lt;/ol>
&lt;p>The common first reaction is to tighten the prompt or retry the model. Our measured conclusion: prompt constraints lower the incidence but cannot suppress it (the verbatim source comment reads &amp;ldquo;the model routinely omits these; the prompt can&amp;rsquo;t hold it down&amp;rdquo;); retries cost another generation and don&amp;rsquo;t guarantee convergence. Another counter-intuitive result came from root-causing three classes of generation-quality problems: 2 of the 3 root causes were in our own backend code and template, not in the model. The right place for the fix is a layer of &lt;strong>deterministic sanitizing and repair&lt;/strong> between model output and artifact — zero tokens, unit-testable, predictable behavior. It is also the output-side concretization of the &lt;a href="https://kaguc.com/blog/deterministic-boundary/">deterministic boundary&lt;/a> principle from Part 11 of this series: no fragment enters system state without passing a deterministic gate.&lt;/p>
&lt;h2 id="entry-cleanup-strip_fragment-and-check_fragment">Entry cleanup: strip_fragment and check_fragment&lt;/h2>
&lt;p>The first gate sits before a fragment enters the document, and handles domain-independent shape problems:&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">strip_fragment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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;Clean a model-returned section fragment: strip Markdown code fences;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> if a full document came back by mistake, cut from the first heading to just
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> before \end&lt;/span>&lt;span class="si">{document}&lt;/span>&lt;span class="s2"> (keeps preamble / top-level end out of the body).&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Models love wrapping code in Markdown fences; asked to &amp;ldquo;return only this section&amp;rdquo;, they may still disobey and return the whole document. The former gets unwrapped; the latter is cut from the first &lt;code>\section&lt;/code>-family command to just before &lt;code>\end{document}&lt;/code>. The companion &lt;code>check_fragment&lt;/code> runs a health check: balanced braces, balanced &lt;code>\begin&lt;/code>/&lt;code>\end&lt;/code>, no &lt;code>\documentclass&lt;/code>, non-empty — all &lt;strong>soft signals&lt;/strong> that warn rather than block, because the check rules themselves can produce false positives.&lt;/p>
&lt;h2 id="the-pit-catalog-deterministic-rewriting-in-sanitize_tex">The pit catalog: deterministic rewriting in sanitize_tex&lt;/h2>
&lt;p>The second gate runs before compilation and is domain-specific: a &lt;strong>pit catalog&lt;/strong>, every entry earned through a real debugging chain — fixing one exposed the next:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symptom (real error)&lt;/th>
&lt;th>Root cause&lt;/th>
&lt;th>Deterministic fix&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>no legal \end found&lt;/code> (with &lt;code>\end{document}&lt;/code> present)&lt;/td>
&lt;td>&lt;code>{%&lt;/code> inside a &lt;code>\def&lt;/code> line comments out the closing &lt;code>}&lt;/code>; runaway definition swallows the rest&lt;/td>
&lt;td>&lt;code>{%&lt;/code>→&lt;code>{}%&lt;/code> on &lt;code>\def&lt;/code> lines only; the multi-line &lt;code>\abstract{%&lt;/code> idiom is left alone&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>\textfont 11/12 undefined&lt;/code> (&lt;code>\mathbf&lt;/code>); &lt;code>Extended mathchar used as mathchar&lt;/code> (&lt;code>\boldsymbol&lt;/code>)&lt;/td>
&lt;td>both conflict with the xelatex + unicode-math combination&lt;/td>
&lt;td>rewrite uniformly to unicode-math&amp;rsquo;s &lt;code>\symbf&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Extended mathchar&lt;/code> / &lt;code>A number should have been here&lt;/code>&lt;/td>
&lt;td>bold inside accents (&lt;code>\hat{\boldsymbol{x}}&lt;/code>)&lt;/td>
&lt;td>drop the bold, keep the accent; both nesting orders handled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A string of Undefined errors&lt;/td>
&lt;td>template commands like &lt;code>\makeEngPage&lt;/code> called with their prerequisite variables unset&lt;/td>
&lt;td>inject empty defaults after &lt;code>\begin{document}&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A missing-&lt;code>\item&lt;/code> / &lt;code>\noalign&lt;/code> cascade&lt;/td>
&lt;td>the two-column table environment instrmlist used as an itemize with &lt;code>\item&lt;/code>&lt;/td>
&lt;td>rewrite &lt;code>\item Name (Model)&lt;/code> inside the block to &lt;code>Name &amp;amp; Model \\&lt;/code>; already-correct rows untouched&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two entries deserve expansion.&lt;/p>
&lt;p>&lt;strong>We fixed the &lt;code>\mathbf&lt;/code> entry in the wrong direction once.&lt;/strong> The first fix was &lt;code>\mathbf&lt;/code>→&lt;code>\boldsymbol&lt;/code> (the template loads the bm package — seemingly reasonable). Only an end-to-end rerun revealed the direction was wrong: &lt;code>\boldsymbol&lt;/code> triggers the same &amp;ldquo;Extended mathchar used as mathchar&amp;rdquo; under xelatex + unicode-math. The correct target is unicode-math&amp;rsquo;s own &lt;code>\symbf&lt;/code>. After the correction, recompiling a historical version containing 13 occurrences of &lt;code>\mathbf&lt;/code> measured: Extended mathchar 13→0, total errors 48→35. The lesson is twofold: sanitizing rules need empirical verification of their own, or the fix itself introduces regressions; and the rewrite target must be a form that is &lt;em>provably compatible with the current engine combination&lt;/em>, not one that is &amp;ldquo;usually equivalent&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>What value to backfill is a product judgment, not just a technical one.&lt;/strong> The template-variable backfill carries an exception table: when the English-page author name is missing, the value of the corresponding Chinese variable is copied — author names are shared across both language pages, and that beats an author line reduced to a lone &amp;ldquo;and&amp;rdquo;. But the English abstract &lt;code>abstractEng&lt;/code> is deliberately left empty: an abstract requires translation, and pushing Chinese text onto the English page would inject wrong information. The red line for automatic repair: &lt;strong>backfill only defaults that are provably harmless; never guess semantics&lt;/strong>.&lt;/p>
&lt;h2 id="artifact-and-safety-figure-placeholders-font-substitution-injection-stripping">Artifact and safety: figure placeholders, font substitution, injection stripping&lt;/h2>
&lt;p>Some problems live not at the syntax layer but at whether the artifact is usable and safe to distribute.&lt;/p>
&lt;p>&lt;strong>A missing figure takes down the whole document.&lt;/strong> Models routinely &lt;code>\includegraphics&lt;/code> figures that don&amp;rsquo;t exist in the provided material. A missing figure doesn&amp;rsquo;t just report &lt;code>File not found&lt;/code> — it cascades into a truncated .aux: the whole document renders 0 pages and the PDF won&amp;rsquo;t open. The fix is a deterministic substitution: any &lt;code>\includegraphics&lt;/code> referencing a file absent from the figure directory is replaced with an &lt;code>\fbox&lt;/code> placeholder stating &amp;ldquo;this figure was not provided — supply it or remove the reference&amp;rdquo;. Zero LLM calls, two error classes eliminated at once, and a readable multi-page PDF comes out.&lt;/p>
&lt;p>&lt;strong>Platform substitution for a hard-coded font.&lt;/strong> The template&amp;rsquo;s cls hard-codes &lt;code>\setmainfont{Times New Roman}&lt;/code> (a Windows-bundled font); a Linux container doesn&amp;rsquo;t have it, producing 100+ font errors per report. The compile step edits only the &lt;strong>working-directory copy&lt;/strong>: on non-Windows platforms the font is replaced with the always-available Latin Modern Roman; the template itself is untouched. The dev machine (WSL2) taught a related lesson: Liberation&amp;rsquo;s font aliases do not satisfy XeTeX/fontspec&amp;rsquo;s exact-name matching — installing the real MS fonts took font errors from 6 to 0.&lt;/p>
&lt;p>&lt;strong>Injection stripping.&lt;/strong> The template&amp;rsquo;s minted 2.x forces full &lt;code>-shell-escape&lt;/code> (&lt;code>minted.sty:1233&lt;/code> checks &lt;code>\pdf@shellescape=1&lt;/code> and refuses restricted mode), and &lt;code>-shell-escape&lt;/code> lets LaTeX run arbitrary shell commands — which makes LLM-generated body text a genuine attack surface. The sanitizing layer strips command-execution primitives from the body: &lt;code>\write18&lt;/code>, &lt;code>\ShellEscape&lt;/code>, &lt;code>\directlua&lt;/code>, and the piped form &lt;code>\input{|cmd}&lt;/code>. minted&amp;rsquo;s own pygmentize invocation lives in the package layer, not the body, so syntax highlighting is unaffected. End-to-end verification: after injecting &lt;code>\immediate\write18{touch ...}&lt;/code> and compiling, the target file was not created. The standalone distribution drops &lt;code>-shell-escape&lt;/code> entirely — code blocks still typeset via listings, losing only syntax coloring.&lt;/p>
&lt;h2 id="after-compilation-diagnose-and-humanize">After compilation: diagnose and humanize&lt;/h2>
&lt;p>&amp;ldquo;Compilation succeeded&amp;rdquo; lies: under nonstopmode, missing figures and undefined references don&amp;rsquo;t stop a PDF from being produced. &lt;code>diagnose&lt;/code> deterministically tallies four items from the log — error-line count (&lt;code>^!&lt;/code>), the list of missing figures, undefined citations, undefined cross-references — so that &amp;ldquo;succeeded&amp;rdquo; no longer masks a degraded artifact.&lt;/p>
&lt;p>The tally serves two audiences. For the model, &lt;code>extract_errors&lt;/code> picks the &lt;code>!&lt;/code>-prefixed error lines plus context as input to the &lt;a href="https://kaguc.com/blog/compile-self-repair/">compile self-repair&lt;/a> loop (Part 6). For the user, &lt;code>humanize&lt;/code> maps diagnostics and common errors into &amp;ldquo;one plain sentence + one clickable action&amp;rdquo; — along the lines of &amp;ldquo;2 figures missing: a.png, b.png&amp;rdquo; with a button &amp;ldquo;supply the figures, or remove the corresponding \includegraphics&amp;rdquo;; &amp;ldquo;Undefined control sequence&amp;rdquo; becomes &amp;ldquo;an undefined command was used (possibly a typo or a missing package)&amp;rdquo;. The raw log is for engineers; it has no place in the product UI.&lt;/p>
&lt;h2 id="the-pipeline-and-the-measurements">The pipeline, and the measurements&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">Model&lt;/span> &lt;span class="n">output&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">strip_fragment&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">unfence&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">cut&lt;/span> &lt;span class="n">fragment&lt;/span> &lt;span class="n">from&lt;/span> &lt;span class="n">full&lt;/span> &lt;span class="n">doc&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">C&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">check_fragment&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">balance&lt;/span> &lt;span class="n">checks&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">soft&lt;/span> &lt;span class="n">signals&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">D&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">sanitize_tex&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">pit&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">catalog&lt;/span> &lt;span class="n">rewrites&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">exec&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">primitive&lt;/span> &lt;span class="n">stripping&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">Assemble&lt;/span> &lt;span class="n">workdir&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">placeholder&lt;/span> &lt;span class="n">missing&lt;/span> &lt;span class="n">figures&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">swap&lt;/span> &lt;span class="n">font&lt;/span> &lt;span class="n">off&lt;/span> &lt;span class="n">Windows&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">F&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">xelatex&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">bibtex&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">G&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">diagnose&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">error&lt;/span> &lt;span class="n">count&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">missing&lt;/span> &lt;span class="n">figures&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">undefined&lt;/span> &lt;span class="n">refs&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">G&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="n">the&lt;/span> &lt;span class="n">user&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">H&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">humanize&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">one&lt;/span> &lt;span class="n">sentence&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">one&lt;/span> &lt;span class="n">clickable&lt;/span> &lt;span class="n">action&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">G&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="n">the&lt;/span> &lt;span class="n">model&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">I&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">extract_errors&lt;/span> &lt;span class="err">→&lt;/span> &lt;span class="n">compile&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">repair&lt;/span> &lt;span class="n">loop&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Measured item&lt;/th>
&lt;th>Before&lt;/th>
&lt;th>After&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>\mathbf&lt;/code>→&lt;code>\symbf&lt;/code> (recompiling a version with &lt;code>\mathbf&lt;/code>×13)&lt;/td>
&lt;td>Extended mathchar 13, errors 48&lt;/td>
&lt;td>0, errors 35&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Photonic-crystal auto report (after the first three fixes: &lt;code>%&lt;/code> in &lt;code>\def&lt;/code>, math bold, bold-in-accent)&lt;/td>
&lt;td>compile failure (runaway)&lt;/td>
&lt;td>valid 131KB PDF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Real MS fonts installed on WSL2 (aliases fail exact-name matching)&lt;/td>
&lt;td>6 font errors&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Injected &lt;code>\immediate\write18{touch ...}&lt;/code>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>file not created (RCE blocked)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The sanitizing layer is pure functions throughout; when the &lt;code>\symbf&lt;/code> fix landed, the full &lt;code>pytest&lt;/code> run was 37 passed — every rewrite rule, every backfill exception, and the injection stripping is pinned by an assertion.&lt;/p>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>The pit catalog only fixes known pits.&lt;/strong> Every rule is bound to a specific combination (GPE template + xelatex + unicode-math + minted 2.x); a new template or engine means walking the debugging chain again. It is not a general-purpose LaTeX fixer, and should not try to become one.&lt;/li>
&lt;li>&lt;strong>Regex rewriting has an inherent collateral-damage surface.&lt;/strong> Every rule must be narrowed to the lesion: only &lt;code>\def&lt;/code> lines, only inside the instrmlist block, already-correct rows preserved verbatim — and pinned by unit tests. One notch too wide and the rule breaks legitimate input; anything that cannot be narrowed to provably-safe should not be auto-fixed, and belongs to the model-driven compile self-repair loop instead.&lt;/li>
&lt;li>&lt;strong>The ceiling of automatic repair is introducing no wrong information.&lt;/strong> Deterministically decidable defaults (empty variables, placeholder boxes) may be backfilled; semantic gaps (the English abstract) must be left to a human or the model. Cross that line and repair becomes contamination.&lt;/li>
&lt;li>&lt;strong>Sanitizing replaces neither upstream constraints nor the downstream loop.&lt;/strong> The &amp;ldquo;LaTeX compilability rules&amp;rdquo; still belong in the system prompt (they lower incidence), and compile self-repair handles the long tail; the sanitizing layer&amp;rsquo;s role is zero-token interception of high-frequency known pits. The three layers are a union, not substitutes.&lt;/li>
&lt;li>&lt;strong>Injection stripping is one layer of defense in depth, not all of it.&lt;/strong> The regex layer does not cover extreme bypasses such as catcode tricks — an acceptable trade-off for a local single-user tool; a multi-tenant server must stack harder layers (a restricted shell-escape baseline, sandboxed execution environments).&lt;/li>
&lt;/ol></description></item><item><title>Multi-agent adversarial review as an engineering practice</title><link>https://kaguc.com/blog/adversarial-review/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/adversarial-review/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/adversarial-review-zh/">中文&lt;/a>. Part 13 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-the-typical-failure-of-an-llm-finding-is-that-it-looks-real">The problem: the typical failure of an LLM finding is that it looks real&lt;/h2>
&lt;p>When you use an LLM to review code, volume is never the bottleneck — it will happily list fifty &amp;ldquo;potential issues&amp;rdquo; against any codebase. The bottleneck is at the other end: the typical failure mode of an LLM finding is &lt;strong>plausible-but-wrong&lt;/strong>. The line number is specific, the failure narrative is complete, the suggested fix is fully formed — and then it falls apart the moment you confront it with the code: the guard already exists, the alleged race path is unreachable, the &amp;ldquo;leaked&amp;rdquo; resource is reclaimed somewhere else.&lt;/p>
&lt;p>A review pipeline without a verification stage drowns in fake findings, and fake findings cost you both ways: either you falsify each one by hand, paying back every hour the review saved, or you start ignoring the report wholesale — and the real problems die with the fake ones. The second outcome is the more common and the more fatal: a report that mixes true and false findings carries close to zero engineering information.&lt;/p>
&lt;p>A separate, independent problem is breadth: a single-perspective review — whether a human or a single agent — cannot stay simultaneously alert across security, concurrency, billing, and cross-platform consistency.&lt;/p>
&lt;p>Both problems point at the same structural remedy: split &amp;ldquo;find problems&amp;rdquo; and &amp;ldquo;verify problems&amp;rdquo; into two stages with opposite objectives — the finding side optimizes for coverage, the verifying side optimizes for truth. This article walks through the structure, grounded in two production runs (one full-project audit, one session-level review) plus one set of overturn data.&lt;/p>
&lt;h2 id="the-structure-parallel-discovery-by-dimension-adversarial-verification-per-finding">The structure: parallel discovery by dimension, adversarial verification per finding&lt;/h2>
&lt;p>&lt;strong>Stage one: N auditors in parallel, each guarding exactly one dimension.&lt;/strong> The full-project audit used 6 (backend core, API-DB-security, LaTeX-verify-distill, frontend, test blind spots, documentation drift); the session-level review used 4 (concurrency/runs, security, frontend regression, data integrity). The point of slicing by dimension is not parallel speed-up but &lt;strong>forced perspective&lt;/strong>: an auditor that only looks at concurrency does not have its attention taxed by functional correctness.&lt;/p>
&lt;p>Findings obey a format discipline: every one must land as &lt;code>file:line&lt;/code> + a concrete failure scenario + a proposed fix. This is the precondition for everything downstream — it turns a finding from &amp;ldquo;something feels off&amp;rdquo; into a &lt;strong>falsifiable claim&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Stage two: each finding goes to an independent verifier.&lt;/strong> The verifier is not the agent that produced the finding and does not enter with that agent&amp;rsquo;s reasoning in context; its instructions are &lt;strong>default skepticism, specialize in falsification&lt;/strong> — go into the code and look for evidence that the finding does &lt;em>not&lt;/em> hold. The verdict is three-valued: CONFIRMED / uncertain / dismissed, and only CONFIRMED enters the repair queue.&lt;/p>
&lt;p>Independence is a requirement, not an option. When one agent checks its own work, its finding is sunk cost sitting in its context, and it will defend it; only when &amp;ldquo;successfully overturning the finding&amp;rdquo; is the verifier&amp;rsquo;s success criterion do the incentives align. This is the same issue as &lt;a href="https://kaguc.com/blog/judge-validity/">judge validity&lt;/a> from part 9 of this series, seen from the other side: the verifier is itself a judge, and its validity has to be demonstrated by the fact that it actually overturns things — data below.&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[Codebase / change set] --&amp;gt; B1[Auditor: security]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> A --&amp;gt; B2[Auditor: concurrency]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> A --&amp;gt; B3[Auditor: ... N dimensions total]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B1 --&amp;gt; F[Finding: file:line + failure scenario + fix]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B2 --&amp;gt; F
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B3 --&amp;gt; F
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> F --&amp;gt; V{Independent verifier: default skepticism, falsify}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> V --&amp;gt;|CONFIRMED| T[Triage by severity]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> V --&amp;gt;|dismissed| X[Discard]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> V --&amp;gt;|uncertain| U[File as open question]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> T --&amp;gt; R[Fix, or defer explicitly with a recorded reason]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> R --&amp;gt; P[Regression tests confirm no breakage]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="two-measured-runs">Two measured runs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Full-project audit&lt;/th>
&lt;th>Session-level review&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Scope&lt;/td>
&lt;td>Entire codebase (backend + frontend + docs + tests), focused on the broader/older parts&lt;/td>
&lt;td>One session&amp;rsquo;s changes: 44 files / 1,467 lines, including a core-path refactor&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Auditors&lt;/td>
&lt;td>6, by subsystem/dimension&lt;/td>
&lt;td>4, by risk dimension&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CONFIRMED&lt;/td>
&lt;td>27 (5 high / 14 medium / 8 low)&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>uncertain / dismissed&lt;/td>
&lt;td>0 / 2&lt;/td>
&lt;td>all others falsified&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Disposition&lt;/td>
&lt;td>24 fixed, 3 explicitly deferred&lt;/td>
&lt;td>7 fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Post-fix verification&lt;/td>
&lt;td>pytest 243 passed (+6 new tests), vite build clean&lt;/td>
&lt;td>pytest 18 passed + targeted live tests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Beyond the counts, what deserves attention is the &lt;strong>type&lt;/strong> of problem on the confirmed list — a substantial share is the kind a single person reading the diff end-to-end would very likely miss:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TOCTOU race in the 409 concurrency guard&lt;/strong> (session-level): &amp;ldquo;check whether a run is already running&amp;rdquo; and &amp;ldquo;create the run&amp;rdquo; were not atomic, so concurrent requests could double-start. The fix moved the check, the creation, and the handle registration together inside &lt;code>runs._LOCK&lt;/code>; the repair was closed out empirically — two concurrent requests return &lt;code>[200, 409]&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Chat billing used the unresolved provider&lt;/strong> (session-level): with &lt;code>DEFAULT_PROVIDER=anthropic&lt;/code> and the request omitting the provider field, cost was computed at deepseek&amp;rsquo;s unit prices — roughly a 60× underestimate. Provider resolution lives in one place, pricing in another; reviewed module by module, both sides &amp;ldquo;look correct&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Anthropic streaming &lt;code>max_tokens=32000&lt;/code> while DeepSeek&amp;rsquo;s was 64000&lt;/strong> (full audit, medium): long reports silently truncated on one provider; only a cross-provider consistency comparison exposes it. Fixed by unifying on &lt;code>_STREAM_MAX_TOKENS=64000&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Figure sandbox environment hardcoded POSIX&lt;/strong> (full audit, high): on the Windows distribution (the primary platform), the CPython subprocess cannot start for lack of SystemRoot/TEMP, so &lt;code>make_figure&lt;/code> crashes unconditionally.&lt;/li>
&lt;li>&lt;strong>&lt;code>EXPOSE_RESET_TOKEN&lt;/code> defaulting to true&lt;/strong> (full audit, high): any anonymous caller could obtain a reset token and change a password — account takeover; changed to a secure default of false.&lt;/li>
&lt;/ul>
&lt;p>These respectively require concurrency-interleaving reasoning, cross-module dataflow tracing, cross-provider comparison, and cross-platform runtime knowledge — exactly the categories that one dedicated perspective per dimension covers and one sequential read-through misses.&lt;/p>
&lt;p>One disposition detail worth copying: the 3 deferrals are not silent omissions but &lt;strong>explicit decisions with recorded reasons&lt;/strong> — all are SaaS-only issues neutralized in the shipped STANDALONE configuration by &lt;code>_no_cloud&lt;/code>/forced-local, and fixing them outright would have meant disturbing a set of non-STANDALONE test semantics; they are filed for whenever SaaS is revived. The output of a review is not just fixes; it is also this traceable record of &lt;em>why not&lt;/em>.&lt;/p>
&lt;h2 id="verification-is-not-a-rubber-stamp-overturn-data">Verification is not a rubber stamp: overturn data&lt;/h2>
&lt;p>Whether the adversarial verification stage actually works needs evidence of its own. The same find-then-adversarially-recheck structure was applied to a different kind of object: auditing whether the writing rules produced by the &lt;a href="https://kaguc.com/blog/distill-judgment-experiment/">distillation experiment&lt;/a> (part 12 of this series) genuinely learned &amp;ldquo;judgment&amp;rdquo; rather than reskinning structural templates. Eleven agents in total: for each rule pair, an item-by-item initial classification, followed by an adversarial re-review whose instructions were, again, default skepticism — specializing in items whose &amp;ldquo;because&amp;rdquo; is a tautology or whose structural action carries only a thin veneer of justification.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pair&lt;/th>
&lt;th>Initial judgment share&lt;/th>
&lt;th>After adversarial re-review&lt;/th>
&lt;th>Pair verdict&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>87%&lt;/td>
&lt;td>80%&lt;/td>
&lt;td>holds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>82%&lt;/td>
&lt;td>73%&lt;/td>
&lt;td>holds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>73%&lt;/td>
&lt;td>67%&lt;/td>
&lt;td>holds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>82%&lt;/td>
&lt;td>64%&lt;/td>
&lt;td>holds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>100%&lt;/td>
&lt;td>90%&lt;/td>
&lt;td>holds&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The initial numbers of &lt;strong>all five pairs were revised downward&lt;/strong>; the post-adversarial mean is 0.746, and the overturned items follow one highly consistent pattern — the action overlaps a structural template and the stated reason is close to a tautology. The pair-level verdicts held 5/5, but no pair emerged untouched. The original report also flags, unprompted, that pair 5&amp;rsquo;s 0.9 is optimistic (the initial 1.0 was itself suspicious).&lt;/p>
&lt;p>This data establishes two things. First, the verifier really does overturn items case by case — it is not process decoration. Second, the value of the adversarial stance runs in both directions: in code review it kills false positives (2 dismissed; &amp;ldquo;all others falsified&amp;rdquo; at the session level), and in evaluation tasks it deflates optimistic self-assessment (100% → 90%).&lt;/p>
&lt;h2 id="cost-and-applicability">Cost and applicability&lt;/h2>
&lt;p>The cost structure is straightforward: every finding pays for one additional dedicated verification pass, plus orchestration overhead. When it is worth it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Large change surfaces.&lt;/strong> The session-level run covered 44 files / 1,467 lines including a core-path refactor (&amp;ldquo;merging chat into run&amp;rdquo;) — a surface beyond what one person can hold in a single pass.&lt;/li>
&lt;li>&lt;strong>Pre-release / security surfaces.&lt;/strong> Problems like &lt;code>EXPOSE_RESET_TOKEN&lt;/code> or the Stripe webhook skipping validation when no secret is configured (fixed to fail closed: 503 without a secret) have a miss cost wildly out of proportion to the verification cost.&lt;/li>
&lt;li>&lt;strong>Legacy code never systematically audited.&lt;/strong> The full-project audit&amp;rsquo;s stated goal was precisely &amp;ldquo;not just the new code — focus on the broader/older parts&amp;rdquo;, code outside the field of view of any recent review.&lt;/li>
&lt;/ul>
&lt;p>When it is not worth it, the source record already contains the judgment: single-feature implementation is &amp;ldquo;sequential and interleaved, with no fan-out&amp;rdquo; — solo work suffices; review and audit (breadth plus independent verification) are what the multi-agent workflow is actually good at. Extrapolating to small changes: on a few dozen lines of diff, falsifying three findings by hand is far faster than orchestrating a six-auditor pipeline, and the verification overhead simply exceeds the benefit.&lt;/p>
&lt;h2 id="limits">Limits&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>The shared-origin blind spot.&lt;/strong> The verifier and the author of the code under audit are the same (or a sibling) LLM. The adversarial structure removes positional bias — no one defends their own output anymore — but it does not extend the capability boundary: a category of problem invisible to both sides stays invisible to the verification stage too. It is no substitute for signals of a different origin — actually running the code, live concurrency tests, human domain knowledge.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;0 uncertain&amp;rdquo; does not mean &amp;ldquo;0 escaped&amp;rdquo;.&lt;/strong> 27 CONFIRMED with 0 uncertain measures the precision side: nearly everything raised was real. It says nothing about the recall side — the problems no auditor raised at all. This very audit confirmed three test blind spots: an audit can find the holes in the tests, but there is no instrument measuring the holes in the audit.&lt;/li>
&lt;li>&lt;strong>Verification depth is itself sampled.&lt;/strong> The overturn experiment&amp;rsquo;s report states honestly that only pair 4&amp;rsquo;s source file was empirically spot-checked; the rest relies on sub-agent labeling. Verification can be shallow, and a CONFIRMED stamped by shallow verification deserves a discount — mistaking &amp;ldquo;verification happened&amp;rdquo; for &amp;ldquo;verification was thorough&amp;rdquo; is this pipeline&amp;rsquo;s most seductive self-deception.&lt;/li>
&lt;li>&lt;strong>The structure depends on finding discipline.&lt;/strong> &lt;code>file:line&lt;/code> + failure scenario + fix is what makes a finding a falsifiable claim; relax it to &amp;ldquo;module X may have issues&amp;rdquo; and the verifier has nothing to confront — the whole pipeline degenerates into two rounds of opinion exchange.&lt;/li>
&lt;/ol></description></item><item><title>Prompt assembly as engineering: layering, contamination, and trimming</title><link>https://kaguc.com/blog/prompt-assembly/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/prompt-assembly/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/prompt-assembly-zh/">中文&lt;/a>. Part 2 of the Engineering LLM Applications series, grounded — like the rest of the series — in the real source code and tests of a production-grade AI writing agent.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-when-the-prompt-grows-too-long-for-anyone-to-dare-touch">The problem: when the prompt grows too long for anyone to dare touch&lt;/h2>
&lt;p>When your first LLM feature ships, the prompt is usually one f-string: a role blurb, format requirements, a few examples, the user input, concatenated and sent. That stage is fine — the problems arrive three months later:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Fix one place, break another.&lt;/strong> The same prompt text is reused by several call paths; wording tuned for scenario A quietly degrades scenario B&amp;rsquo;s output, and no test turns red.&lt;/li>
&lt;li>&lt;strong>Example content leaks into output.&lt;/strong> To teach the model &lt;em>how to write&lt;/em>, you paste in a model essay; one day a user finds that essay&amp;rsquo;s numbers and citations inside their own report.&lt;/li>
&lt;li>&lt;strong>Multi-turn gets more expensive every turn.&lt;/strong> Each turn carries the full history, and the history contains entire documents the model previously produced; token spend climbs with turn count, most of it redundant.&lt;/li>
&lt;li>&lt;strong>Changing a sentence requires a release.&lt;/strong> The prompt is hard-coded in source; tweaking one line of wording in the field means shipping a new package.&lt;/li>
&lt;/ol>
&lt;p>These four failure classes share one root cause: the prompt is treated as &lt;em>a piece of text&lt;/em> rather than a &lt;strong>build artifact&lt;/strong>. A build artifact implies three things: a layered structure (each layer with its own change frequency and ownership), incidents (which need incident records), and regression protection (changes are guarded by tests). The rest of this article dissects that practice through &lt;code>prompts.py&lt;/code> and its tests, from this series&amp;rsquo; evidence-base project (an AI writing agent producing LaTeX lab reports).&lt;/p>
&lt;h2 id="mechanism-1-layered-assembly--each-layer-has-its-own-change-frequency">Mechanism 1: layered assembly — each layer has its own change frequency&lt;/h2>
&lt;p>The system prompt is a concatenation of five layers (&lt;code>build_system&lt;/code>), not one string:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Content&lt;/th>
&lt;th>Change frequency&lt;/th>
&lt;th>Hot-swappable&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Role boundary&lt;/td>
&lt;td>Identity + hard prohibitions (no AI-speak, no fabricated data or citations)&lt;/td>
&lt;td>Very low&lt;/td>
&lt;td>Via overlay&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Genre spec&lt;/td>
&lt;td>Two section skeletons: lab report / course notes&lt;/td>
&lt;td>Low&lt;/td>
&lt;td>Via overlay&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quality anchors&lt;/td>
&lt;td>Writing methodology + golden sample (permanent domain assets)&lt;/td>
&lt;td>Low&lt;/td>
&lt;td>Via overlay&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Writing-judgment skill&lt;/td>
&lt;td>Distilled &amp;ldquo;why we write it this way&amp;rdquo;&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Distillable, replaceable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output-format hard rules&lt;/td>
&lt;td>LaTeX template skeleton + 6 compile-error pitfalls&lt;/td>
&lt;td>Tracks the template&lt;/td>
&lt;td>Via overlay&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three design points:&lt;/p>
&lt;p>&lt;strong>Move knowledge assets out of code.&lt;/strong> The methodology (~4KB of Markdown) and the golden sample (a 2.2KB LaTeX technique excerpt) live under the &lt;code>knowledge/&lt;/code> directory as file assets; code only assembles. Together with the bib file and the material pack they form the agent&amp;rsquo;s domain assets — reviewable and replaceable independently of the code.&lt;/p>
&lt;p>&lt;strong>Write format rules with their error messages attached.&lt;/strong> The output-format layer does not just say &amp;ldquo;do this&amp;rdquo;; it lists six numbered pitfalls, each with the real compile error that violation triggers — e.g. &amp;ldquo;before calling &lt;code>\makeEngPage{}&lt;/code> you must &lt;code>\def&lt;/code> the English metadata … missing any one of them raises &lt;em>Undefined control sequence&lt;/em>&amp;rdquo;, and &amp;ldquo;bold vectors only with &lt;code>\symbf{}&lt;/code>; &lt;code>\mathbf&lt;/code> triggers &lt;em>Extended mathchar&lt;/em>&amp;rdquo;. These were baked into the prompt after real failures — the compiler&amp;rsquo;s error-driven experience, front-loaded.&lt;/p>
&lt;p>&lt;strong>Trim by call site.&lt;/strong> Section-level small edits (scoped editing, see &lt;a href="https://kaguc.com/blog/deterministic-boundary/">The deterministic boundary&lt;/a>) assemble with &lt;code>scoped=True&lt;/code>: the quality anchors are dropped — methodology plus golden sample come to roughly 3.2k characters, about 2k tokens, and a one-section fragment edit has no use for a full model essay about someone else&amp;rsquo;s experiment. The role boundary, the distilled judgment layer, and the format rules stay, because the fragment must still be stylistically correct and compilable.&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[Assemble system] --&amp;gt; B[Role boundary]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> B --&amp;gt; C[Genre spec: report or notes]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt; D{Scoped small edit?}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> D --&amp;gt;|yes| E[Skip quality anchors, keep distilled judgment layer]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> D --&amp;gt;|no, full generation| F{Distilled skill present?}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> F --&amp;gt;|yes| G[Fixed quality anchors, dedup, then judgment layer]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> F --&amp;gt;|no| H[Built-in judgment layer = the anchors]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> E --&amp;gt; I[Output-format hard rules]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> G --&amp;gt; I
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> H --&amp;gt; I
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="mechanism-2-a-real-incident--the-quality-anchors-got-skipped">Mechanism 2: a real incident — the quality anchors got skipped&lt;/h2>
&lt;p>Layering is not about tidiness on paper; it exists because &lt;em>not&lt;/em> layering caused a real incident. The incident record sits in the docstring of &lt;code>build_system&lt;/code> itself (translated; the source is Chinese):&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="s2">&amp;#34;&amp;#34;&amp;#34;⚠️ An early L1 refactor made the &amp;#34;distilled template&amp;#34; path (skill_prompt
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">non-empty) skip builtin_skill entirely → golden sample + methodology lost →
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">generation depth/style collapse (the root cause of &amp;#34;output is worse with
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">distilled templates&amp;#34;). Quality anchors are now extracted and always injected,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">with dedup for old built-in/legacy templates that carry their own anchors.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The post-mortem: in the early implementation the quality anchors were bundled inside the &amp;ldquo;built-in skill&amp;rdquo;; a refactor made the &amp;ldquo;use a distilled template&amp;rdquo; path skip the built-in skill entirely — golden sample and methodology vanished together, and generation depth and style collapsed. The symptom read as &amp;ldquo;distilled templates produce worse output&amp;rdquo; and was for a while investigated as a distillation-quality problem; the actual cause was a missing layer in assembly.&lt;/p>
&lt;p>The fix hardened into two structural decisions. First, &lt;strong>decouple quality anchors from the skill layer&lt;/strong>: the anchors are &amp;ldquo;permanent domain assets&amp;rdquo;, independent of whether a distilled template is in use; a distilled template carries only the judgment layer (why to write it this way) and is not allowed to displace the depth/style anchors. Second, &lt;strong>dedup for legacy templates&lt;/strong> — old built-in templates that already carry the anchors are not injected twice:&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">if&lt;/span> &lt;span class="n">skill&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">anchors&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_quality_anchors&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">kind&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">anchors&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;金样例&amp;#34;&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">skill&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># skip if the skill already embeds the golden sample&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parts&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">anchors&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parts&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">skill&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The transferable lesson: layered assembly must state explicitly &lt;strong>which layers are unconditionally present&lt;/strong> — otherwise any &amp;ldquo;looks equivalent&amp;rdquo; refactor can silently remove a layer. A prompt missing a layer throws no error; it only degrades.&lt;/p>
&lt;h2 id="mechanism-3-few-shot-contamination-guards">Mechanism 3: few-shot contamination guards&lt;/h2>
&lt;p>The golden sample is a double-edged sword. It is a model-essay excerpt about YBCO — &lt;em>someone else&amp;rsquo;s experiment&lt;/em> — used to teach &amp;ldquo;how to write with depth, to spec, and compilably&amp;rdquo;. The natural risk of few-shot is content contamination: the model copies the sample&amp;rsquo;s subject, numbers, and citations into the user&amp;rsquo;s report — fabricating data by way of the example. There are three guards, all at the wording level:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>An isolation label at injection time.&lt;/strong> The assembly code wraps the golden sample in a declaration: &amp;ldquo;it is a model essay about &lt;strong>another experiment&lt;/strong> — &lt;strong>never copy its subject / title / keywords / numbers / citations / section layout&lt;/strong>; you must write strictly from this session&amp;rsquo;s materials, about the experiment you actually did.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>The sample file carries its own immunization comments.&lt;/strong> &lt;code>fewshot_ybco.tex&lt;/code> is not a complete report but a 2.2KB &lt;em>technique excerpt&lt;/em>: only an equation-bearing principle paragraph, a booktabs table, and similar craft demonstrations survive. Its header comment restates &amp;ldquo;the topic below is only an example … never copy this excerpt&amp;rsquo;s title, data, citations, or sections&amp;rdquo;, and even the table caption reads &amp;ldquo;example — a real report must contain this experiment&amp;rsquo;s real data&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The methodology layer adds an iron rule.&lt;/strong> The methodology file&amp;rsquo;s own hard-rules section independently repeats: &amp;ldquo;never copy the model essay&amp;rsquo;s / golden sample&amp;rsquo;s subject, numbers, or citations — that is someone else&amp;rsquo;s experiment.&amp;rdquo;&lt;/li>
&lt;/ol>
&lt;p>The triple redundancy is deliberate: wording-level protection is probabilistic, and a single declaration can be diluted in a long context. The final backstop is not in the prompt layer at all — a provenance check runs server-side against the &lt;em>full&lt;/em> materials, deterministically. That belongs to the deterministic-boundary side of the system.&lt;/p>
&lt;h2 id="mechanism-4-multi-turn-history-folding-and-material-trimming">Mechanism 4: multi-turn history folding and material trimming&lt;/h2>
&lt;p>In multi-turn revision, what the model needs is the &lt;strong>thread of per-turn feedback&lt;/strong> (what the user objected to first, what changed next) — not the full text of every historical version. Yet the assistant&amp;rsquo;s history messages are precisely entire LaTeX documents. The folding rule in &lt;code>build_messages&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="k">if&lt;/span> &lt;span class="n">role&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;assistant&amp;#34;&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\\&lt;/span>&lt;span class="s2">documentclass&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">content&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;[此前已生成的文档版本，此处省略源码]&amp;#34;&lt;/span> &lt;span class="c1"># &amp;#34;earlier document version, source elided&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">elif&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">max_chars&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">content&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="n">max_chars&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;…（略）&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three points. Full documents are replaced by a one-line placeholder — the current document is already supplied separately in this turn&amp;rsquo;s user content, so re-sending every historical version burns tokens for zero value. &lt;strong>Non-document assistant notes are kept&lt;/strong> — e.g. an auto-exploration conclusion like &amp;ldquo;the data is in the xrd/ directory&amp;rdquo; is a fact later turns need. Everything else over 4,000 characters is truncated, and only the last 40 history messages are kept. Dedicated tests pin this behavior (document folding, note preservation, count capping).&lt;/p>
&lt;p>Materials get the same deterministic trimming (zero LLM calls, zero tokens): in revision turns, materials over 12k characters are filtered by lexical overlap with the current feedback; pure-number matrix lines (bare CSV) are always dropped — the figure pipeline has already digested the raw data, and prose writing has no use for row-level numbers. If filtering keeps fewer than two lines and under 40 characters, it falls back to the head of the material — filtering must never destroy all context. On the chat path, materials become a &lt;strong>per-file-quota&lt;/strong> stable digest (each file guaranteed at least a 600-character allowance, so later files are not starved by earlier large ones), depending only on the material itself and therefore byte-identical across turns — usable as a stable first-message prefix that hits DeepSeek&amp;rsquo;s automatic prefix caching.&lt;/p>
&lt;h2 id="mechanism-5-the-overlay-hot-swap-layer-and-its-regression-gates">Mechanism 5: the overlay hot-swap layer and its regression gates&lt;/h2>
&lt;p>The last cost of hard-coded prompts is release coupling: changing one sentence in the field means shipping a new installer. The remedy is the agent-pack overlay:&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">_prompt_part&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rel&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">default&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">_overlay_read&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rel&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">default&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Role, genre specs, format rules, methodology, and golden sample are all overridable: a same-named non-empty file in the overlay directory wins; otherwise the built-in default applies. &lt;code>_overlay_read&lt;/code> &lt;strong>reads on every call&lt;/strong> (lazy loading) — swap the pack and the next generation uses it, with no recompile and no restart; a failed read or an empty file returns the empty string and falls back to the built-in, so an empty pack can never wipe out the knowledge.&lt;/p>
&lt;p>The danger of a hot-swap layer is that &lt;strong>behavior with no overlay must not change by a single byte&lt;/strong>. Two groups of offline tests guard this:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Overlay behavior tests&lt;/strong>: no overlay → bundled defaults; overlay takes precedence and is lazy (change the content from V1 to V2 and the next call sees V2); an empty file falls back to the built-in.&lt;/li>
&lt;li>&lt;strong>Prompt-shape assertions in the golden regression gate&lt;/strong>: the scoped prompt must still contain the &amp;ldquo;rewrite only this block&amp;rdquo; instruction and the prohibition wording (with &lt;code>\documentclass&lt;/code> and &lt;code>\end{document}&lt;/code> appearing &lt;em>inside the prohibition&lt;/em>); &lt;code>build_user(gaps=None)&lt;/code> must be byte-identical to calling without &lt;code>gaps&lt;/code> — proving a newly added parameter did not contaminate the existing path.&lt;/li>
&lt;/ul>
&lt;p>Both groups are plain &lt;code>pytest&lt;/code>, fully offline: whoever edits the prompt back to &amp;ldquo;output the full document&amp;rdquo;, or lets the default path drift, turns CI red immediately.&lt;/p>
&lt;h2 id="costs-and-gains">Costs and gains&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mechanism&lt;/th>
&lt;th>Cost of not doing it&lt;/th>
&lt;th>Effect in practice&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Layering + scoped trimming&lt;/td>
&lt;td>Full system prompt on every small edit&lt;/td>
&lt;td>~2k tokens of quality anchors saved per scoped edit; with scoped editing, output tokens drop an order of magnitude&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fixed anchor injection&lt;/td>
&lt;td>Relying on each template to carry its own&lt;/td>
&lt;td>Fixed the &amp;ldquo;distilled-template collapse&amp;rdquo; incident; dedup prevents double injection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Contamination labels × 3&lt;/td>
&lt;td>Bare few-shot, model-essay content leaking&lt;/td>
&lt;td>Three wording-level guards + server-side provenance backstop&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>History folding + material trimming&lt;/td>
&lt;td>Re-sending all history and materials every turn&lt;/td>
&lt;td>Full documents fold to a one-line placeholder; 40-message / 4,000-character double cap; stable prefix hits caching&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Overlay hot-swap&lt;/td>
&lt;td>Every wording change is a release&lt;/td>
&lt;td>Swap the pack, next generation applies it; no-overlay behavior byte-identical, guarded by golden gates&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Do not layer during prototyping.&lt;/strong> With one call path and a prompt still changing daily, one f-string &lt;em>is&lt;/em> the correct form; layering and golden gates are for the stage where the prompt has stabilized, multiple call sites reuse it, and the field needs hot swaps. Premature abstraction pays every cost twice.&lt;/li>
&lt;li>&lt;strong>Shape assertions do not test semantics.&lt;/strong> The golden gate asserts keyword presence and byte identity; a rewording that preserves the keywords passes green while quality shifts. Semantic drift needs online evaluation sets — see part 8 of this series, &lt;a href="https://kaguc.com/blog/llm-testing-pyramid/">The testing pyramid for LLM applications&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Contamination guards are probabilistic.&lt;/strong> Isolation labels lower the copy probability; they do not zero it. High-risk domains (numbers, citations) need deterministic verification outside the prompt as the backstop — the defense cannot live in wording alone.&lt;/li>
&lt;li>&lt;strong>Hot swapping cuts both ways.&lt;/strong> The overlay lets a field pack bypass the bundled tests — golden gates guard the built-in defaults, not the pack&amp;rsquo;s contents. Hot-swap capability must come with an acceptance process for the pack itself; otherwise it is an unguarded change channel.&lt;/li>
&lt;/ol></description></item><item><title>The deterministic boundary: a first principle for LLM application architecture</title><link>https://kaguc.com/blog/deterministic-boundary/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/deterministic-boundary/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/deterministic-boundary-zh/">中文&lt;/a>. Part 11 of the Engineering LLM Applications series — where the mechanisms of the preceding ten articles converge into one principle. The series is grounded in the implementation and measurements of a production-grade AI writing agent (FastAPI + React + Tauri, 351 test cases).&lt;/em>&lt;/p>
&lt;h2 id="the-problem-probabilistic-components-engineering-obligations">The problem: probabilistic components, engineering obligations&lt;/h2>
&lt;p>Once an LLM is embedded in a production application, the hard engineering problem is not that the model is insufficiently smart. It is that a &lt;strong>probabilistic component&lt;/strong> degrades system properties in several predictable ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Scope escape.&lt;/strong> Ask the model to &amp;ldquo;fix one section&amp;rdquo; and it rewrites the whole document. Empirical measurement of full-rewrite revision (arXiv:2601.13217) shows revisions regress 16–27% of already-covered content on average — not a model slip, but a statistical property of open-ended rewriting.&lt;/li>
&lt;li>&lt;strong>Loop collapse.&lt;/strong> In long contexts, agents degenerate into calling the same tool with identical arguments. A public case (qwen-code issue #4695) records 43 consecutive &lt;code>git status&lt;/code> calls consuming 8.9M tokens in one session; SDK-level retries cannot help, because every call succeeds at the API layer.&lt;/li>
&lt;li>&lt;strong>Cost drift.&lt;/strong> If every turn carries the full context, a &amp;ldquo;fix the keywords&amp;rdquo; request pays for the entire document plus all source material — we measured roughly 6–7k input tokens for such requests, of which about 1.5k carries information.&lt;/li>
&lt;li>&lt;strong>Untestable regressions.&lt;/strong> Non-deterministic output defeats conventional assertions; a prompt edit silently shifts quality and CI has nothing to catch it with.&lt;/li>
&lt;/ol>
&lt;p>Against these failure modes, our project converged on one architectural principle, which this article calls the &lt;strong>deterministic boundary&lt;/strong>:&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Never ask the model a question deterministic code can answer; let the model produce only the minimal necessary fragment; admit no fragment into system state without passing a deterministic gate.&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;p>The principle is orthogonal to two existing families of tooling: structured outputs / function calling address &lt;strong>format&lt;/strong> determinism (what the output looks like); guardrail frameworks address &lt;strong>content&lt;/strong> compliance (what the output may say). The deterministic boundary addresses &lt;strong>scope and state&lt;/strong> determinism — which part of the system the output is allowed to modify, and under what conditions it lands. The &lt;code>str_replace&lt;/code> / fast-apply pattern in code editors (model produces the diff, application is deterministic) is the same principle expressed in another domain.&lt;/p>
&lt;p>The rest of this article walks through five mechanisms, using document revision as the running example. The stack is Python/FastAPI and the document format is LaTeX, but the mechanisms are format-agnostic: they apply to any domain with parseable structure.&lt;/p>
&lt;h2 id="mechanism-1-deterministic-targeting--the-sectionizer">Mechanism 1: deterministic targeting — the sectionizer&lt;/h2>
&lt;p>Scoped editing presupposes a deterministic answer to &amp;ldquo;what is the character range of the target section.&amp;rdquo; Our sectionizer (~260 lines, zero LLM calls, zero dependencies) parses LaTeX source into a node tree along &lt;code>\section&lt;/code> / &lt;code>\subsection&lt;/code> / &lt;code>\subsubsection&lt;/code>, assigning each node a &lt;code>char_start&lt;/code> / &lt;code>char_end&lt;/code>. Two design points deserve expansion:&lt;/p>
&lt;p>&lt;strong>Mask, don&amp;rsquo;t shift.&lt;/strong> A &lt;code>\section&lt;/code> inside a comment or a verbatim environment is a false heading — but deleting those regions before parsing would invalidate every character offset. The solution is to replace them with &lt;strong>equal-length whitespace&lt;/strong>:&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">_mask&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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;Replace comments and verbatim environments with equal-length spaces
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> (all character offsets preserved), so a \section inside them is not
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> treated as a real heading — while splice still uses original offsets.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Headings are located on the masked text; content is read from the original at the same offsets. Parsing and splicing share one coordinate system, so there is no translation step — and no translation bugs.&lt;/p>
&lt;p>&lt;strong>Dual anchors.&lt;/strong> Every node carries both a positional anchor (&lt;code>n0&lt;/code>, &lt;code>n1&lt;/code>, stable within one version) and a content-fingerprint key (&lt;code>L1:ErrorAnalysis&lt;/code>, derived from level + title). Positional anchors drift when sections are inserted or removed; content keys follow the title and survive across versions, so they take precedence during resolution. This is the cheapest possible mechanism for &amp;ldquo;refer to the same section across versions&amp;rdquo;: no diff algorithm, just two string keys.&lt;/p>
&lt;p>The failure mode is deterministic too: if no structure parses, the sectionizer returns &lt;code>editable=False&lt;/code> and the caller falls back to whole-document revision. &lt;strong>The boundary machinery never raises into the main flow&lt;/strong> — its job is to narrow the model&amp;rsquo;s scope, not to add a failure point.&lt;/p>
&lt;h2 id="mechanism-2-scoped-generation-and-deterministic-splicing">Mechanism 2: scoped generation and deterministic splicing&lt;/h2>
&lt;p>After targeting, the prompt sent to the model contains only the target section (plus hard rules against scope escape), and the model returns only a replacement fragment for that section. The fragment&amp;rsquo;s path back into the master document is entirely code:&lt;/p>
&lt;ol>
&lt;li>&lt;code>strip_fragment&lt;/code> — cleanup: remove Markdown fences; if the model disobeyed and returned a full document, cut out the span from the first heading to just before &lt;code>\end{document}&lt;/code>;&lt;/li>
&lt;li>&lt;code>check_fragment&lt;/code> — health check: balanced braces, balanced environments, no &lt;code>\documentclass&lt;/code> (soft signals that warn rather than block);&lt;/li>
&lt;li>&lt;code>splice(content, start, end, new_text)&lt;/code> — a one-line reassembly in which &lt;strong>everything outside the range is byte-identical&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>Byte-identical is a testable promise, not an adverb: the assertion is written directly into the test suite. One implementation lesson: a fragment must preserve the count of top-level &lt;code>\section&lt;/code> commands within the replaced span, otherwise the positional anchors of subsequent edits in the same round drift and hit the wrong section. That bug was caught by an internal adversarial review and is now pinned by a permanent test.&lt;/p>
&lt;h2 id="mechanism-3-zero-token-pre-routing">Mechanism 3: zero-token pre-routing&lt;/h2>
&lt;p>&amp;ldquo;Which part does this feedback want to change&amp;rdquo; is a routing problem. The default is to ask a model, but a substantial share of feedback carries an &lt;strong>unambiguous explicit target&lt;/strong>: &amp;ldquo;the bibliography format is wrong&amp;rdquo;, &amp;ldquo;the abstract is too long&amp;rdquo;, &amp;ldquo;the units in Table 2 are off&amp;rdquo;, &amp;ldquo;rewrite §3.1&amp;rdquo;. Those are decided by rules:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">signal classes = { bibliography terms, abstract/keywords, section references
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (Table N / Figure N / § numbers / unique section titles) }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">exactly one class hit → route directly (0 tokens)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">no signal / conflicting classes / &amp;#34;whole document&amp;#34; wording → fall back to LLM routing
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The design stance is &lt;strong>conservative-first: prefer falling back to the LLM over ever guessing&lt;/strong>. Disambiguation is deterministic as well — when both &amp;ldquo;Data Processing&amp;rdquo; and &amp;ldquo;Data Processing and Results&amp;rdquo; match, the longer title wins. Combined with the narrow prompt from Mechanism 2, this cut input tokens for keyword-fix-type requests from roughly 6–7k to about 1.5k in our measurements; routing itself costs nothing, adds no latency, needs no API key, and runs at full speed in offline tests.&lt;/p>
&lt;h2 id="mechanism-4-the-zero-token-regression-gate">Mechanism 4: the zero-token regression gate&lt;/h2>
&lt;p>The autonomous agent&amp;rsquo;s reflection loop (draft → self-revise × N) is where scope escape does the most damage. Beyond constraining revision into a structured edit plan (schema-enforced &lt;code>changes[]&lt;/code>, where an empty array means converged and terminates early) plus scoped splicing, there is one final gate before anything lands — &lt;code>regression_check&lt;/code>, six fully deterministic criteria:&lt;/p>
&lt;ul>
&lt;li>loss of the compilable skeleton (&lt;code>\documentclass&lt;/code> / &lt;code>\end{document}&lt;/code>);&lt;/li>
&lt;li>length collapse below 60% of the previous version (truncation, or &amp;ldquo;rest unchanged&amp;rdquo;-style elision);&lt;/li>
&lt;li>structural score decrease (eight equal-weight checks: section count, error analysis, data tables, equations, no TODO placeholders, …);&lt;/li>
&lt;li>shrinkage of the &lt;code>\cite&lt;/code> key set;&lt;/li>
&lt;li>fewer sections;&lt;/li>
&lt;li>fewer figures or tables.&lt;/li>
&lt;/ul>
&lt;p>Any trigger reverts to the previous version and stops the iteration. The reason for choosing deterministic criteria over one more LLM judge call is stated in the module&amp;rsquo;s own comments: this particular failure mode — losing content — &lt;strong>is deterministically detectable&lt;/strong>, whereas the pairwise judge&amp;rsquo;s validity in this domain is unverified and would cost an extra call per round. More correct, and cheaper.&lt;/p>
&lt;p>The gate has its own edge case: bibliography generation runs after reflection, so with an empty bib the &amp;ldquo;citations closed&amp;rdquo; check is false for any draft containing &lt;code>\cite&lt;/code>, and the gate would misread &amp;ldquo;added a citation&amp;rdquo; as degradation. The fix excludes that check from the gate score while the bib is empty; citation loss is still caught separately by the key-set-shrinkage criterion. &lt;strong>A gate is code, and code has bugs — but a gate&amp;rsquo;s bugs can be pinned by unit tests, while model drift cannot. That asymmetry is precisely why the gate belongs on the deterministic side.&lt;/strong>&lt;/p>
&lt;h2 id="mechanism-5-putting-the-boundary-itself-under-ci--prompt-shape-gates">Mechanism 5: putting the boundary itself under CI — prompt-shape gates&lt;/h2>
&lt;p>Offline tests cannot run a real LLM, so they cannot measure generation quality — but they can test the &lt;strong>shape of a prompt&lt;/strong>: whether the scoped prompt still demands &amp;ldquo;change only this section&amp;rdquo;; whether the assembly function&amp;rsquo;s output on gap-free input is byte-identical to a golden file. If someone edits the prompt back to &amp;ldquo;output the full revised document&amp;rdquo;, plain &lt;code>pytest&lt;/code> goes red. The most fragile part of the deterministic boundary — constraints written in natural language — thereby gets the same regression protection as code. A companion gate validates the scorer itself: gold-standard good reports must score ≥ 0.75, bad ones ≤ 0.35, with a gap ≥ 0.4 — the scorer must demonstrably encode quality before it is allowed to act as a gate.&lt;/p>
&lt;h2 id="the-whole-picture-and-the-measured-gains">The whole picture, and the measured gains&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">Edit&lt;/span> &lt;span class="n">request&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">Deterministic&lt;/span> &lt;span class="n">pre&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">router&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">explicit&lt;/span> &lt;span class="n">target&lt;/span> &lt;span class="n">hit&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">S&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Scoped&lt;/span> &lt;span class="n">prompt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">target&lt;/span> &lt;span class="n">section&lt;/span> &lt;span class="n">only&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">no&lt;/span> &lt;span class="k">signal&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">conflict&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">L&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">LLM&lt;/span> &lt;span class="n">router&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">S&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">S&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">G&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">LLM&lt;/span> &lt;span class="n">generates&lt;/span> &lt;span class="n">section&lt;/span> &lt;span class="n">fragment&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">G&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">C&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Deterministic&lt;/span> &lt;span class="n">cleanup&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">health&lt;/span> &lt;span class="n">check&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">P&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Splice&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="n">outside&lt;/span> &lt;span class="nb">range&lt;/span> &lt;span class="n">unchanged&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">P&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">H&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">Who&lt;/span> &lt;span class="n">is&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">the&lt;/span> &lt;span class="n">loop&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">H&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">conversational&lt;/span> &lt;span class="n">editing&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">U&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Proposal&lt;/span> &lt;span class="n">ledger&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">lands&lt;/span> &lt;span class="n">only&lt;/span> &lt;span class="n">on&lt;/span> &lt;span class="n">user&lt;/span> &lt;span class="n">accept&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">H&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">autonomous&lt;/span> &lt;span class="n">reflection&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">R&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">Zero&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">token&lt;/span> &lt;span class="n">regression&lt;/span> &lt;span class="n">gate&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">R&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">V&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Commit&lt;/span> &lt;span class="n">as&lt;/span> &lt;span class="n">new&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">R&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">degraded&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">Revert&lt;/span> &lt;span class="n">to&lt;/span> &lt;span class="n">previous&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stop&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mechanism&lt;/th>
&lt;th>Alternative&lt;/th>
&lt;th>Measured gain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Sectionizer + splice&lt;/td>
&lt;td>Full-document rewrite by the model&lt;/td>
&lt;td>Bytes outside the target range unchanged; avoids the 16–27% content-regression mode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Zero-token pre-router&lt;/td>
&lt;td>LLM routing on every request&lt;/td>
&lt;td>0-token decisions for explicit targets; input 6–7k → ~1.5k tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Regression gate&lt;/td>
&lt;td>Accept every revision / LLM judge&lt;/td>
&lt;td>One judge call saved per round; degradation reverts, convergence stops early&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prompt-shape gate&lt;/td>
&lt;td>Manual review of prompt edits&lt;/td>
&lt;td>The deterministic share of 351 tests runs fully offline, in CI&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="where-the-principle-does-not-apply">Where the principle does not apply&lt;/h2>
&lt;p>In keeping with this series&amp;rsquo; convention, the honest boundary of the boundary:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>It presupposes parseable structure.&lt;/strong> LaTeX has &lt;code>\section&lt;/code>, code has ASTs, Markdown has headings; free-form prose has none. Without structural anchors there is no sectionizer — the boundary collapses to just cleanup and gates.&lt;/li>
&lt;li>&lt;strong>The boundary is code, and code has carrying costs.&lt;/strong> The sectionizer must handle starred headings, optional short-title arguments, comment and verbatim masking; we maintain dozens of unit tests for this layer alone. If the product&amp;rsquo;s shape is still changing fast, building the boundary early makes every change cost twice.&lt;/li>
&lt;li>&lt;strong>It protects stock, not flow.&lt;/strong> The boundary&amp;rsquo;s value grows with the value of existing assets: it prevents version N from being wrecked, and contributes nothing to generating version 1. Tasks that are creation-dominated, with no existing asset to protect, do not need it.&lt;/li>
&lt;li>&lt;strong>Deterministic criteria only measure the measurable.&lt;/strong> The regression gate prevents &lt;em>getting worse&lt;/em>; it cannot certify &lt;em>getting better&lt;/em>. Preference-level quality still requires pairwise judges or humans — which is the subject of another article in this series (validity engineering for LLM-as-judge).&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;em>Beyond Single-shot Writing: Deep Research Agents are Unreliable at Multi-turn Report Revision&lt;/em> (arXiv:2601.13217) — empirical measurement of the 16–27% content-regression mode in full-rewrite revision; structured edit plans and scoped revision are the remedies it names.&lt;/li>
&lt;li>&lt;em>PaperOrchestra: A Multi-Agent Framework for Automated AI Research Paper Writing&lt;/em> (arXiv:2604.05018) — structured multi-agent pipelines beating autonomous baselines (+50–68% / +14–38%); its accept-or-revert refinement gate reports 79–81% win rate with 0% degradation (body-text detail, not independently verified).&lt;/li>
&lt;li>&lt;em>Self-Refine: Iterative Refinement with Self-Feedback&lt;/em> (arXiv:2303.17651) — feedback must be specific and actionable; generic feedback measurably hurts, and tasks where errors are hard to self-detect need external signals.&lt;/li>
&lt;li>qwen-code issue #4695 — a public case of tool-loop collapse (43 identical calls / 8.9M tokens).&lt;/li>
&lt;/ul></description></item><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>The token economics of LLM applications</title><link>https://kaguc.com/blog/token-economics/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/token-economics/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/token-economics-zh/">中文&lt;/a>. Part 1 of the Engineering LLM Applications series. The series is grounded in the implementation and measurements of a production-grade AI writing agent (FastAPI + React + Tauri), and discusses transferable engineering methods.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-why-does-fixing-one-keyword-cost-67k-tokens">The problem: why does fixing one keyword cost 6–7k tokens&lt;/h2>
&lt;p>When you build an LLM application, the first constraint that forces an architectural decision is usually not model capability — it is the bill. Our project (a LaTeX lab-report writing agent) received a blunt question from a user: &amp;ldquo;why did you send 6k?&amp;rdquo; — he only wanted to change the report&amp;rsquo;s keywords.&lt;/p>
&lt;p>The precise ledger (before the fix):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Content&lt;/th>
&lt;th>Size (approx.)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>System prompt&lt;/td>
&lt;td>Role rules + report spec + writing methodology (1,948 chars) + golden-sample exemplar (1,235 chars) + LaTeX template rules&lt;/td>
&lt;td>~3k tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session material&lt;/td>
&lt;td>First 6,000 chars of &lt;code>material&lt;/code> — including the entire experiment txt bundle (data matrices, field-sweep parameters, …)&lt;/td>
&lt;td>~3–4k tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Target block + instruction&lt;/td>
&lt;td>One &lt;code>\keyword{...}&lt;/code> line + the edit request&lt;/td>
&lt;td>~0.1k&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Total&lt;/td>
&lt;td>&lt;/td>
&lt;td>~6–7k in / ~50 out&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The root cause is not the model but one-size-fits-all context assembly: whatever the edit, the full set of quality anchors plus the full material gets stuffed in. Fixing a keyword has no use for a complete exemplar report about a different experiment, and even less for raw data matrices. The user&amp;rsquo;s follow-up became the design charter for this pipeline: &amp;ldquo;the process may be complex, but the goal is to save tokens.&amp;rdquo; Cost optimization is not cutting features; it is layering context by problem type — minimal context per type. Below are the four layers we shipped.&lt;/p>
&lt;h2 id="layer-1-zero-token-deterministic-pre-routing">Layer 1: zero-token deterministic pre-routing&lt;/h2>
&lt;p>&amp;ldquo;Which part does this feedback want to change&amp;rdquo; is a routing problem, and the default is to ask a model. But a substantial share of feedback carries an unambiguous explicit target: bibliography, abstract, keywords, &amp;ldquo;Table 2&amp;rdquo;, &amp;ldquo;Figure 3&amp;rdquo;, &amp;ldquo;§3.1&amp;rdquo;, a uniquely matching section title. These are decided with regexes and string matching (&lt;code>_fast_route&lt;/code> — zero LLM calls, no API key even needed): exactly one signal class hit → route directly; &amp;ldquo;whole document&amp;rdquo; wording, no signal, or conflicting classes → fall back conservatively to LLM routing, never guess.&lt;/p>
&lt;p>The gain is more than one saved routing call: the decision has zero latency, is 100% explainable (the log is tagged &amp;ldquo;deterministic fast decision, 0 tokens&amp;rdquo;), and offline tests run at full speed. This pattern is developed further as part of the deterministic-boundary principle in &lt;a href="https://kaguc.com/blog/deterministic-boundary/">Part 11&lt;/a>.&lt;/p>
&lt;h2 id="layer-2-tiered-system-prompts">Layer 2: tiered system prompts&lt;/h2>
&lt;p>The most expensive part of the system prompt is the quality anchors: the writing methodology plus the golden-sample exemplar, about 3.2k characters, roughly 2k tokens. They are indispensable for whole-document generation — an early refactor once dropped them and generation depth and style collapsed immediately — but useless for small edits like &amp;ldquo;fix one section&amp;rdquo; or &amp;ldquo;fix the keywords&amp;rdquo;.&lt;/p>
&lt;p>So &lt;code>build_system(scoped=True)&lt;/code> adds a tier: paragraph-level and metadata edits drop the quality anchors while keeping the role rules (no fabrication), the format spec, and the distilled judgment layer — the fragment must still be stylistically correct and compilable. Whole-document generation does not take this tier; its anchors stay. Each scoped call saves about 2k tokens. The full design of layered prompt assembly is the subject of &lt;a href="https://kaguc.com/blog/prompt-assembly/">Part 2&lt;/a>.&lt;/p>
&lt;h2 id="layer-3-material-trimming-and-stable-prefixes">Layer 3: material trimming and stable prefixes&lt;/h2>
&lt;p>The material layer does two things, and the second matters more than the first.&lt;/p>
&lt;p>&lt;strong>Deterministic trimming.&lt;/strong> &lt;code>select_material&lt;/code> filters at zero token cost: pure-number matrix lines (bare CSV) are dropped; lines with lexical overlap with the target section or the feedback, or &amp;ldquo;data lines with textual annotation&amp;rdquo; (e.g. &amp;ldquo;measured 0.5 mm&amp;rdquo;), are kept, capped at 2,000 characters. The filter has a floor — it never filters the context away entirely:&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="c1"># keep &amp;gt;=2 lines or &amp;gt;=40 chars; otherwise treat filtering as failed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and fall back to the first `cap` chars&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="n">out&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">keep&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">2&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">40&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="n">cap&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole-document revision path gets the same treatment (triggered only above 12k characters; never on first-version generation): autonomous exploration used to write up to 120k characters of raw files back into the material, after which every chat / whole-edit turn carried 40–70k tokens — even when the feedback was just &amp;ldquo;make the tone more formal&amp;rdquo;. After filtering: 8–15k per turn.&lt;/p>
&lt;p>&lt;strong>Stable prefixes.&lt;/strong> A deep audit produced an honest conclusion: on providers with prefix caching, a slimmer system prompt saves roughly nothing in direct cost (its value is focus — keeping irrelevant text from interfering); the real money is in the material block that misses the cache every turn. DeepSeek&amp;rsquo;s context cache is fully automatic, and the hit price is about 0.8% of a miss (v4-pro: hit $0.003625/M vs miss $0.435/M — vendor list prices verified online, 2026-07). To collect that discount, the material message must be byte-identical across turns — so the trimming and per-file digest functions depend only on the material itself, never on the current turn&amp;rsquo;s feedback, and the result is pinned as a fixed &lt;code>messages[0]&lt;/code> prefix. Measured input cost: about -49%, multiplicative with the relevance filtering above.&lt;/p>
&lt;p>The Anthropic path requires declaring the cache explicitly, and the TTL is a genuine trade-off:&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">system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;text&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;text&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">system&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;cache_control&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ephemeral&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;ttl&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1h&amp;#34;&lt;/span>&lt;span class="p">}}],&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The reasoning is written in the source comment: users revise documents at human pace, so a 5-minute TTL expires between turns — you pay the cache-write premium every turn and never hit. The 1h tier writes at 2× price, reads at 0.1×, and every hit renews the TTL, matching the human rhythm. Below the model&amp;rsquo;s minimum prefix threshold it silently skips caching — harmless. Measured: Claude-path input cost down 54–65%.&lt;/p>
&lt;h2 id="layer-4-per-step-model-tiering">Layer 4: per-step model tiering&lt;/h2>
&lt;p>Pipeline steps are not uniformly hard: the exploration tool loop (reading files, deciding what to read next), the material health check (JSON classification), BibTeX generation, and the English-abstract translation are simple, high-frequency tasks; drafting and reflective revision are what need the flagship model. So the model is passed per step: simple steps go to the cheap tier (DeepSeek V4 Flash — input $0.14/M, output $0.28/M, about 1/3 of Pro&amp;rsquo;s unit price; the vendor claims Flash matches Pro on simple agent tasks at roughly 12× lower cost — vendor claim, not independently verified), while drafting, reflection, figure generation, and compile self-repair stay on Pro. With no cheap tier configured, everything falls back to the main model — zero behavior change; and cost accounting accrues per actual model of each call, because a single flat rate misprices runs that mix the two tiers.&lt;/p>
&lt;h2 id="the-measured-ledger">The measured ledger&lt;/h2>
&lt;p>Typical inputs after layering context by problem type:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Problem type&lt;/th>
&lt;th>System prompt&lt;/th>
&lt;th>Material&lt;/th>
&lt;th>Typical in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Routing (explicit target)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>0 (deterministic decision)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Routing (everything else)&lt;/td>
&lt;td>Small router system&lt;/td>
&lt;td>none&lt;/td>
&lt;td>~1.5–2.5k&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Edit abstract/keywords&lt;/td>
&lt;td>Scoped tier&lt;/td>
&lt;td>none (document outline digest instead)&lt;/td>
&lt;td>~1.5k&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Edit one body section&lt;/td>
&lt;td>Scoped tier&lt;/td>
&lt;td>relevance-filtered, ≤2,000 chars&lt;/td>
&lt;td>~2–3k&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Whole-document rewrite&lt;/td>
&lt;td>Full (quality anchors intact)&lt;/td>
&lt;td>full&lt;/td>
&lt;td>10k+ (money well spent)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The opening example: ~6–7k input tokens before → 0 (fast route hits the keywords class) + ~1.5k after. The whole-document revision path drops from 40–70k to 8–15k per turn, then multiplies with the ~-49% prefix-cache discount. Compile self-repair was also switched to a fresh minimal context (current draft + error excerpt + template rules), saving 30–50k tokens per repair round.&lt;/p>
&lt;p>Observation ships with the optimization: every call collects real token usage via &lt;code>usage_out&lt;/code> (including cache-hit counts) into the process log and trace, so agent-lab can show &amp;ldquo;which step spent how much&amp;rdquo;. This round of changes added 10 backend tests (backend 290 passed, agent-lab 15 passed). One negative lesson: the accounting was once wrong — cost estimation did not discount cache hits, so autonomous-run costs displayed up to several times too high. Fix the observability first, or the optimization aims at the wrong target.&lt;/p>
&lt;h2 id="when-not-to-do-this">When not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Never filter material for first-version generation.&lt;/strong> The material is the sole source of facts — err on the side of completeness; relevance filtering is for revision turns only.&lt;/li>
&lt;li>&lt;strong>Never slim the system prompt for whole-document generation.&lt;/strong> The quality anchors are where depth and style come from; that spend is worth it.&lt;/li>
&lt;li>&lt;strong>Never economize on the verification layer.&lt;/strong> Provenance checking still runs against the complete material server-side — you save on what is fed to the model, not on validation.&lt;/li>
&lt;li>&lt;strong>Nominal savings ≠ real savings.&lt;/strong> Once caching hits, many &amp;ldquo;big&amp;rdquo; optimizations shrink: splitting the exploration/figure contexts looked like 50k+ tokens saved, but the cache already absorbed about 90% of it, leaving 10–20% real savings — we downgraded that item accordingly. Rank optimizations by post-cache-discount numbers.&lt;/li>
&lt;li>&lt;strong>Fix observation before optimizing.&lt;/strong> When accounting ignores cache hits, the most expensive path looks even more expensive than it is, and steers the optimization effort in the wrong direction.&lt;/li>
&lt;/ol></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><item><title>Wiring external signals into the loop: compile-and-repair</title><link>https://kaguc.com/blog/compile-self-repair/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/compile-self-repair/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/compile-self-repair-zh/">中文&lt;/a>. Part 6 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-models-cannot-fix-errors-they-cannot-see">The problem: models cannot fix errors they cannot see&lt;/h2>
&lt;p>Scenario: an autonomous agent generates a LaTeX lab report, and xelatex fails. Who fixes it?&lt;/p>
&lt;p>The reflex is to ask the model to &amp;ldquo;check it again.&amp;rdquo; The evidence against that path is specific. One of Self-Refine&amp;rsquo;s conclusions (arXiv:2303.17651) is that &lt;strong>on tasks where errors are hard to self-detect, pure self-reflection is nearly useless, and gains only return once an external verification signal is wired in&lt;/strong>. The systematic review in &lt;em>When Can LLMs Actually Correct Their Own Mistakes&lt;/em> (TACL 2024) is blunter: prompting-only self-critique does not improve — and can degrade — arithmetic and code tasks, and the flattering self-correction numbers in the literature mostly leaned on an oracle (stopping only when the known-correct answer appears). Compile errors are the canonical hard-to-self-detect case: the model rereads the &lt;code>\def&lt;/code> line it just wrote and cannot see that a &lt;code>%&lt;/code> inside the braces comments out the closing brace — but xelatex sees it, and its error report cannot be faked.&lt;/p>
&lt;p>So the right question is not &amp;ldquo;how do we make the model self-check&amp;rdquo; but &amp;ldquo;how do we wire an objective external verifier into the generation loop.&amp;rdquo; This article dissects the compile-and-repair loop we shipped in an AI writing agent — &lt;strong>really compile after generation; on failure, extract the errors and feed them back for a repair round&lt;/strong> — and the engineering constraints that keep this deceptively simple loop under control in production.&lt;/p>
&lt;h2 id="from-patching-to-really-compiling">From patching to really compiling&lt;/h2>
&lt;p>Before the compiler was wired in, the system already had a deterministic sanitizer layer (&lt;a href="https://kaguc.com/blog/output-sanitizing/">part 3 of this series&lt;/a>): &lt;code>\mathbf&lt;/code>→&lt;code>\symbf&lt;/code>, &lt;code>%&lt;/code> comments inside &lt;code>\def&lt;/code> lines, backfilling template variables the model forgot to set — the known pitfalls that rules can encode. But patches cannot enumerate the long tail. The devlog states the goal of this step plainly: &amp;ldquo;not patching with the sanitizer, but having the agent really compile after generating the report, feed the xelatex errors back for a round of its own fixes on failure, and push the first-pass success rate to the max.&amp;rdquo;&lt;/p>
&lt;p>That fixed the division of labor: &lt;strong>rules handle the known pitfalls (zero tokens); the compiler backstops the long tail (one LLM call per round)&lt;/strong>. In practice the sanitizer has already cured the common traps, so the first compile check usually passes outright; the repair branch only catches what remains. The loop itself is three steps:&lt;/p>
&lt;ol>
&lt;li>&lt;code>compile_tex&lt;/code> — assemble a complete working directory (article.tex + cls + bib + figures), run xelatex → bibtex → xelatex ×2, return &lt;code>{ok, pdf_path, log}&lt;/code>;&lt;/li>
&lt;li>&lt;code>extract_errors&lt;/code> — pull from the log the lines starting with &lt;code>!&lt;/code>, the &lt;code>l.&lt;/code> line-number lines, and Runaway / Emergency stop lines, capped at 1800 characters — never feed back the whole log;&lt;/li>
&lt;li>on failure, feed the error excerpt back to the model, demand the full revised source, at most 2 rounds.&lt;/li>
&lt;/ol>
&lt;p>The cap of 2 is not arbitrary: a survey finding (&lt;em>How Many Tries&lt;/em>, arXiv:2604.10508) across 7 models is that the first two rounds capture 76–95% of the achievable gain, with near-zero return from round three on; Self-Refine likewise plateaus by round 3. Extra rounds only multiply wall-clock and token spend. One further detail: the second round&amp;rsquo;s repair output is not re-verified inside the loop — the repaired draft is kept, and final verification is left to the user-side manual compile/export path.&lt;/p>
&lt;p>A counter-intuitive design point: the repair is a &lt;strong>full-document rewrite&lt;/strong>, not a scoped patch of the kind used in part 7. The reason is a property of LaTeX errors — they are often decoupled from the root cause, and line numbers are unreliable (&lt;em>LaTeX Compilation Challenges&lt;/em>, arXiv:2603.02873), so pinpoint fixes at the reported line easily patch the wrong place. Scoped editing presupposes deterministic targeting, and compile errors are precisely where that presupposition fails.&lt;/p>
&lt;h2 id="termination-fingerprint-round-cap-wall-clock">Termination: fingerprint, round cap, wall clock&lt;/h2>
&lt;p>The biggest production risk of a self-repair loop is not failing to fix — it is &lt;strong>failing to stop&lt;/strong>. The consensus on loop governance is that &amp;ldquo;when to stop&amp;rdquo; must not be left to the model&amp;rsquo;s discretion; an outer controller enforces it with deterministic criteria. There are three gates:&lt;/p>
&lt;p>&lt;strong>The no-progress gate.&lt;/strong> After each failed round, fingerprint the errors — the first 120 characters of the first &lt;code>!&lt;/code> error line (LaTeX&amp;rsquo;s anchor line); if it equals the previous round&amp;rsquo;s fingerprint, declare no progress, stop immediately, keep the current draft:&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">_error_fingerprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">errs&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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;Compile-error fingerprint: first &amp;#39;! …&amp;#39; error line (LaTeX&amp;#39;s anchor),
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> else the first 120 chars. Used for no-progress detection.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">line&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">errs&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">splitlines&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">s&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;!&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">return&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="mi">120&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">errs&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()[:&lt;/span>&lt;span class="mi">120&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If the same error survived a repair round, this model has no answer for this error; feeding it the same report again will most likely produce the same failure, and burning more tokens has no expected return.&lt;/p>
&lt;p>&lt;strong>The round cap.&lt;/strong> &lt;code>for attempt in range(2)&lt;/code> — a hard limit.&lt;/p>
&lt;p>&lt;strong>The wall-clock gate.&lt;/strong> A single compile runs up to 4 subprocess passes at 180 seconds each — a token budget cannot contain CPU time. &lt;code>COMPILE_WALL_BUDGET&lt;/code> (default 600 seconds) is checked before each round; on expiry, stop and keep the current draft. The config comment states the motive: prevent multi-round self-repair from hogging the customer machine&amp;rsquo;s CPU.&lt;/p>
&lt;p>All three gates are deterministic criteria executed on the Python side; the model has no say in whether the loop stops — the same stance as the tool-loop circuit breaker in &lt;a href="https://kaguc.com/blog/agent-loop/">part 4&lt;/a>.&lt;/p>
&lt;h2 id="minimal-repair-context-8k-tokens-not-4060k">Minimal repair context: ~8k tokens, not 40–60k&lt;/h2>
&lt;p>How the repair call&amp;rsquo;s context is constructed is the single most money-saving decision in this loop. The intuitive approach is to reuse the agent&amp;rsquo;s full exploration history — surely the model &amp;ldquo;fixes better knowing more&amp;rdquo;? The opposite holds. The judgment recorded in the code comment: &lt;strong>95% of compile errors are template-level syntax problems&lt;/strong> (balancing, command misuse, template variables), and fixing them requires exactly three things:&lt;/p>
&lt;ul>
&lt;li>the full current draft;&lt;/li>
&lt;li>the error excerpt (≤1800 characters);&lt;/li>
&lt;li>the formatting hard rules (no &lt;code>%&lt;/code> comments inside &lt;code>\def&lt;/code> braces; bold vectors with &lt;code>\symbf&lt;/code>; no bold inside accents; balance braces/environments/&lt;code>$&lt;/code>; end with a top-level &lt;code>\end{document}&lt;/code>).&lt;/li>
&lt;/ul>
&lt;p>About 8k tokens in total. Reusing the full exploration messages would carry 40–60k tokens of dead weight — source material, tool-call records, multiple draft versions — none of it contributing to fixing one brace; switching to a fresh minimal context saves 30–50k tokens per repair round. The system prompt narrows the mandate to a single sentence:&lt;/p>
&lt;blockquote>
&lt;p>You are a LaTeX compile-repair assistant: fix compile errors only — do not rewrite content, do not add or remove sections, do not touch data.&lt;/p>
&lt;/blockquote>
&lt;p>This is not only about cost: the measurements in &lt;em>Revisit Self-Debugging&lt;/em> (arXiv:2501.12793) show label-only feedback beating verbose detailed feedback — inaccurate long context introduces ambiguity and lowers scores. Commercial products are isomorphic: Overleaf&amp;rsquo;s commercial Error Assist also deliberately minimizes the repair context, sending only the full error, the relevant code lines, and a file-name list (product-page information, not independently verified).&lt;/p>
&lt;h2 id="concurrency-isolation-a-compile-directory-per-session">Concurrency isolation: a compile directory per session&lt;/h2>
&lt;p>A plain constraint you only learn by tripping on it: the compile working directory must be isolated per session. &lt;code>compile_tex&lt;/code> writes article.tex into the directory, copies the cls and figures, and rmtree-rebuilds the figure subdirectory during assembly — two concurrent generation runs sharing one directory will overwrite each other&amp;rsquo;s source, delete each other&amp;rsquo;s figures, and pollute each other&amp;rsquo;s logs. After partitioning the repair directory as &lt;code>_repair/&amp;lt;session_id&amp;gt;&lt;/code>, each run writes its own. The lesson generalizes: &lt;strong>a verifier is a side-effecting process, not a pure function&lt;/strong> — when wiring an external verifier into a loop, isolation is a default, not an optimization.&lt;/p>
&lt;h2 id="the-whole-picture-quantified">The whole picture, quantified&lt;/h2>
&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[Final draft] --&amp;gt; W{Wall-clock budget exceeded?}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> W --&amp;gt;|yes| S[Stop, keep current draft]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> W --&amp;gt;|no| C[compile_tex: real compile]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt;|ok| P[PDF produced]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> C --&amp;gt;|fail| E[extract_errors: pull ! error lines]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> E --&amp;gt; F{Fingerprint same as last round?}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> F --&amp;gt;|same = no progress| S
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> F --&amp;gt;|different| R[Minimal-context repair call ≈8k tokens]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> R --&amp;gt; N[2 rounds used?]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> N --&amp;gt;|yes| S
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> N --&amp;gt;|no| W
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constraint&lt;/th>
&lt;th>Failure mode without it&lt;/th>
&lt;th>Implementation and numbers&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Round cap&lt;/td>
&lt;td>Grinding on for rounds with near-zero return from round 3&lt;/td>
&lt;td>K=2; first two rounds capture 76–95% of achievable gain (survey figure)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No-progress gate&lt;/td>
&lt;td>The same error fed back again and again, spinning in place&lt;/td>
&lt;td>Fingerprint = first 120 chars of the first &lt;code>!&lt;/code> line; stop on consecutive match&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Wall-clock gate&lt;/td>
&lt;td>4 passes × 180s per compile; a token cap cannot contain CPU&lt;/td>
&lt;td>&lt;code>COMPILE_WALL_BUDGET&lt;/code>, default 600s; on expiry keep the current draft&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Minimal context&lt;/td>
&lt;td>40–60k tokens of exploration-history dead weight per call&lt;/td>
&lt;td>Current draft + error excerpt + hard rules ≈8k; saves 30–50k tokens per round&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session isolation&lt;/td>
&lt;td>Concurrent runs clobber each other&amp;rsquo;s source/figures/logs&lt;/td>
&lt;td>Dedicated &lt;code>_repair/&amp;lt;session_id&amp;gt;&lt;/code> working directory&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="where-not-to-build-this-loop">Where not to build this loop&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>It presupposes a cheap, objective, unfakeable verifier.&lt;/strong> Compilers, type checkers, and unit tests qualify; &amp;ldquo;is the writing good&amp;rdquo; does not. The stop condition must anchor on an objective signal like &amp;ldquo;compilation succeeded&amp;rdquo; — never on the model&amp;rsquo;s self-assessment. Quality dimensions without a verifier belong to evaluation and judges (parts 8 and 9), not to this loop.&lt;/li>
&lt;li>&lt;strong>Deterministic repair comes first.&lt;/strong> Pitfalls that can be written as rules (known syntax traps, missing-figure placeholders, variable backfill) should be cured by the zero-token sanitizer before any LLM repair; the repair loop only backstops the long tail that rules cannot enumerate. Reversing the order means using a probabilistic component for deterministic work: paying a call every time while adding new uncertainty.&lt;/li>
&lt;li>&lt;strong>Weak models need a non-LLM exit.&lt;/strong> Survey data puts weak models&amp;rsquo; repair rate on deadlocked errors at only about 30% (not independently verified). The loop must ship with a fallback that does not depend on the model — here, export_zip produces a compilable package to hand to Overleaf or a human. Without an exit, the loop degenerates on weak models into &amp;ldquo;burn the budget, then give up.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>When the verifier is expensive, the budget gate precedes the round cap.&lt;/strong> Here a single compile is wall-clock-bounded at 4 passes × 180 seconds (at most 720s), so K=2 is affordable; if the verifier is a ten-minute integration test suite, the same K=2 may be unacceptable — the wall-clock budget must become the first gate, including the option of &amp;ldquo;give up without repairing at all.&amp;rdquo;&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;em>Self-Refine: Iterative Refinement with Self-Feedback&lt;/em> (arXiv:2303.17651) — self-reflection is nearly useless on tasks where errors are hard to self-detect; gains return with external signals; benefit concentrates in the first 1–2 rounds (its &amp;ldquo;+20% average&amp;rdquo; figure not independently verified).&lt;/li>
&lt;li>&lt;em>When Can LLMs Actually Correct Their Own Mistakes&lt;/em> (TACL 2024) — prompting-only self-critique does not improve, and can degrade, arithmetic/code tasks; flattering self-correction results mostly relied on an oracle.&lt;/li>
&lt;li>&lt;em>How Many Tries&lt;/em> (arXiv:2604.10508) — the first two rounds capture 76–95% of achievable gain; near zero from round 3.&lt;/li>
&lt;li>&lt;em>LaTeX Compilation Challenges&lt;/em> (arXiv:2603.02873) — LaTeX errors are often decoupled from root causes; line numbers are unreliable.&lt;/li>
&lt;li>&lt;em>Revisit Self-Debugging&lt;/em> (arXiv:2501.12793) — label-only feedback beats verbose feedback.&lt;/li>
&lt;li>Overleaf Error Assist — the commercial isomorph of minimal repair context (product-page information, not independently verified).&lt;/li>
&lt;/ul></description></item></channel></rss>