docs(phase-29): cookbook §90 — five tool-integrity laws from SESSION-21 (R16/R30)

Each law is one this session paid for, and each fix CHANGED the answer its tool gave:
 §90a a comparison tool must share its reference oracle's index space exactly (objdump -dr vs -drz
      elided 6 nops -> 104 vs 110 ins -> phantom mismatches; and the .s word field is little-endian
      hex TEXT, not the instruction integer). Five false alarms preceded the first true one.
 §90b 'byte-neutral' is not 'wanted' — undo on the SUCCESS path (a 0-bank run left 123 files of
      dead diff because the backstop only fired on a NON-neutral edit). §61 applied forward.
 §90c a library-callable function must FAIL CLOSED on unconfigured module state — an empty result
      is indistinguishable from a true negative (R32's silent skip in a different hat).
 §90d do not measure a live wave's drafts — §87 staleness in real time (same function, two verdicts
      two minutes apart; its agent rewrote the file 12s earlier).
 §90e an agent's CONCLUSION and its EVIDENCE fail independently: re-derive the premise, design the
      fix from what you can prove. The agent's jtbl bug was real and confirmed 3 ways; its 'zero
      xrefs' premise was false and its proposed remedy would have deleted a referenced symbol.
      The fix instead gates on the function's own sltiu bound — the program declaring its own table
      length. Negative control (228 combinations, exactly 1 change) is half the deliverable.
This commit is contained in:
Drew T
2026-07-27 09:29:12 -06:00
parent 87b02b044f
commit 0be781efdf
+76
View File
@@ -6741,3 +6741,79 @@ family_sweep --stage-only → sweep_parallel.py -j 12 → blast_radius.py
```
**Parallelism changes THROUGHPUT, never the verdict** — the whole-binary byte-gate is still the sole
arbiter (G3/P9) and still reverts a wrong draft in its own binary.
## §90 — Five tool-integrity laws from one session, each of which changed an answer (Phase 29 SESSION-21, 2026-07-27)
SESSION-21 set out to run a mass wave over the family exemplars. Before the wave returned a single
draft, five tools had produced a confidently wrong answer, and each fix changed the number it gave.
These are the generalizable laws, not the individual bugs.
### §90a — A comparison tool MUST share its reference oracle's index space, exactly
`tools/reloc_verify.py` (promoted this session) compares "what the target names at instruction index
i" against "what my object relocates at index i". It used `objdump -dr`; `masked_diff` has always
used `-drz`. **Without `-z`, objdump ELIDES runs of identical instructions**, dropping them from the
listing — `func_801330E0` read **104** instructions under `-dr` and **110** under `-drz` (6 elided
nops). Every index after the first nop run therefore compared against the wrong instruction, and the
tool manufactured 2 mismatches on a draft that is clean at those sites.
A second instance in the same tool: the splat `.s` word field is **little-endian hex TEXT**
(`0080033C`), not the instruction integer. `masked_diff` byte-swaps it (`struct.unpack("<I", …)`);
the new tool did not, and reported "word differs" on three sites that are byte-IDENTICAL
(`3C038000` vs `3C038000`).
> **The law:** when you write a tool that compares against an existing oracle, do not re-derive its
> extraction — *reuse it, or byte-for-byte replicate its flags and encodings*, and cross-check on a
> known-clean input before trusting a single verdict. Five false alarms preceded the first true one.
### §90b — "Byte-neutral" is not "wanted": undo on the SUCCESS path too
Two ladder stages (`family_sweep --normalize-self-decls`, `gate_stage`'s ARITY pre-pass) had a
backstop that restored their snapshot **only when the edit proved NON-neutral**. A run that banked
nothing therefore left every edit in the tree: `--normalize-self-decls` 0/123 left **123 files /
246 insertions / 246 deletions** of dead diff, and a `git add -A` would have committed it as noise.
> **The law (§61 applied to the success path):** an edit that bought nothing gets reverted,
> regardless of whether it was byte-safe. Restore the snapshot on a 0-bank group. It cannot cost a
> match — there is nothing to preserve — so the only thing at risk is the noise itself.
### §90c — A library-callable function must FAIL CLOSED on an unconfigured module
`progress.linked_subsegs()` is gated on a module global that `set_binary()` assigns. Imported as a
library without that call it returned an **empty set** — "this binary has no linked library
subsegs", which for `main` is wrong by 49 and silently reclassifies ~960 already-byte-identical
PsyQ-linked stubs as outstanding game-code work. The CLI path always configured first, so the defect
was invisible for as long as nobody imported it. I hit it within five minutes of importing it.
> **The law:** a function whose correctness depends on module state must raise when that state is
> absent. An empty result is indistinguishable from a true negative, and that is the R32 silent-skip
> shape wearing a different hat.
### §90d — Do not measure a live wave's drafts (§87 in real time)
Cross-checking the wave's landed drafts mid-run gave a *different* verdict for the same function two
minutes apart — its agent had rewritten the file 12 seconds earlier. §87 says a stored draft is
"a claim with a timestamp"; during a live wave that timestamp is **now**.
> **The law:** draft QA happens after the wave returns, never during. A measurement of a file that
> is still being written describes nothing.
### §90e — An agent's CONCLUSION and its EVIDENCE fail independently — re-derive the premise, not the fix
A wave agent found a genuine `jtbl_carve` bug (spimdisasm splits one jump table across two dlabels,
so the carve reserves 112 B for an object supplying 200 B — §84-class, invisible to `match_one`).
Its conclusion was correct and confirmed three ways. **Its stated evidence was not:** it reported the
continuation label as having *"ZERO xrefs anywhere in the tree"*; the label has two, and the agent's
proposed remedy — delete it — would have removed a symbol two emitted words reference.
The repair therefore uses a gate that needs no judgement call: **the owning function's own
`sltiu N` range check**, which gcc emits immediately before the indexed load, so *the program
declares its own table length*. Absorb a continuation only when it is adjacent, its words are all
code addresses, and absorbing lands on an **exact** `sltiu` bound — the SET of bounds, never
`max()`, because a multi-switch function has several and no way to say which owns this table.
Three more errors surfaced only by testing the fix: the absorption fired and the **trailing-pad trim
immediately undid it** (it re-trimmed against the first dlabel's words); a continuation ends at **its
own last `.word`**, not the next dlabel (224 B further on here) — which is the very assumption being
repaired; and the shortfall warning fired **~90 times across 38 tables** until it was scoped to an
unambiguous single-bound pairing. *A warning that fires on ambiguity is noise, not a signal.*
> **The law:** when an agent hands you a diagnosis AND a fix, re-derive the diagnosis from the bytes
> and design the fix from what you can prove. Verified: the split table goes 28 → 50 words, and
> across **38 jtbls × 6 functions = 228 combinations exactly ONE range changes**. Both halves of
> that sentence are the deliverable — the fix, and the negative control proving its blast radius.