feat(draw): audit the exclude list as a PREREQUISITE — a stale one is refused

An exclude list records what the TOOLING could not do, then gets treated as a property of
the FUNCTIONS. Nothing re-examined it, so every tool fix left behind a population that is
now tractable and still marked impossible — invisible, because the draw filters it out
before anything measures it.

MEASURED one day after .run/S71_exclude.txt was written: 88 of its 107 entries were
stale — 28 already banked, 14 linked PsyQ symbols that were never targets, and 46 whose
blocker had since been fixed. Those 46 are 12,750 instructions of open, drawable work
including main:SaveLoadRoutine (1,165), the largest function left in main.

* tools/exclude_audit.py (NEW) — classifies each entry by its CURRENT blocker
  (BANKED / LINKED / RE-PROBE / CARVE-BLOCKED / WALL), regenerates keeping only the
  still-valid classes, and --assert-fresh exits 3 on staleness.
* draw_waves --exclude-file — runs that audit and REFUSES to draw on a stale list, naming
  the counts and the regenerate command. --exclude-stale-ok still draws but prints what it
  ignores: skipping is possible, never silent. Also fixes the old --exclude parsing, which
  could not survive a '#' comment.
* .run/S72_exclude.txt — the regenerated list: 19 entries (16 CARVE-BLOCKED + 3 WALL),
  each carrying its reason, down from 107.

Verified in all three directions: stale refuses rc=1, fresh proceeds rc=0, override
proceeds and announces. The parser's own report-don't-drop design caught a bug I
introduced in it (comma-splitting before comment-stripping).
This commit is contained in:
Drew T
2026-09-02 14:38:39 -06:00
parent fd5e700cfe
commit 983df054f2
5 changed files with 239 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
# regenerated by tools/exclude_audit.py from .run/S71_exclude.txt
# 19 still-valid of 107; 88 dropped as stale (banked / linked / blocker-since-fixed).
# An exclude list records what the TOOLING could not do — regenerate it as
# part of every tool fix, or it becomes a list of work you decided not to do.
main:func_8005D9C4 # WALL: no jump table — curated compiler fact, not a tooling limit
main:func_8005ECC0 # WALL: no jump table — curated compiler fact, not a tooling limit
main:func_80062144 # WALL: no jump table — curated compiler fact, not a tooling limit
ov_SC01_084:func_80182328 # CARVE-BLOCKED: ov_SC01_084_jr_8017F690 owns >1 raw jtbl span — needs a split
ov_SC01_084:func_80182A00 # CARVE-BLOCKED: ov_SC01_084_jr_8017F690 owns >1 raw jtbl span — needs a split
ov_SC02_005:func_80185060 # CARVE-BLOCKED: ov_SC02_005_jr_80181D30 owns >1 raw jtbl span — needs a split
ov_SC02_005:func_80185E80 # CARVE-BLOCKED: ov_SC02_005_jr_80181D30 owns >1 raw jtbl span — needs a split
ov_SC02_011:func_80183178 # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split
ov_SC02_011:func_80183630 # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split
ov_SC02_011:func_8018418C # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split
ov_SC02_011:func_80188E3C # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split
ov_SC03_105:func_801806F8 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_80180ABC # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_80180EC0 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_801813BC # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_801818E8 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_80181C84 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_8018624C # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
ov_SC03_105:func_801867D0 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split
+2
View File
@@ -754,6 +754,8 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo
| | `tools/t5_targets.py` · `tools/t5_cards.py` · `tools/wave_card_fuel.py` · `tools/t5_bank.sh` | **(Phase 31 S63 T5)** The wave *draw* and the wave *bank* halves around the T4 harness: `t5_targets.py --wave .run/t5x --n 48 [--residue .run/t5w] [--main-only]` draws a routed slate (open per `corpus.stubs`, ledger `.run/t5/drawn.json` keyed (binary,fn), ≤120 ins → sonnet / >120 → opus, residue → opus once, stratified by band, ≤4 per binary, one fn NAME per wave — the harness keys packs/drafts by bare name, so `claude_wave_packs`/`wave_judge` now REFUSE duplicates, R43/R48); then **`t5_cards.py --wave .run/t5x`** BUILDS the target's OWN card fuel (`tu_ref` = in-TU banked neighbours §194-E, `decl_prior` = fleet signatures §196) from `wave_card_fuel.py` — those helpers extracted VERBATIM out of `build_wave_atlas.py` (which parses argv at import and so was unusable as a library) so both consumers share ONE oracle (R33); measured 88% tu_ref / 98% decl_prior on a fresh K-class slate, against 0% correct fuel before the fix. Then `claude_wave_packs.py --cards <wave>/cards.json` → Workflow `tools/workflows/claude_wave_draft.js` `args={wave, targets}` (one agent per target, `model=target.arm`) → `tools/t5_bank.sh .run/t5x sonnet opus` (refuses if a lane/gate runs or src/ config/ is dirty; `wave_judge --union`; then a RECOVERY pass — every un-banked draft through `fix_tu_ret_decls`, which retypes a destination TU's `extern void` decl on a value-returning function and re-gates (wave t5a: 2/2 banked, both byte-exact bodies refused for that decl alone); then the R22 clean fleet sweep parsed `N passed, 0 failed of N`; commit `— N banked`). |
| | `tools/jtbl_rodata_pads.py --derive` | **(Phase 31 S62 T3a, cookbook §303)** Module path of the §8e pads filter: the Makefile runs `--derive $(BINARY) --tu <tu>` for every `md_*` object **and for `main` (P31 S72)** — jump-table pads derived at build time from the retail island + the emission stream (trailing pads `0t1`, table-aware, const data passes through). No stored spec; an anchor miss fails the build with the offset. **S72:** `--derive` now serves main too — `_file0_vram` returns the code segment's `vram - start` (the PS-X EXE's 0x800 header), which makes both `raw[a - vram]` and `vram + <yaml offset>` correct for the EXE and leaves flat overlays byte-identical; `_splat_yaml` resolves main to `splat.us.exe.yaml`. |
| | **main's TU layout (P31 S72)** | main's game code is no longer one file. `src/800.c` was split at the jtbl-span TU boundaries into **`src/800.c`** (vram 0x800123F0-0x8002B0B4, owns `.rodata` span A 0x80072A38-0x80072C70), **`src/800_b.c`** (0x8002B0B4-0x80035270, span B 0x80072E44-0x80073140) and **`src/800_c.c`** (0x80035270-0x8003A444, span C 0x800732A0-0x8007344C), plus **`src/800_shared.h`** for the 19 typedefs + 2 includes that cross. Reason: one code object contributes exactly ONE contiguous `.rodata` run, so each jump-table span needs its own object or every switch function in it double-emits its table (cookbook §426/§431). main's island is now a 7-piece sandwich and `ld_interleave` runs with `--order`, not `--front/--tail`. **Any tool that reads "main's source" must glob `corpus.src_files('main')`, never hardcode `src/800.c`** — `reconcile_slate` did, and saw 133 of 187 typedefs after the split. |
| | `tools/split_indicator.py` | **(P31 S72, cookbook §426/§431)** Which code subsegs MUST be split before their switch functions can bank: a code object contributes exactly ONE contiguous `.rodata` run, so a subseg owning raw jump tables in ≥2 non-adjacent island spans makes every switch function outside the one carveable span unbankable at any effort. **Decidable from the raw image at 0% matched** — no attempt needed. `--self-test` fires on main's pre-S72 island, stays silent on main today, and does not over-fire on a one-span subseg; linked PsyQ subsegs are excluded on principle. Runs in `make tools-health` (informational while 4 known violations exist — flip to a hard gate once they are split). First fleet run: 209/213 OK, 4 flagged holding 16 open fns / 3,613 ins. |
| | `tools/exclude_audit.py` | **(P31 S72)** Classifies every wave-exclude entry by its CURRENT blocker — `BANKED` / `LINKED` / `RE-PROBE` (blocker since fixed) / `CARVE-BLOCKED` / `WALL` — and regenerates the list keeping only the still-valid classes. **`draw_waves --exclude-file` runs it as a PREREQUISITE and refuses a stale list** (`--exclude-stale-ok` overrides, loudly). Measured on `.run/S71_exclude.txt` one day after it was written: 88 of 107 entries stale, of which 46 were open drawable functions totalling 12,750 instructions — `main:SaveLoadRoutine` among them. |
| | `tools/main_diff_locate.py` | **(P31 S72, cookbook §426/§427)** Turns a RED main gate — whose whole output is two SHA1s — into a NAMED list of divergent symbols: byte-diffs `build/us/SLUS_007.26` against `extracted/retail/SLUS_007.26`, coalesces the differing bytes into runs, and attributes them PER BYTE to symbols via `build/us/SLUS_007.26.map` (file-offset mapping DERIVED from each output section's `load address`, not the 0x800 header constant). `--focus <fn>` prints the routing verdict: **BODY REJECT** (divergence confined to the function) vs **PLUMBING REJECT** (the function is byte-identical, everything differs elsewhere → the §376/§378 chain) vs **MIXED**. `--self-test <addr>` is the negative control: flips one byte at a known address and asserts the containing symbol is named, plus the identical-pair-reports-zero direction. `gate_main` calls it automatically and preserves the red image + map under `.run/gate_main_fail/` BEFORE the R40 baseline control rebuilds over them. |
| | `tools/blocker_probe.py` | **(Phase 29 SESSION-16, cookbook §65)** WHY a byte-correct draft fails the whole-binary gate. Read-only; **two oracles** (R34): STATIC (`cdecl.parse` + `cdecl.compatible` — cc1's own acceptance question, never text equality) beside the REAL cc1 (via `rtu_match`), leading with the DISAGREEMENT table. Classes `self_decl_hdr`/`self_decl_tu`/`callee_decl`/`data_decl`/`local_type`, each mapped to a blast-radius tier (T0 draft-only / T1 binary-local / T2 fleet-shared). Blockers STACK, so a function's tier is the MAX over them. 36 drafts in ~9 s. Replaced+deleted `.run/diag_plumbing.py`. |
| | `tools/symcheck.py` | **(Phase 29 SESSION-18, cookbook §67a)** The pre-gate SYMBOL-SET guard: diffs the symbols a draft's object references (reloc records) against the target `.s`'s `%hi`/`%lo`/`jal` set. Catches the class **every masked oracle is structurally blind to** — `match_one`/`masked_diff` compare relocation-MASKED words (object-vs-`.s` mode is symbol-agnostic by construction) and `rtu_match` **compiles without linking**, so a draft that invents an extern no symbol table defines reads MATCH in both and can never bank (the SESSION-17 `func_801463A0` `_s`-alias trap). Negative-control-proven: on a draft with one renamed data extern, `match_one` reports the SAME 14 mismatched as the correct draft while `symcheck` exits 1 naming both the MISSING and the INVENTED symbol. `--c` (compiles via `match_one`, so the triple can never drift) or `--obj`. A cheap necessary condition, NOT a match oracle — still finish on the byte-gate (G3/P9). |
+12
View File
@@ -33,6 +33,18 @@ python3 tools/draw_waves.py --only-main --prefix .run/<name>m_ --waves 1 --per-w
* **`--prefix` IS A RELATIVE PATH.** `--prefix s67o` writes `./s67o1/` into the repo root, not
`.run/`. Always pass `.run/<name>_`.
* **THE EXCLUDE LIST IS AUDITED BEFORE THE DRAW, AND A STALE ONE IS REFUSED (P31 S72).** Pass it as
`--exclude-file .run/<name>_exclude.txt`; `draw_waves` classifies every entry against the CURRENT
tooling and **exits non-zero** if any is stale, naming the count and the regenerate command.
`--exclude-stale-ok` still draws, but prints what it is ignoring — skipping is possible, never
silent. Regenerate with `tools/exclude_audit.py <old> --write <new>`.
> **Measured the day after `.run/S71_exclude.txt` was written: 88 of its 107 entries were stale**
> — 28 already banked, 14 linked PsyQ symbols that were never targets, and **46 whose blocker had
> since been fixed**. Those 46 are **12,750 instructions of open, drawable work**, including
> `main:SaveLoadRoutine` (1,165), the largest function left in main. A list that filters them out
> costs more than it saves. Current list: **`.run/S72_exclude.txt` (19 entries)**.
* **The exclude list is not optional.** It carries two populations that no model can bank:
* **96 jtbl functions whose carve plan `build_carve` REFUSES** (non-contiguous same-subseg
`.rodata`). Cookbook §322. Before the S67 probe fix these all read "carveable".
+38 -1
View File
@@ -137,12 +137,49 @@ def main():
ap.add_argument('--only-main', action='store_true', help='draw ONLY main (the main lane; implies --main)')
ap.add_argument('--ledger', default=LEDGER)
ap.add_argument('--exclude', default='', help='comma-separated binary:fn to skip')
ap.add_argument('--exclude-file', help='file of binary:fn to skip (# comments allowed); '
'audited for freshness before the draw')
ap.add_argument('--exclude-stale-ok', action='store_true',
help='draw anyway against a STALE exclude list (prints what it ignores)')
ap.add_argument('--dry', action='store_true')
a = ap.parse_args()
ledger = json.load(open(a.ledger)) if os.path.exists(a.ledger) else {}
drawn = {tuple(k.split(':', 1)) for k in ledger}
skip = {tuple(x.split(':', 1)) for x in a.exclude.split(',') if ':' in x}
# THE EXCLUDE LIST IS A PREREQUISITE, NOT A PARAMETER (P31 S72).
# An exclude list records what the TOOLING could not do at the moment it was written, and is
# then treated as a property of the FUNCTIONS. Nothing re-examined it, so every tool fix left
# behind a population that is now tractable and still marked impossible -- invisible, because
# the draw filters it out before anything measures it. Measured the day after `.run/
# S71_exclude.txt` was written: 88 of its 107 entries were stale (28 already banked, 14 linked
# PsyQ symbols that were never targets, 46 whose blocker had since been fixed) -- including
# SaveLoadRoutine, the single largest function left in main.
# So the draw AUDITS whatever exclusions it is handed and REFUSES on staleness. Skipping is
# still possible (--exclude-stale-ok) but can no longer be silent.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import exclude_audit as EA
ex_rows, ex_bad = EA.parse(text=a.exclude)
if a.exclude_file:
r2, b2 = EA.parse(path=a.exclude_file)
ex_rows += r2; ex_bad += b2
if ex_bad:
sys.exit(f"draw_waves: {len(ex_bad)} unparseable exclude entr(ies) — refusing rather than "
f"under-excluding (R43): {ex_bad[:4]}")
if ex_rows:
classified, _ = EA.classify(rows=ex_rows)
stale = [(b, f, k) for b, f, k, _ in classified if k in EA.STALE]
if stale:
by = collections.Counter(k for _, _, k in stale)
msg = (f"\n*** EXCLUDE LIST IS STALE — {len(stale)} of {len(classified)} entries no "
f"longer describe reality ({dict(by)}).\n"
f" e.g. " + ", ".join(f"{b}:{f}" for b, f, _ in stale[:6]) + "\n"
f" Regenerate: tools/exclude_audit.py <list> --write <new>\n"
f" Drawing against it silently filters out work that is now doable.")
if not a.exclude_stale_ok:
sys.exit(msg + "\n (--exclude-stale-ok to draw anyway)")
print(msg + "\n --exclude-stale-ok given: proceeding, and ignoring those entries.")
ex_rows = [(b, f) for b, f, k, _ in classified if k not in EA.STALE]
skip = set(ex_rows)
bins = sorted(os.path.basename(p) for p in glob.glob('src/*') if os.path.isdir(p))
if a.only_main:
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""exclude_audit.py -- classify every entry of a wave exclude list by its CURRENT blocker, and
regenerate the list so it never silently becomes "work we have decided not to do".
THE STANDING RULE THIS ENFORCES. An exclude list records **what the TOOLING could not do at the
moment it was written**, and is treated thereafter as a property of the FUNCTIONS. Nothing in the
pipeline re-examines it, so every tool improvement leaves behind a population that is now tractable
and still marked impossible -- invisible, because the draw filters it out before anything measures
it. S71 re-probed 68 such entries and found 17 newly carveable, banked 9, four of them in 57 seconds.
MEASURED ON `.run/S71_exclude.txt` AT P31 S72 (107 entries, written the day before):
28 BANKED -- already matched; the entry is dead
58 RE-PROBE/LINKED-- the blocker that put them here is GONE, or they were never targets
16 CARVE-BLOCKED -- still real: their subseg owns >1 raw jtbl span (split_indicator)
5 WALL -- a curated per-function compiler fact
i.e. **74 of 107 entries were wrong**, one day after the list was written.
THE CLASSES, and why each is decided the way it is:
* `BANKED` -- the `INCLUDE_ASM` stub is gone. The source is the oracle, not any ledger.
* `LINKED` -- the function lives in a PsyQ LINKED subseg. It is not a matching target at
all and `draw_waves` already refuses those, so the entry is pure noise.
* `CARVE-BLOCKED` -- it has a jump table and its subseg still owns raw tables in >= 2 non-adjacent
spans (`split_indicator`), so it genuinely cannot bank yet. KEEP.
* `RE-PROBE` -- it has a jump table and its subseg is now CLEAN. Whatever refused it has since
been fixed. **DROP, loudly** -- this is the population the rule exists for.
* `WALL` -- no jump table, so the jtbl machinery is not why it was excluded. A curated
per-function compiler fact (§188 epilogue, §332 `la`-in-delay-slot). KEEP,
because it cannot be re-derived here -- but it should carry its refutation
list so a later idea can be checked against it cheaply.
tools/exclude_audit.py .run/S71_exclude.txt # report
tools/exclude_audit.py .run/S71_exclude.txt --write OUT # regenerate (KEEP classes only)
tools/exclude_audit.py .run/S71_exclude.txt --assert-fresh # exit 3 if stale (the draw prereq)
"""
import argparse, collections, functools, os, re, sys
print = functools.partial(print, flush=True)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, 'tools'))
KEEP = ('CARVE-BLOCKED', 'WALL')
STALE = ('BANKED', 'LINKED', 'RE-PROBE')
def parse(path=None, text=None):
"""-> (rows, unparseable). Accepts a FILE or a comma/space-separated string, because
`draw_waves --exclude` takes the latter and the freshness prerequisite must cover both."""
src = open(path, errors='replace').read() if path else (text or '')
rows, bad = [], []
# STRIP COMMENTS PER LINE **BEFORE** SPLITTING ON COMMAS, never after. The regenerated list
# carries a `# CLASS: reason` note on every entry and prose in its header, and those contain
# commas -- splitting first turned `# ...compiler fact, not a tooling limit` into a second
# "entry" reading `not a tooling limit`. Caught only because this function REPORTS what it
# cannot parse instead of dropping it (R32); a silent parser would have quietly under-excluded.
for line in src.splitlines():
body = line.split('#', 1)[0]
for chunk in body.split(','):
s = chunk.strip()
if not s:
continue
if ':' not in s:
bad.append(s); continue
b, _, f = s.partition(':')
rows.append((b.strip(), f.strip()))
return rows, bad
def classify(path=None, rows=None):
"""-> ([(binary, fn, klass, note)], unparseable). Unparseable lines are reported, not dropped."""
import corpus, progress, split_indicator as SI
if rows is None:
rows, bad = parse(path=path)
else:
bad = []
by = collections.defaultdict(list)
for b, f in rows:
by[b].append(f)
blocked, linked_of, stubs_of = {}, {}, {}
for b in by:
try:
_st, lines = SI.check(b)
blocked[b] = {m.group(1) for l in lines
for m in [re.search(r'subseg `([^`]+)`', l)] if m}
except Exception:
blocked[b] = set()
try:
progress.set_binary(b); linked_of[b] = set(progress.linked_subsegs())
except Exception:
linked_of[b] = set()
try:
stubs_of[b] = {x.symbol: x for x in corpus.stubs(b).values()}
except Exception:
stubs_of[b] = None
out = []
for b, f in rows:
stubs = stubs_of.get(b)
if stubs is None:
out.append((b, f, 'WALL', 'binary not readable — kept, unverified')); continue
st = stubs.get(f)
if st is None:
out.append((b, f, 'BANKED', 'no INCLUDE_ASM stub in the source')); continue
sub = os.path.basename(os.path.dirname(st.asm_path))
if sub in linked_of.get(b, ()):
out.append((b, f, 'LINKED', f'subseg {sub} is linked PsyQ — never a target')); continue
txt = open(st.asm_path, errors='replace').read()
# §401: a jtbl function jumps through a register that is NOT $ra; `jr $ra` ends every function
if re.search(r'\bjr\s+\$(?!ra\b)\w+', txt):
if sub in blocked.get(b, ()):
out.append((b, f, 'CARVE-BLOCKED', f'{sub} owns >1 raw jtbl span — needs a split'))
else:
out.append((b, f, 'RE-PROBE', f'{sub} is clean now — the blocker is gone'))
else:
out.append((b, f, 'WALL', 'no jump table — curated compiler fact, not a tooling limit'))
return out, bad
def main():
ap = argparse.ArgumentParser()
ap.add_argument('path')
ap.add_argument('--write', metavar='OUT', help='regenerate, keeping only still-valid entries')
ap.add_argument('--assert-fresh', action='store_true',
help='exit 3 if any entry is stale (the draw-time prerequisite)')
a = ap.parse_args()
os.chdir(REPO)
rows, bad = classify(a.path)
cnt = collections.Counter(k for _, _, k, _ in rows)
print(f"{a.path}: {len(rows)} entries")
for k, v in cnt.most_common():
print(f" {v:4d} {k}{' (STALE)' if k in STALE else ''}")
if bad:
print(f" !! {len(bad)} unparseable line(s), kept out of the count: {bad[:3]}")
stale = [r for r in rows if r[2] in STALE]
for k in STALE:
ex = [f"{b}:{f}" for b, f, kk, _ in rows if kk == k]
if ex:
print(f"\n {k} ({len(ex)}): " + ", ".join(ex[:8]) + (" …" if len(ex) > 8 else ""))
if a.write:
keep = [r for r in rows if r[2] in KEEP]
with open(a.write, 'w') as fh:
fh.write(f"# regenerated by tools/exclude_audit.py from {a.path}\n"
f"# {len(keep)} still-valid of {len(rows)}; "
f"{len(stale)} dropped as stale (banked / linked / blocker-since-fixed).\n"
f"# An exclude list records what the TOOLING could not do — regenerate it as\n"
f"# part of every tool fix, or it becomes a list of work you decided not to do.\n")
for b, f, k, note in keep:
fh.write(f"{b}:{f} # {k}: {note}\n")
print(f"\nwrote {a.write}: {len(keep)} kept, {len(stale)} dropped")
if a.assert_fresh and stale:
print(f"\n*** STALE — {len(stale)} of {len(rows)} entries no longer describe reality. "
f"Regenerate with:\n tools/exclude_audit.py {a.path} --write <new>\n"
f"Drawing against this list would silently filter out work that is now doable.")
return 3
return 0
if __name__ == '__main__':
sys.exit(main())