<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>LLM | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/llm/</link><atom:link href="https://kaguc.com/tag/llm/index.xml" rel="self" type="application/rss+xml"/><description>LLM</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>LLM</title><link>https://kaguc.com/tag/llm/</link></image><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>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>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 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></channel></rss>