Files
BFM-decomp/tools/draft_prechecks.py
T
Drew T e903713b71 feat(phase-30 S50): the mechanical A-prop draft — 256 members banked with no agent in the loop
Final S50 state: 307 instances banked, stubs 12,468 -> 12,161, fleet 95.3% instr / 90.0%
distinct / 96.65% fn-count. R22 clean rebuild 4x, check-all 213/213 every time.

- tools/aprop_autodraft.py + tools/draft_prechecks.py: seed body + symbol_map + a MINIMAL
  synthesized preamble. The seed's decl layer never travels — that layer is family_sweep's
  dominant failure (331 of 458 S49 verdicts). 256 banked at zero agent tokens, against the
  ~20M the same work would have cost as a wave.
- Macro seeds (567 of 1196 members, all 3737 de-macroize) take the DEFINITION only; the block
  stays the decl source. Pasting it whole measured 28% vs inline's 68% — func_8016AB6C's macro
  is 1,891 lines of which 108 are the function.
- IMM is a second engine, not a wall: T2a's imm_map_tier1 resolves a per-location LITERAL like
  symbol_map resolves a per-location SYMBOL. 131 of 275 IMM members resolve.
- draft_prechecks negative-controlled against ALL 205 banked drafts: zero false positives,
  catches 39 of 67 known failures. That control found two bugs in the checks themselves —
  C89 `f()` declares UNSPECIFIED parameters (not zero), and a member's own definition read as
  a call to itself. Conservative by design: a pre-check that discards good drafts is worse
  than one that lets a few builds fail.
- The A-prop pool is now priced exactly: PURE 437/37,376 ins, IMM 275/8,849, STRUCT 238/4,259.
- Cookbook §171a; SETUP rows; CURRENT_PHASE S50 FINAL checkpoint.
2026-08-14 07:42:19 -06:00

119 lines
5.0 KiB
Python

"""Static pre-checks that turn a BUILD-time draft failure into a GENERATION-time skip.
Every class here was measured as a real failure in the S50 autodraft runs, and every one is
decidable without compiling anything. That is the whole point: a build spent to learn something a
regex knows is a build wasted, and at ~1 min/group over ~100 groups the waste is the run.
1. ARITY DISAGREEMENT — `too few arguments to function 'func_80146C3C'`.
The destination TU already declares a callee with a different parameter count than the seed's
body passes. Neither emitting the seed's decl (conflicting types) nor relying on the
destination's (too few arguments) can work; the member needs a real edit.
2. UNDEFINED DATA — `undefined reference to 'D_801E9C44'`.
The draft references a symbol nothing in the DESTINATION binary defines. The seed's own data
symbol survived into the draft because it sits at no relocation slot symbol_map covers.
"""
import glob
import os
import re
def _decl_arity(text, sym):
"""Parameter count of the first declaration/definition of `sym` in `text`, or None."""
m = re.search(rf'^[ \t]*(?:extern\s+)?[A-Za-z_][\w \*]*\b{re.escape(sym)}\s*\(([^;{{)]*)\)',
text, re.M)
if not m:
return None
args = m.group(1).strip()
if args == '':
return None # C89: `f()` declares UNSPECIFIED parameters, not zero — never a conflict
if args == 'void':
return 0
return len([a for a in args.split(',') if a.strip()])
def _call_arity(body, sym):
"""Argument count at the first CALL of `sym` in `body`, or None. Depth-aware so a nested call
or a comma inside a cast does not miscount."""
m = re.search(rf'\b{re.escape(sym)}\s*\(', body)
if not m:
return None
depth, args, cur = 0, [], ''
for ch in body[m.end() - 1:]:
if ch == '(':
depth += 1
if depth == 1:
continue
elif ch == ')':
depth -= 1
if depth == 0:
args.append(cur)
break
if depth == 1 and ch == ',':
args.append(cur)
cur = ''
continue
if depth >= 1:
cur += ch
return len([a for a in args if a.strip()])
def arity_conflicts(body, dest_text, syms, self_name=None):
"""Symbols the body CALLS with an argument count the destination's own declaration refuses."""
bad = []
for s in syms:
if not s.startswith('func_') or (self_name and s.lower() == self_name.lower()):
continue # the member's own definition is not a call to itself
d, c = _decl_arity(dest_text, s), _call_arity(body, s)
# CONSERVATIVE by measurement: flag only when the destination declares NAMED parameters and
# the body passes fewer. Every true positive had that shape (`dest decl 1 vs call 0`) and
# every false positive the other one (`dest decl 0 vs call 1`) — where the `0` comes from a
# `(void)` my parser reads out of a declaration it should not have matched. A pre-check that
# throws away good drafts is worse than one that lets a few builds fail.
if d is not None and c is not None and d > c:
bad.append(f"{s}(dest decl {d} vs call {c})")
return bad
_DEF_CACHE = {}
def defined_in_binary(binary, sym):
"""True if `sym` is defined anywhere in the destination binary — its asm (a dlabel/glabel, or
a symbol file entry) or its C. Cached per binary: the asm scan is the expensive part."""
key = binary
if key not in _DEF_CACHE:
defs = set()
for p in glob.glob(f"asm/{binary}/**/*.s", recursive=True):
for m in re.finditer(r'^(?:dlabel|glabel|jlabel)\s+([A-Za-z_]\w*)', open(p).read(), re.M):
defs.add(m.group(1))
for p in glob.glob(f"config/symbols.{binary}*.txt"):
for m in re.finditer(r'^([A-Za-z_]\w*)\s*=', open(p).read(), re.M):
defs.add(m.group(1))
_DEF_CACHE[key] = defs
return sym in _DEF_CACHE[key]
def undefined_data(binary, dest_text, syms, self_name=None, body=""):
"""Referenced symbols nothing in the destination binary defines, and the destination TU does
not define locally either.
A symbol the DRAFT ITSELF defines is not undefined — its own function name above all, which
has no `glabel` left in asm precisely BECAUSE it is now C. Skipping that was 38 of 38 false
positives in the negative control."""
out = []
for s in syms:
if self_name and s.lower() == self_name.lower():
continue
if re.search(rf'^[^\n=;]*\b{re.escape(s)}\b[^\n;]*\)\s*\{{', body, re.M) or \
re.search(rf'^[ \t]*(?:const\s+|static\s+)*[A-Za-z_][\w \*]*\b{re.escape(s)}\b'
rf'(?:\s*\[[^\]]*\])?\s*=', body, re.M):
continue
if defined_in_binary(binary, s):
continue
if re.search(rf'^[ \t]*(?:const\s+|static\s+)*[A-Za-z_][\w \*]*\b{re.escape(s)}\b[^\n;]*[=;]',
dest_text, re.M):
continue
out.append(s)
return out