Files
BFM-decomp/tools/test_residual_class.py
T
Drew T b3ab5c2253 feat(phase-29 Task-13A): deterministic residual classifier — the permuter's problem is TARGETING
The autopsy (hindsight-study §7) assumed the permuter loses for want of a mutation.
Measured over the whole open backlog, it loses because it is aimed at work a
search-closer provably cannot close.

- NEW tools/residual_class.py: decide a near-miss's class FROM THE BYTES. Decodes each
  mismatching MIPS word -> (op-skeleton, register-fields, immediate); drift FIRST (one
  inserted insn inflates `closeness` by the tail length), then consistent-injective
  register map -> REGALLOC-PERM (§31 S11/RC-3), same-multiset-reorder -> SCHEDULE-REORDER,
  DELAY-SLOT, WIDTH/BRANCH-POLARITY/STRENGTH/ADDRESSING/IMM-OFFSET/IMM-VALUE. Every class
  routes to a BUCKET = which tool the failure wants. Uncovered opcode -> UNKNOWN, COUNTED
  (R32). 16 synthetic unit tests (test_residual_class.py).
- NEW tools/autopsy.py: `collect` materialises the corpus Task-12's telemetry never filled
  (1 of 6,169 records had a residual) by recompiling every open draft through the EXISTING
  match_one path (R33) — 1,752 drafts in 21s at -j12. `report` -> docs/autopsy.md.
- NEW corpus.o0_sources()/is_o0(): the opt-level oracle DERIVED from the Makefile's own -O0
  rules, coverage-asserted. Scoring an -O0 target at -O2 makes the residual 100% artefact
  (the trap this phase hit four times).
- R34 cross-check baked in: residual_class's closeness vs masked_diff.structured_diff's,
  asserted per row; 1,673/1,673 agree, 0 classifier errors.

FINDING: of the 972 records the grinder's own filter admits, only 75 (7.7%) are
permuter-shaped; 547 are structural and 348 are drafts that are not the function at all.
~92% of the daemon's CPU went where it could not win — the byte-grounded explanation of
"7 banks all-time, all Phase 21, 0 since" (Phase-22 audit). grinder.candidates() now
filters on the measured bucket (1,303 -> 78) and takes its directed profile from the
measured class, not the logged label (91% carry none -> it ran on gcc defaults).
Degrades to undirected if uncollected and says so; --no-targeting A/Bs it.

Two measured corollaries (R14, not projections):
- 699 records rank as near-misses at closeness up to 278 purely from a length artefact:
  un-attempted work misfiled as a backlog of hard functions -> new `redraft` bucket.
- a 12-draft gate probe of the `integration` bucket banked 1/12 (11 PLUMBING), so the 306
  prices Task 14's reconcile ladder rather than promising free banks. func_80167714
  (104 ins, reach-134) banked x1, un-propagated by design (§55b).

Two defects fixed forward:
- masked_diff._common_typedefs() used ONE shared probe path, so parallel match_one
  processes clobbered each other: 14 of 1,752 drafts lost in a single 12-way run (0.8%),
  silently, in every parallel wave ever run. Now per-PID.
- gate_stage.match_one_closeness never passed --o0 -> phantom residuals for every -O0
  function, written straight into the backlog this autopsy reads.

R22 clean-fleet: check-all 140 passed, 0 failed of 140; tools-health OK (dedup 1847/0,
C1 234343/234343); 0 NON_MATCHING (G4). Flywheel captured in-session (R30/R31):
cookbook §60, decision-log entry, SETUP.md inventory.
2026-07-21 11:03:59 -06:00

167 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""test_residual_class.py — synthetic unit tests for the deterministic residual classifier.
Hand-encoded MIPS-I words (verified against the encoding, not against our own decoder — a test
that reuses the code under test proves nothing). Every named class must fire on a stream that
exhibits it, and the classes that route to `structural` must NOT be misread as regalloc/schedule:
that misread is the whole failure mode this classifier exists to prevent, because it sends the
permuter to grind CPU at a residual local mutation can never reach.
Run: .venv/bin/python tools/test_residual_class.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import residual_class as rc
def I(word, mnem="", reloc=None):
return {"word": word, "mnem": mnem, "reloc_kind": reloc, "reloc_op": None}
# --- hand-encoded words -----------------------------------------------------------------------
ADDU_V0_A0_A1 = 0x00851021 # addu $v0,$a0,$a1 op0 rs=4 rt=5 rd=2 funct=0x21
ADDU_V1_A0_A1 = 0x00851821 # addu $v1,$a0,$a1 rd=3
ADDU_S0_S1_S2 = 0x02328021 # addu $s0,$s1,$s2 rs=17 rt=18 rd=16
ADDU_S1_S0_S2 = 0x02128821 # addu $s1,$s0,$s2 rs=16 rt=18 rd=17
OR_S2_S0_ZERO = 0x02009025 # or $s2,$s0,$zero rs=16 rt=0 rd=18 funct=0x25
OR_S2_S1_ZERO = 0x02209025 # or $s2,$s1,$zero rs=17
OR_S2_S2_ZERO = 0x02409025 # or $s2,$s2,$zero rs=18
LW_V0_8_A0 = 0x8C820008 # lw $v0,8($a0) op=35 rs=4 rt=2 imm=8
LH_V0_8_A0 = 0x84820008 # lh $v0,8($a0) op=33
LW_V0_12_A0 = 0x8C82000C # lw $v0,12($a0) imm=12
LW_V0_16_A0 = 0x8C820010 # lw $v0,16($a0) imm=16
LW_V0_20_A0 = 0x8C820014 # lw $v0,20($a0) imm=20
SLL_V0_A0_2 = 0x00042080 # sll $v0,$a0,2 op0 rt=4 rd=2 sa=2 funct=0
MULT_A0_A1 = 0x00850018 # mult $a0,$a1 op0 rs=4 rt=5 funct=0x18
BEQ_A0_A1 = 0x10850004 # beq $a0,$a1,+4 op=4
BNE_A0_A1 = 0x14850004 # bne $a0,$a1,+4 op=5
NOP = 0x00000000
JAL = 0x0C000000 # jal (target is a link-time value -> always masked)
PASS = []
FAIL = []
def check(name, got, want_klass, want_bucket=None):
ok = got["klass"] == want_klass and (want_bucket is None or got["bucket"] == want_bucket)
(PASS if ok else FAIL).append(
"%-22s -> %-17s %-11s %s" % (name, got["klass"], got["bucket"], got["sig"]))
if not ok:
FAIL[-1] += " EXPECTED %s/%s" % (want_klass, want_bucket)
def main():
# 1. identical streams -> MATCH
s = [I(ADDU_V0_A0_A1), I(LW_V0_8_A0), I(NOP)]
check("identical", rc.classify_streams(s, list(s)), "MATCH", "integration")
# 2. a jal whose target differs is MASKED away -> still MATCH (relocation-invariance)
a = [I(JAL | 0x111, "jal x", "26"), I(NOP)]
b = [I(JAL | 0x222, "jal y"), I(NOP)]
check("jal target masked", rc.classify_streams(a, b), "MATCH", "integration")
# 3. GLOBAL register permutation $s0<->$s1 -> REGALLOC-PERM (the §31 S11/RC-3 class)
a = [I(ADDU_S0_S1_S2, "addu"), I(OR_S2_S0_ZERO, "or"), I(NOP)]
b = [I(ADDU_S1_S0_S2, "addu"), I(OR_S2_S1_ZERO, "or"), I(NOP)]
v = rc.classify_streams(a, b)
check("reg permutation", v, "REGALLOC-PERM", "permuter")
assert v["detail"]["global"] is True, "a whole-function swap must read as global"
# 4. two instructions swapped in ORDER -> SCHEDULE-REORDER
a = [I(LW_V0_8_A0, "lw"), I(ADDU_V0_A0_A1, "addu"), I(NOP)]
b = [I(ADDU_V0_A0_A1, "addu"), I(LW_V0_8_A0, "lw"), I(NOP)]
check("stmt reorder", rc.classify_streams(a, b), "SCHEDULE-REORDER", "permuter")
# 5a. a nop hoisted INTO the delay slot, same instruction multiset -> SCHEDULE-REORDER.
# Reorder is the tighter (and correct) reading of a pure swap; what matters for routing is
# that it lands on the SCHEDULE profile either way.
a = [I(BEQ_A0_A1, "beq"), I(NOP, "nop"), I(ADDU_V0_A0_A1, "addu")]
b = [I(BEQ_A0_A1, "beq"), I(ADDU_V0_A0_A1, "addu"), I(NOP, "nop")]
v = rc.classify_streams(a, b)
check("delay-slot swap", v, "SCHEDULE-REORDER", "permuter")
assert v["profile"] == "schedule", "a nop/insn swap must route to the schedule profile"
# 5b. gcc filled the slot with an instruction we left empty and the multisets DIFFER
# -> DELAY-SLOT in its own right
a = [I(BEQ_A0_A1, "beq"), I(NOP, "nop"), I(ADDU_V0_A0_A1, "addu")]
b = [I(BEQ_A0_A1, "beq"), I(ADDU_V0_A0_A1, "addu"), I(SLL_V0_A0_2, "sll")]
check("delay-slot fill", rc.classify_streams(a, b), "DELAY-SLOT", "permuter")
# 6. ONE extra instruction near the front of a long function -> LENGTH-DRIFT, not a wall.
# closeness reads ~tail-length; the class must say "+1 instruction", the structural truth.
body = [I(LW_V0_8_A0 + (i << 0), "lw") for i in range(0, 40)]
a = body[:4] + [I(NOP, "nop")] + body[4:]
b = list(body)
v = rc.classify_streams(a, b)
check("one extra insn", v, "LENGTH-DRIFT", "structural")
assert v["detail"]["delta"] == 1 and v["detail"]["explains"] == "tail", v["detail"]
assert v["closeness"] > 30, "index-wise closeness really is inflated (that is the point)"
# 7. access WIDTH flip lw->lh -> structural (an §18/§43 idiom, NOT permuter fuel)
a = [I(LW_V0_8_A0, "lw $v0,8($a0)"), I(ADDU_V0_A0_A1, "addu")]
b = [I(LH_V0_8_A0, "lh $v0,8($a0)"), I(ADDU_V0_A0_A1, "addu")]
check("load width flip", rc.classify_streams(a, b), "WIDTH", "structural")
# 8. branch polarity flip -> structural
a = [I(BEQ_A0_A1, "beq $a0,$a1,4"), I(NOP)]
b = [I(BNE_A0_A1, "bne $a0,$a1,4"), I(NOP)]
check("branch polarity", rc.classify_streams(a, b), "BRANCH-POLARITY", "structural")
# 9. strength reduction mult <-> sll
a = [I(MULT_A0_A1, "mult $a0,$a1"), I(NOP)]
b = [I(SLL_V0_A0_2, "sll $v0,$a0,2"), I(NOP)]
check("strength reduction", rc.classify_streams(a, b), "STRENGTH", "structural")
# 10. every immediate off by a CONSTANT -> IMM-OFFSET (a frame/struct-layout shift)
a = [I(LW_V0_8_A0, "lw"), I(LW_V0_12_A0, "lw"), I(LW_V0_16_A0, "lw")]
b = [I(LW_V0_12_A0, "lw"), I(LW_V0_16_A0, "lw"), I(LW_V0_20_A0, "lw")]
v = rc.classify_streams(a, b)
check("constant imm shift", v, "IMM-OFFSET", "structural")
assert v["detail"]["delta"] == 4, v["detail"]
# 11. a HI16/LO16 immediate difference is MASKED -> not a residual at all
a = [I(0x3C020000 | 0x8013, "lui $v0,0x8013", "HI16"), I(NOP)]
b = [I(0x3C020000 | 0x8017, "lui $v0,0x8017"), I(NOP)]
check("hi16 imm masked", rc.classify_streams(a, b), "MATCH", "integration")
# 12a. TWO disjoint swaps ($s0<->$s1 and $v0->$v1) are still ONE consistent injective map,
# so this is a genuine permutation — asserting it is not, is the mistake to avoid.
a = [I(ADDU_S0_S1_S2, "addu"), I(ADDU_V0_A0_A1, "addu")]
b = [I(ADDU_S1_S0_S2, "addu"), I(ADDU_V1_A0_A1, "addu")]
check("two disjoint swaps", rc.classify_streams(a, b), "REGALLOC-PERM", "permuter")
# 12b. a CONTRADICTORY map ($s0->$s1 here, $s0->$s2 there) is not a permutation
# -> REGALLOC-LOCAL: still regalloc, but honestly labelled as unexplained
a = [I(ADDU_S0_S1_S2, "addu"), I(OR_S2_S0_ZERO, "or")]
b = [I(ADDU_S1_S0_S2, "addu"), I(OR_S2_S2_ZERO, "or")]
check("contradictory map", rc.classify_streams(a, b), "REGALLOC-LOCAL", "permuter")
# 13. coverage assertion: an opcode the decoder does not cover must be COUNTED as UNKNOWN,
# never silently absorbed into a neighbouring class (R32).
a = [I(0xF4000000, "??"), I(NOP)]
b = [I(0xF4000001, "??"), I(NOP)]
check("uncovered opcode", rc.classify_streams(a, b), "UNKNOWN", "unknown")
# 14. the degraded (stored-residual) path declares itself degraded
v = rc.classify_residual([[0, "%08x addu" % ADDU_S0_S1_S2, "%08x addu" % ADDU_S1_S0_S2]])
assert v["detail"].get("degraded") is True, "the capped path must flag itself"
PASS.append("%-22s -> %-17s %-11s %s" % ("degraded path", v["klass"], v["bucket"], "flagged"))
# 15. every class in the route table is reachable from KLASSES and has a bucket
for k in rc.KLASSES:
assert rc._ROUTE[k][1] in ("permuter", "structural", "integration",
"redraft", "unknown"), k
for line in PASS:
print(" ok " + line)
for line in FAIL:
print(" FAIL " + line)
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())