Files
BFM-decomp/tools/residual_rules.py
T
Drew T 02592300df feat(triage): the residual-classifier head-to-head — both implementations, kept
residual_rules.py (mine) and residual_rules_b.py (an independent Fable build,
forbidden from reading mine). Committed because the EXPERIMENT is the artifact:

                          mine    b
  classified            85/113  113/113
  errored                   28        0
  any rule fired           18%      88%
  certain/high              1%      63%
  pure residual-SHAPE      ~1%     1.8%

The last row is the finding. Two independent implementations CONVERGED at ~1-2% on
pure cookbook-shape rules, so that tier's ceiling is the POPULATION, not the code:
surgical single-mechanism residuals live at the END of escalations, not in
first-pass wave output. The shape tier belongs in escalation loops; the ladder's
value is everything above it (banked / wall / compile / autodecl / integration).

b also diagnosed my 28 errors exactly: they are functions banked DURING S68 after
the eval set was drawn, so corpus.stubs() no longer contains them and my resolver
raised IndexError on every one. It detects the same condition via corpus.matched()
and calls it ALREADY-BANKED — stale card, spend zero tokens.

Two things b did better that are worth copying: it never parsed disassembly TEXT
(every decision decodes the raw 32-bit word, so the two-disassembler formatting
disagreement that cost me two bugs never touched it), and it REMOVED three of its
own false-positive mechanisms found on held-out cases, all score-reducing, and
disclosed them.

Spec for finishing the ladder: docs/next-session-triage-ladder.md
2026-08-31 22:24:56 -06:00

282 lines
14 KiB
Python

#!/usr/bin/env python3
"""residual_rules.py — turn a measured residual into the cookbook lever that addresses it. (P31 S68)
WHY THIS EXISTS, with the price attached. `docs/cookbook-index.md` is already a SYMPTOM -> SECTION
table, hand-curated from what agents actually hit. But it is PROSE, so every agent re-derives the
lookup by reading, and the lookup is most of what an escalation buys. Measured in S68, each of these
closed a function by APPLYING KNOWLEDGE THAT WAS ALREADY WRITTEN DOWN — in three cases written down
the same hour:
func_800241C0 sonnet 229k (no bank) -> fable 74k MATCH
func_80187A30 opus 294k (no bank) -> fable 152k MATCH
func_8003491C sonnet 348k (no bank) -> fable 135k MATCH
func_8017E764 opus 386k (no bank) -> fable 215k MATCH
func_8017EC4C opus 408k (no bank) -> fable 211k MATCH
That is ~1.7M tokens of rediscovery. A table lookup wearing a very expensive coat.
WHAT THIS DOES AND DELIBERATELY DOES NOT DO. It CLASSIFIES a residual and PRESCRIBES the lever, with
the evidence rows that triggered it. It does **not** rewrite C. The fixes are structural — "declare
a function-scope single-set local and use it constant-first" (§368), "give every case its own
duplicated body" (§366) — not regex-able, and a rewriter that guesses wrong SILENTLY is precisely
the failure mode that cost this session a dozen debugging detours. Classification is where the
measured spend is; rewriting is where the risk is.
EVERY RULE STATES ITS OWN CONFIDENCE AND ITS OWN REFUTATION. A rule that cannot be wrong is not a
rule, it is a wish — and §361/§370 exist precisely because two agents asserted a compiler-internal
cause that was really their own earlier lever. So each rule carries `refute:` telling the reader what
would DISPROVE it, and the caller is expected to check that before spending.
tools/residual_rules.py --binary ov_SC03_105 --fn func_80187A30 --c <draft.c>
tools/residual_rules.py --selftest # the S68 functions whose cause is KNOWN
"""
import argparse
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(REPO)
sys.path.insert(0, os.path.join(REPO, "tools"))
import corpus # noqa: E402
# THE TWO SIDES ARE DISASSEMBLED BY DIFFERENT TOOLS AND DISAGREE ON THE `$`:
# yours reads `mult v0,v1`, the target reads `mult $a3, $v0`. My first version required the `$`,
# so one side parsed as ZERO registers, the lengths never matched, and the rule silently skipped a
# case whose cause was already proven. A detector that cannot reproduce a KNOWN member is not
# evidence — which is exactly the rule this file exists to encode.
REG = r"\$?(?:zero|at|v[01]|a[0-3]|t[0-9]|s[0-8]|k[01]|gp|sp|fp|ra)\b"
COMMUTATIVE = ("mult", "multu", "addu", "add", "and", "or", "xor", "nor", "slt", "sltu")
def _txt(row):
"""(mine, target) with the leading raw hex word stripped, tabs normalised."""
def clean(s):
s = re.sub(r"^[0-9a-f]{8}\s+", "", s or "")
return re.sub(r"\s+", " ", s.replace("\t", " ")).strip()
return clean(row[1]), clean(row[2])
def _mn(s):
return (s.split(" ", 1) + [""])[0]
def _regs(s):
return re.findall(REG, s)
# ---------------------------------------------------------------------------------------------
# Each rule: (id, section, confidence, test(rows, ctx) -> (evidence_rows, note) | None, advice, refute)
# ---------------------------------------------------------------------------------------------
def rule_wall_332(rows, ctx):
"""A %lo in a TARGET delay slot: the tail of an assembler macro gcc emits atomically."""
hits = [r for r in ctx["target_slot_lo"]]
if hits:
return hits[:3], "target fills %d delay slot(s) with a macro tail" % len(hits)
return None
def rule_copy_capture(rows, ctx):
"""§372: a wrong-register row whose TARGET register is the DEST of a matching copy insn."""
copy_dests = set()
for r in rows:
m, t = _txt(r)
if m == t and re.match(r"addu\s+(%s)\s*,\s*(%s)\s*,\s*\$zero" % (REG, REG), t):
copy_dests.add(_regs(t)[0])
if not copy_dests:
return None
ev = []
for r in rows:
m, t = _txt(r)
if not m or not t or _mn(m) != _mn(t):
continue
rm, rt = _regs(m), _regs(t)
if len(rm) == len(rt) and rm != rt:
moved = [b for a, b in zip(rm, rt) if a != b]
if any(x in copy_dests for x in moved):
ev.append(r)
if ev:
return ev[:3], "wrong-register row reads the destination of a MATCHING copy insn"
return None
CONSTGEN = re.compile(r"^(?:li|addiu|ori)\s+(%s)\s*,\s*(?:(%s)\s*,\s*)?(-?(?:0x)?[0-9a-fA-F]+)$"
% (REG, REG))
def _val(tok):
"""int value of an immediate written in any base the two disassemblers use, or None."""
try:
return int(tok, 16) if tok.lower().startswith(("0x", "-0x")) else int(tok, 10)
except (TypeError, ValueError):
return None
def _norm(r):
return (r or "").lstrip("$")
def rule_reload_remat(rows, ctx):
"""§368: the CONSTANT's register changes AND its OPERAND POSITION in a commutative op changes.
§368's tell is a ROLE change, not a reversal: "the constant register sits FIRST in the emitted
commutative op while your draft has it second". That is visible across TWO rows — a
constant-materialisation whose destination register differs, and a later commutative op where
that register appears in a different operand slot. My first version tested for the registers
being literally reversed and therefore missed ov_SC03_105/func_80187A30, whose cause fable had
already PROVEN to be exactly this.
"""
moved = {} # normalised const-dest in MINE -> in TARGET
for r in rows:
m, t = _txt(r)
cm, ct = CONSTGEN.match(m), CONSTGEN.match(t)
# COMPARE VALUES, NOT TEXT. One side prints `li v1,30` and the other
# `addiu $a3,$zero,0x1E` — the SAME constant in different bases. String-comparing them
# (and `lstrip("0x")`, which strips CHARACTERS, not a prefix) silently never matched.
if cm and ct and _val(cm.group(3)) is not None and _val(cm.group(3)) == _val(ct.group(3)):
if _norm(cm.group(1)) != _norm(ct.group(1)):
moved[_norm(cm.group(1))] = _norm(ct.group(1))
if not moved:
return None
ev = []
for r in rows:
m, t = _txt(r)
if not m or not t or _mn(m) != _mn(t) or _mn(t) not in COMMUTATIVE:
continue
rm = [_norm(x) for x in _regs(m)]
rt = [_norm(x) for x in _regs(t)]
if len(rm) != len(rt) or len(rm) < 2:
continue
for src, dst in moved.items():
if src in rm and dst in rt and rm.index(src) != rt.index(dst):
ev.append(r)
break
if ev:
return ev[:3], ("the constant's register moved (%s) AND its operand POSITION changed in a "
"commutative op" % ", ".join("%s->%s" % kv for kv in list(moved.items())[:2]))
return None
def rule_slot_steal(rows, ctx):
"""§373/§370: target fills a delay slot where mine emits nop (reorg steal)."""
ev = [r for r in rows if _mn(_txt(r)[0]) == "nop" and _mn(_txt(r)[1]) not in ("nop", "")]
if ev:
return ev[:3], "target fills %d slot(s) where yours emits nop" % len(ev)
return None
def rule_length_drift(rows, ctx):
"""§366 / frame-size family: a length mismatch dominates; a diff-by-rows read is misleading."""
d = ctx["nins_tgt"] - ctx["nins_mine"]
if d:
return [], "length drift %+d (target %d, yours %d)" % (d, ctx["nins_tgt"], ctx["nins_mine"])
return None
RULES = [
("WALL-332", "§332 + tools/wall_sweep.py", "certain", rule_wall_332,
"STOP. The target's delay slot holds the second half of an assembler macro (a %lo). gcc emits "
"that pair as ONE atomic insn, so NO C can put it there. This is a toolchain wall — do not "
"draft it and NEVER escalate it; an escalation cannot beat the toolchain.",
"Refuted if the %lo line is NOT actually in a delay slot (check the preceding line is a "
"branch/jal)."),
("COPY-CAPTURE", "§372", "high", rule_copy_capture,
"Spell the copy as a PLUS so it is not a reg-reg copy: `register s32 zr __asm__(\"$0\"); "
"P = X + zr;`. That kills BOTH cse make_regs_eqv (no reg equivalence recorded) and "
"local-alloc optimize_reg_copy_1 (not a single_set copy), and emits the byte-identical "
"`addu $rd,$rs,$zero`. Uses that must read the SOURCE register must precede the copy in "
"SOURCE order — canon_reg walks source order.",
"Refuted if the rows' registers are unrelated to any matching copy's destination — then it is "
"an allocation problem (§368) or a schedule (§373), not capture."),
("RELOAD-REMAT", "§368", "high", rule_reload_remat,
"Declare the constant as a FUNCTION-SCOPE single-set local assigned ONCE at entry (a different "
"basic block from its uses) and write each use constant-first: `s32 cK; cK = K; ... prod = cK "
"* x;`. If its live range crosses calls and the callee-saved regs are taken, global-alloc "
"leaves it uncolored, reload records reg_equiv_constant, DELETES the init (count-neutral) and "
"rematerialises per use — choosing the register by order_regs_for_reload, which reaches "
"registers no pin can. Do NOT pin: pins measured WORSE (18 and 14 vs 8).",
"Refuted if the rows are not commutative ops, or if the operand order already matches — then "
"see §372 (capture) or §373 (anti-dep)."),
("SLOT-STEAL", "§373 (+§370)", "medium", rule_slot_steal,
"reorg fill_simple_delay_slots steals the FIRST ELIGIBLE insn scanning BACK from the branch — "
"eligible = shares no register with anything between it and the branch. So the question is "
"not 'how do I move X into the slot' but 'what makes X eligible and its rival not'. The value "
"must accumulate OUTSIDE the register-heavy tail: split the expression so the accumulator "
"lands in a different register. Prefer the DEAD-RESET cse-breaker (`p = expr; use(p); p = 0;`) "
"over an asm re-tie — the re-tie is a real insn whose def->asm->arg chain perturbs the "
"schedule and has caused a phantom residual twice.",
"Refuted if the slot insn is a %lo macro tail — then it is WALL-332 and unreachable."),
("LENGTH-DRIFT", "§366 / §333 / §358", "low", rule_length_drift,
"A length mismatch makes every row after the first divergence misleading — fix the COUNT "
"first. On a switch, a drift equal to a small number of case nodes points at group_case_nodes "
"merging STACKED consecutive labels (§366: give every case its own duplicated body, let "
"cross_jump re-merge). Otherwise suspect the frame: a DECLARED aggregate sets frame size "
"(§333) and an UNREFERENCED one still takes its slot (§358).",
"Refuted once the counts match and rows still differ — then classify the rows, not the count."),
]
def classify(binary, fn, cpath):
st = next((s for s in corpus.stubs(binary).values() if s.symbol == fn), None)
if st is None:
return {"error": "no open stub %s:%s" % (binary, fn)}
p = subprocess.run([sys.executable, "tools/match_one.py", fn, "--c", cpath,
"--asm-subdir", st.asm_dir, "--json"],
capture_output=True, text=True, timeout=600)
try:
d = json.loads((p.stdout or "").strip().splitlines()[-1])
except (ValueError, IndexError):
return {"error": "match_one gave no JSON", "raw": (p.stdout or p.stderr)[-300:]}
rows = d.get("residual") or []
v = d.get("verdict") or {}
# the target's own delay slots holding a macro tail — reuse the wall detector, do not re-derive
import wall_sweep
slot_lo = wall_sweep.scan(os.path.join(st.asm_dir, fn + ".s")) or []
ctx = {"nins_mine": v.get("nins_mine") or d.get("nins") or 0,
"nins_tgt": v.get("nins_tgt") or 0, "target_slot_lo": slot_lo}
out = {"binary": binary, "fn": fn, "status": d.get("status"),
"closeness": d.get("closeness"), "rows": len(rows), "hits": []}
for rid, sec, conf, test, advice, refute in RULES:
r = test(rows, ctx)
if r:
ev, note = r
out["hits"].append({"rule": rid, "section": sec, "confidence": conf, "why": note,
"advice": advice, "refuted_if": refute,
"evidence": [{"idx": e[0], "mine": _txt(e)[0], "target": _txt(e)[1]}
for e in ev]})
return out
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--binary"); ap.add_argument("--fn"); ap.add_argument("--c")
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
if not (a.binary and a.fn and a.c):
ap.error("give --binary --fn --c")
r = classify(a.binary, a.fn, a.c)
if a.json:
print(json.dumps(r, indent=1)); return 0
if r.get("error"):
print("ERROR:", r["error"]); return 1
print("%s:%s status=%s closeness=%s (%d residual rows)"
% (r["binary"], r["fn"], r["status"], r["closeness"], r["rows"]))
if not r["hits"]:
print(" no rule fired — this is genuinely unclassified, grind or escalate")
for h in r["hits"]:
print("\n [%s] %s (confidence: %s)" % (h["rule"], h["section"], h["confidence"]))
print(" why: %s" % h["why"])
for e in h["evidence"]:
print(" idx %-4d yours: %-34s target: %s" % (e["idx"], e["mine"], e["target"]))
print(" DO: %s" % h["advice"])
print(" refuted if: %s" % h["refuted_if"])
return 0
if __name__ == "__main__":
sys.exit(main())