Files
BFM-decomp/tools/scope_demote_drafts.py
Drew T c4380c19e4 feat(p31 s67): stranded-draft census + honest jtbl probe — the frontier's biggest class is carve plumbing
MEASURED (denominators in .run/S67_findings.md):
* 193 of the 530 open functions ALREADY have a draft on disk (1,885 wave targets seen,
  1,521 banked, 171 open-no-draft, 166 never drawn). Classified in their real TUs:
  37 MATCH / 67 NEAR / 89 CC1-FAIL.
* 159 open functions (30% of the frontier) reference a jump table; 96 are PLAN-REFUSED
  by build_carve (non-contiguous same-subseg .rodata), 75 non-main across 38 subsegs.
  Not a codegen wall and not a decl wall — carve plumbing.

NEW
* tools/strand_census.py — coverage-asserted census + rtu_match classifier + draft staging.
  Keys binary:fn (R48); classifies each pair once after merging every manifest's view.
* tools/o0_detect.py — the -O0 prologue tell extracted from match_one (which parses argv at
  import and therefore cannot be imported). match_one re-exports it; ONE definition (R33).
  Wiring it into the classifier turned md_MAIN_003 from 8 NEAR (7 of them >20) into 6 MATCH.
  Negative-controlled both directions.
* tools/scope_demote_drafts.py — §8d as an _xform-contract gate rung. NOT yet exercised.

FIXED
* jtbl_carve --probe now runs build_carve (a pure planner) and reports plan-refused. It
  previously called only island_probe, which answers a necessary-not-sufficient question —
  every blocked function probed "carveable", and S66 priced 32 of them as free on that.
* blocker_probe.macro_scope selects the LAST #define per macro name, matching cpp.
  engine_core.h has 1,037 duplicate DEFINE_func_ names and 4 with DIFFERENT bodies.

NOT VALIDATED — DO NOT SCALE
* jr_isolate_all: two real defects fixed (carried types deduped by name; header-provided
  types no longer re-emitted) but ov_SC02_000 STILL fails the byte gate after them.
  Open lead: file_scope_types carries a block without its enclosing #if guard. 20 of 35
  blocked overlays dry-run clean and that number means nothing until one round-trips.

0 functions banked this session. tools-health has ONE pre-existing cdecl defect
(1 of 74,749 declarations, func_8017EE08_p55352/struct ZnRec) — cdecl.py and its inputs
are byte-identical to HEAD, so it is not from this change.

Knowledge banked: cookbook §322/§323/§323a/§323b, decision-log pivot, accelerators #13/#14.
2026-08-31 01:46:16 -06:00

145 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""scope_demote_drafts.py — the §8d scope-demote, as a gate-ladder rung. (P31 S67, W3a)
THE GAP THIS CLOSES. `scope_data_externs.fix()` is byte-proven and already used by the FAMILY
lanes (`family_sweep`, `bank_exemplar`, `jtbl_family_bank`) — but nothing in `gate_stage`'s recovery
ladder ever called it, so a draft written by a wave AGENT never saw it. The S66 free-wins audit
measured the cost: of 193 stranded drafts, 89 die in cc1, and 74 of those die on `conflicting types`
— the exact error §8d exists to remove. One byte-proven instance from that audit:
`ov_SC04_018:func_80181270`, whose fix() output goes straight to an rtu MATCH with no hand edit.
WHAT IT DOES. For each draft in `--in`, demote the file-scope DATA externs the target TU does not
already declare at file scope down to block scope (and auto-alias a decl whose type disagrees with
the TU's). A loosely-typed engine declares one symbol several incompatible ways across a TU, and C
tolerates that at BLOCK scope while rejecting it at file scope — so the draft must not establish a
global the TU never had. See scope_data_externs' own docstring for the byte-proven asymmetry.
WHY A SEPARATE TOOL rather than a branch inside an existing rung: the ladder's contract is
"a no-op-safe draft-dir -> draft-dir rewrite, and the whole-binary byte-gate is the sole arbiter"
(G3/P9). A rung that can only ever delete a conflicting global or leave the draft untouched fits
that contract exactly, and keeping it separate means the gate's own `_xform` clears its output dir
and can drop the rung with one line if it ever regresses a bank.
TU RESOLUTION IS DERIVED, NEVER ASSUMED (the `tu_for` lesson): the draft's home TU comes from the
corpus' INCLUDE_ASM line, so a draft that lands in a `_jr_<ADDR>` / `_o0` carve file is reconciled
against the file that will actually compile it.
tools/scope_demote_drafts.py --overlay ov_SC04_018 --in .run/d --out .run/d-sd
"""
import argparse
import glob
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cdecl # noqa: E402 the coverage-asserting decl parser
import corpus # noqa: E402
import scope_data_externs as SDE # noqa: E402 its decl-text reader
from scope_data_externs import fix as scope_data_fix # noqa: E402 §8d, one definition (R33)
_TU_DATA_TYPES = {}
def _tu_data_types(tu_path):
"""{D_sym -> declared type text} AS CC1 SEES IT (cdecl.tu_scope, i.e. cpp), cached per TU.
Same call family_sweep makes (its `_tu_data_types`), for the same reason: a text scan cannot see
a MACRO-INJECTED declaration, and that is exactly where these conflicts live — `extern Vec8
D_80114F24;` sits inside a DEFINE_func_* macro body in engine_core.h while the overlay .c holds
only `DEFINE_func_XXXX()`. The FULL scope is the right question (compatibility is order-
independent in C), not the `above=` visibility form."""
if tu_path not in _TU_DATA_TYPES:
try:
sc = cdecl.tu_scope(tu_path)
_TU_DATA_TYPES[tu_path] = {n: SDE._decl_type_text(d.text or "", n)
for n, d in sc.items()
if n.startswith("D_") and d.text
and SDE._decl_type_text(d.text or "", n)}
except Exception as e: # loud, never fatal (R32)
print(" [tu-types] %s: %s" % (tu_path, repr(e)[:80]), flush=True)
_TU_DATA_TYPES[tu_path] = {}
return _TU_DATA_TYPES[tu_path]
def tu_for(binary, fn, override=None):
"""The TU this draft will be spliced into — derived from the corpus, never defaulted.
A CorpusError PROPAGATES: falling back to `src/<bin>/<bin>.c` on an oracle failure is how the
recovery passes ended up reconciling against a different translation unit than the one that
compiles the code (see cast_call_sites.tu_for)."""
if override:
return override if os.path.isabs(override) else os.path.join(REPO, override)
try:
addr = int(fn[5:], 16)
except ValueError:
return os.path.join(REPO, "src/%s/%s.c" % (binary, binary))
st = corpus.stubs(binary).get(addr)
if st is None:
return None
return os.path.join(REPO, st.path)
def _stub_pos(tu_text, fn):
"""Char offset of this function's INCLUDE_ASM stub — the splice point fix() measures from."""
m = re.search(r'INCLUDE_ASM\("[^"]*",\s*%s\);' % re.escape(fn), tu_text)
return m.start() if m else None
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--overlay", required=True, help="the binary these drafts belong to")
ap.add_argument("--in", dest="indir", required=True)
ap.add_argument("--out", dest="outdir", required=True)
ap.add_argument("--src-file", dest="src_file", default=None,
help="force the target TU (default: derived per draft from the corpus)")
ap.add_argument("-v", "--verbose", action="store_true")
a = ap.parse_args()
os.makedirs(os.path.join(REPO, a.outdir), exist_ok=True)
drafts = touched = moved_total = 0
no_tu = no_stub = 0
for p in sorted(glob.glob(os.path.join(REPO, a.indir, "*.c"))):
fn = os.path.basename(p)[:-2]
body = open(p).read()
out_body = body
tu = tu_for(a.overlay, fn, a.src_file)
if tu and os.path.exists(tu):
tu_text = open(tu).read()
pos = _stub_pos(tu_text, fn)
if pos is None:
# Already spliced, or a curated name — nothing to measure "above" from. Carry the
# draft through unchanged rather than guessing a position (R43: a rung that cannot
# handle an input passes it on intact; it never half-transforms it).
no_stub += 1
else:
# cdecl.tu_scope sees MACRO-INJECTED file-scope decls (`extern Vec8 D_x;` inside a
# DEFINE_func_* body in engine_core.h) that a text scan cannot; fix() falls back to
# its own text scan when this is absent.
tu_types = _tu_data_types(tu)
new, mv = scope_data_fix(body, tu_text, pos, fn, tu_types)
out_body = new
if new != body:
touched += 1
moved_total += len(mv)
if a.verbose:
print(" %s: demoted %d %s" % (fn, len(mv), mv))
else:
no_tu += 1
open(os.path.join(REPO, a.outdir, os.path.basename(p)), "w").write(out_body)
drafts += 1
# R32: the denominators. A rung that transforms 0 of N must say so — "no-op" and "never ran"
# are indistinguishable in a gate log otherwise, and that ambiguity is what this ladder exists
# to remove.
print("scope-demote: drafts %d · rewritten %d · symbols demoted %d · no-TU %d · no-stub %d"
% (drafts, touched, moved_total, no_tu, no_stub))
return 0
if __name__ == "__main__":
sys.exit(main())