diff --git a/docs/cookbook-index.md b/docs/cookbook-index.md
index 066e891626..6d67ee6e45 100644
--- a/docs/cookbook-index.md
+++ b/docs/cookbook-index.md
@@ -2,7 +2,7 @@
> **Generated by `tools/cookbook_index.py` — do not hand-edit** (R33). Regenerate after adding a cookbook section.
>
-> `docs/matching-cookbook.md` is ~716 KB / 1080 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses.
+> `docs/matching-cookbook.md` is ~716 KB / 1081 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses.
**How to use:** name what you SEE in the diff (a stolen delay slot, an extra `la`, a swapped register pair, a `conflicting types` error), find that symptom below, read those sections first. If nothing fits, THEN grind — and add a section when you win.
@@ -1336,7 +1336,7 @@
- **§408** — ★★★ — §406 REFUTED AS A SWEEP: THE SHAPE IS THE FAMILY, THE DISAGREEMENT IS THE DEFECT (P31 S71; 0 MATCH / 14 applied, 0 / 210) L33209
- **§411** — ★★★ — THE PACK MUST CARRY THAT FUNCTION'S OWN HISTORY (P31 S71; measured 38/39 vs 124/131) L33386
-### (unbucketed — title matched no symptom vocabulary) (323)
+### (unbucketed — title matched no symptom vocabulary) (324)
- **§3-How** — to use this L30
- **§1** — Idiom catalog (asm pattern → C that produces it) L39
@@ -1661,6 +1661,7 @@
- **§407** — ★★ — LATE-WAVE ADDENDA TO §405 (the last agents in) L33180
- **§409** — ★★★ — THE S71 JOURNAL-FUELLED WAVE: 100% FIRST-PASS MATCH, AND THE NINE LAWS IT BROUGHT BACK (P31 S71) L33261
- **§3-The** — nine laws this wave produced L33288
+- **§413** — ★★★ — DIFFICULTY IS THE RESIDUAL CLASS, NOT `nins` — ROUTE THE MODEL TIER OFF HISTORY (P31 S71, Drew) L33473
## All sections, in order
@@ -2745,6 +2746,7 @@
- **§410** — ★★★ — COPY THEN ACCUMULATE ON THE COPY: resolving the birthing-boost vs register-allocation dilemma (P31 S71; byte-proven `ov_SC04_015/func_8017EB78`, 98 ins) L33353
- **§411** — ★★★ — THE PACK MUST CARRY THAT FUNCTION'S OWN HISTORY (P31 S71; measured 38/39 vs 124/131) L33386
- **§412** — ★★★ — §323 CARVE BLOCKER 2 WAS A REGEX THAT COULD NOT SEE PAST `__attribute__` (P31 S71) L33424
+- **§413** — ★★★ — DIFFICULTY IS THE RESIDUAL CLASS, NOT `nins` — ROUTE THE MODEL TIER OFF HISTORY (P31 S71, Drew) L33473
---
@@ -3837,3 +3839,4 @@ Notes routinely quote that as a section id. This table resolves it. Grep bait: `
| L33353 | §410 | ★★★ — COPY THEN ACCUMULATE ON THE COPY: resolving the birthing-boost vs register-allocatio |
| L33386 | §411 | ★★★ — THE PACK MUST CARRY THAT FUNCTION'S OWN HISTORY (P31 S71; measured 38/39 vs 124/131) |
| L33424 | §412 | ★★★ — §323 CARVE BLOCKER 2 WAS A REGEX THAT COULD NOT SEE PAST `__attribute__` (P31 S71) |
+| L33473 | §413 | ★★★ — DIFFICULTY IS THE RESIDUAL CLASS, NOT `nins` — ROUTE THE MODEL TIER OFF HISTORY (P31 |
diff --git a/docs/matching-cookbook.md b/docs/matching-cookbook.md
index 4d9d21e601..0b8dc17a96 100644
--- a/docs/matching-cookbook.md
+++ b/docs/matching-cookbook.md
@@ -33469,3 +33469,40 @@ position relative to a brace or a keyword must strip `__attribute__((…))` firs
appear between `}` and the name, after the name, and after the parameter list. This is the §134 class
(a scanner that cannot start where the C grammar actually puts things), and it is now the seventh tool
in this project to hit it.
+
+## §413 ★★★ — DIFFICULTY IS THE RESIDUAL CLASS, NOT `nins` — ROUTE THE MODEL TIER OFF HISTORY (P31 S71, Drew)
+
+**The observation.** S71's wave ran one agent per function and logged wall-clock and tool-call counts.
+Duration tracks **iteration count**, and iteration count tracks the **residual class** — not size:
+
+| function | ins | arm | wall | tool calls | what it was |
+|---|---|---|---|---|---|
+| `func_80181294` | **26** | opus | 18 min | 31 | REGALLOC-PERM, finished NEAR/2 |
+| `func_80185D44` | **47** | opus | 21 min | 48 | LUID contradiction (read cc1's `-dS` trace) |
+| `func_80185F4C` | **60** | opus | 22 min | 33 | sched1 birthing-boost tie |
+| `func_800D24D0` | 141 | opus | 33 min | 51 | `memrefs_conflict_p` PLUS-vs-LO_SUM |
+| `func_8017DB98` | 122 | opus | **80 s** | 10 | body recovered from a prior attempt |
+| `func_80182CBC` | 28 | opus | 115 s | 12 | §193-A twin remap |
+
+A 26-instruction function took 18 minutes; a 122-instruction one took 80 seconds. **Size predicted
+neither.** What separates the two groups is whether the residual is a compiler-internal one
+(scheduling ties, birthing boost, register colouring, LUID order) where every hypothesis costs a
+compile-and-measure cycle — or an ordinary body question that a twin or a neighbour answers at once.
+
+**Why they never escalated.** `draw_waves.arm_for` keys the model tier on `nins` alone, so a
+47-instruction regalloc wall is STRUCTURALLY unable to be drawn at the higher tier, and nothing
+escalates mid-run: each agent runs to its own budget and no watcher re-hands the target.
+
+**The fix, available only because the packs now carry history (§411).** `arm_from_history()` reads the
+function's own journal notes at draw time and returns `fable` when they name a compiler-internal
+residual; it never downgrades what the size ladder chose. Negative-controlled over all 3,147 functions
+with history × 3 size bands = 9,441 decisions: **4,020 upgrades (43%), 0 downgrades.**
+
+**A defect the control caught, worth as much as the lever.** The first negative control iterated only
+journal keys carrying a binary and reported `0 decisions / 0 downgrades` — a clean pass over an EMPTY
+set. The agent verdict schema never had a `binary` field, so **every historical note is name-keyed**,
+and the same name is a different function in another overlay (§238). Two consequences: the S71
+past-attempt fuel can serve one overlay's history to another's target, and any future join on these
+rows inherits it. `claude_wave_draft.js`'s `VERDICT` schema now requires `binary`, so new rows are
+exact; the historical corpus stays name-keyed and should be read with that caveat.
+`check-against-a-known-true-case`, again: the instrument passed because it measured nothing.
diff --git a/tools/draw_waves.py b/tools/draw_waves.py
index c11558e925..f245576b32 100644
--- a/tools/draw_waves.py
+++ b/tools/draw_waves.py
@@ -25,6 +25,7 @@ Then, per wave: t5_cards.py -> claude_wave_packs.py -> wave_args.py, and launch
tools/workflows/claude_wave_draft.js with the args wave_args.py printed (never hand-typed).
"""
import argparse, collections, glob, json, os, sys
+import re
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(REPO)
@@ -69,6 +70,51 @@ def arm_for(n):
return 'opus' if n <= 150 else 'fable'
+# THE RESIDUAL CLASS PREDICTS DIFFICULTY BETTER THAN `nins` DOES (Drew, 2026-09-02, S71).
+# Measured over the S71 wave's own agent runs: wall-clock and iteration count track the RESIDUAL,
+# not the size. A 26-instruction function took 18 minutes and 31 tool calls (`func_80181294`,
+# still NEAR); a 122-instruction one took 80 seconds and 10 (`func_8017DB98`). The 20-30 minute
+# runs were all compiler-internal residuals — scheduling ties, birthing boost, register colouring —
+# where each hypothesis costs a compile-and-measure cycle:
+#
+# func_80185D44 47 ins opus 21 min 48 tool calls (LUID contradiction, read cc1 -dS)
+# func_80185F4C 60 ins opus 22 min 33
+# func_800D24D0 141 ins opus 33 min 51
+#
+# `arm_for` keys on size alone, so a 47-instruction regalloc wall was STRUCTURALLY unable to be
+# drawn at the higher tier, and nothing escalates mid-run. Now that every pack carries the
+# function's own history (§411), the prior residual class is known AT DRAW TIME — and 1,352 of the
+# 3,147 functions with history (43%) have a note naming one of these classes.
+_WALL_RE = re.compile(
+ r'permuter|regalloc|register (?:alloc|colou?ring|pressure)|schedule[- ]reorder|SCHEDULE-'
+ r'|birthing|LUID|sched1|sched2|scheduler-internal|cross_?jump|delay[- ]slot|colou?ring',
+ re.I)
+
+
+def arm_from_history(binary, fn, n, _cache={}):
+ """`fable` when this function's own journal history names a compiler-internal residual.
+
+ Escalating SOONER is the standing finding (see arm_for); this applies it to the axis that
+ actually predicts cost. Falls back to the size ladder when there is no history, and never
+ DOWNGRADES what the size ladder chose."""
+ if not _cache:
+ try:
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import journal_notes
+ _cache['idx'] = journal_notes.load()
+ _cache['mod'] = journal_notes
+ except Exception:
+ _cache['idx'] = None
+ base = arm_for(n)
+ idx = _cache.get('idx')
+ if not idx or base == 'fable':
+ return base
+ rows = _cache['mod'].notes_for(idx, binary, fn)
+ if rows and _WALL_RE.search(" ".join(r.get('note') or '' for r in rows)):
+ return 'fable'
+ return base
+
+
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--prefix', required=True, help='wave dir prefix; waves are 1, 2, ...')
@@ -128,7 +174,7 @@ def main():
continue
pool.append(dict(name=s.symbol, addr='0x%08x' % s.addr, nins=n, binary=b,
sub=s.asm_dir, asm=s.asm_path, tu=s.path,
- cls='FRONTIER', arm=arm_for(n), **{'from': 'draw_waves'}))
+ cls='FRONTIER', arm=arm_from_history(b, s.symbol, n), **{'from': 'draw_waves'}))
pool.sort(key=lambda t: (t['nins'], t['binary'], t['name']))
print('population: %d open stub(s) in [%d,%d] ins, undrawn, over %d binaries (%d oracle refusals: %s)'
diff --git a/tools/workflows/claude_wave_draft.js b/tools/workflows/claude_wave_draft.js
index 1b17ea7441..192250775e 100644
--- a/tools/workflows/claude_wave_draft.js
+++ b/tools/workflows/claude_wave_draft.js
@@ -14,6 +14,11 @@ const VERDICT = {
type: 'object',
properties: {
fn: { type: 'string' },
+ // BINARY IS PART OF A FUNCTION'S IDENTITY (R48/§238). Without it every journal note is
+ // name-keyed, and the same name is a DIFFERENT function in another overlay — so the S71
+ // past-attempt fuel (§411) could serve one overlay's history to another's target. Every
+ // historical journal row lacks this; stamping it now makes future ones exact.
+ binary: { type: 'string' },
arm: { type: 'string' },
status: { type: 'string', enum: ['MATCH', 'NEAR', 'FAIL', 'NO-DRAFT'] },
closeness: { type: ['integer', 'null'] },
@@ -21,7 +26,7 @@ const VERDICT = {
draft_path: { type: 'string' },
note: { type: 'string' },
},
- required: ['fn', 'arm', 'status', 'closeness', 'compiles', 'draft_path', 'note'],
+ required: ['fn', 'binary', 'arm', 'status', 'closeness', 'compiles', 'draft_path', 'note'],
}
phase('Draft')
log(`wave ${WAVE}: ${TARGETS.length} targets (${TARGETS.filter(t => t.arm === 'sonnet').length} sonnet / ${TARGETS.filter(t => t.arm === 'opus').length} opus)`)
@@ -41,10 +46,10 @@ CLI equivalents (run from ${REPO}):
submit -> write your FINAL draft to ${REPO}/${WAVE}/${t.arm}/${t.name}.c (mkdir -p the dir) and return the JSON verdict.
HARD RULES: never modify anything under src/, config/, include/, asm/, build/ or run make; never touch other agents' files; write only to ${WAVE}/${t.arm}/${t.name}.c and scratch under ${WAVE}/${t.arm}/scratch_${t.name}/. Budget: up to ~24 compile/match_one iterations, then stop honestly.
-Your final answer is the JSON verdict only: fn, arm="${t.arm}", status (MATCH if match_one says MATCH; NEAR if it compiles with closeness>0; FAIL if it never compiled; NO-DRAFT if you wrote nothing), closeness (integer or null), compiles, draft_path, note (one line: what blocked you, or which cookbook § unlocked it).`,
+Your final answer is the JSON verdict only: fn, binary="${t.binary}", arm="${t.arm}", status (MATCH if match_one says MATCH; NEAR if it compiles with closeness>0; FAIL if it never compiled; NO-DRAFT if you wrote nothing), closeness (integer or null), compiles, draft_path, note (one line: what blocked you, or which cookbook § unlocked it).`,
{ label: `${t.arm}:${t.name}`, phase: 'Draft', model: t.arm, schema: VERDICT }
-).then(v => v || { fn: t.name, arm: t.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent returned null' })
- .catch(e => ({ fn: t.name, arm: t.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent error: ' + String(e).slice(0, 120) }))))
+).then(v => v || { fn: t.name, binary: t.binary, arm: t.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent returned null' })
+ .catch(e => ({ fn: t.name, binary: t.binary, arm: t.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent error: ' + String(e).slice(0, 120) }))))
const byArm = {}
for (const r of results.filter(Boolean)) { (byArm[r.arm] = byArm[r.arm] || []).push(r) }
for (const arm of Object.keys(byArm)) {