mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 13:33:34 -04:00
505a50a9b6
bins is built from src/* DIRECTORIES, and main has no src/main/ — its TUs are top-level src/*.c. So "main" was never in the list, and the filter that keeps it could only ever preserve a "main" already present. --only-main worked solely because it overwrote the list; --main contributed nothing, in every mixed draw this project has ever run. The tool meanwhile printed "main: refusing 49 LINKED subseg(s)" whenever --main was passed, so it announced it was handling main while main was never iterated. A flag that changes nothing is worse than a missing flag: it answers the question you asked. Measured: 0 -> 55 main stubs reach the pool. This is why S76y's 47 main targets had to be assembled by hand from corpus.stubs — the draw could not see the actual frontier. Coverage is now ASSERTED (R32): --main with zero main stubs exits 4 and names itself a defect rather than reporting an empty population as a fact.
333 lines
19 KiB
Python
333 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""tools/draw_waves.py — draw N drafting waves off the OPEN frontier, cheapest-first (P31 S66).
|
|
|
|
Usage:
|
|
draw_waves.py --prefix .run/w --waves 2 --per-wave 110 [--max-nins 50] [--min-nins 0]
|
|
[--binaries ov_,md_] [--main | --only-main] [--dry]
|
|
[--exclude-file config/wave_exclude.txt] [--exclude-stale-ok]
|
|
NOTE: main is EXCLUDED by default (it has its own gate); --main adds it, --only-main draws
|
|
only it. There is no --no-main. --exclude-file AUDITS the list and REFUSES a stale one.
|
|
|
|
WHY (measured, P31 S66). The S65 checkpoint's tier map said "cheap singletons (3-17 ins) ~557 — the
|
|
bulk". 557 was the count of one-member FAMILIES, not of small functions: re-measured off
|
|
`corpus.stubs`, only 28 undrawn non-main stubs are <=17 ins, and the bulk is 51-120 (258) and >120
|
|
(245). A draw tool that reads the FRONTIER rather than a remembered tier count cannot inherit that
|
|
conflation (R33: derive, do not re-derive).
|
|
|
|
WHAT IT ASSERTS
|
|
* population = corpus.stubs (the R32-asserting oracle) over every binary under src/, minus main
|
|
unless --main, minus everything already in the draw ledger (.run/t5/drawn.json, keyed
|
|
"binary:fn" — R48).
|
|
* PACKS ARE NAME-KEYED, so a wave may not contain two functions with the same NAME even in
|
|
different binaries (`claude_wave_packs.py` refuses the whole wave otherwise, measured on r1).
|
|
Collisions are pushed to a LATER wave rather than dropped.
|
|
* every wave's targets.json is written in api_agent's target shape, and the ledger is appended
|
|
only for what is actually written (--dry writes nothing).
|
|
|
|
Then, per wave: t5_cards.py -> claude_wave_packs.py -> wave_args.py, and launch
|
|
tools/workflows/claude_wave_draft.js with the args wave_args.py printed (never hand-typed).
|
|
"""
|
|
import argparse, collections, glob, json, os, sys
|
|
import re
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
os.chdir(REPO)
|
|
sys.path.insert(0, 'tools')
|
|
import corpus
|
|
|
|
LEDGER = '.run/t5/drawn.json'
|
|
|
|
|
|
def arm_for(n):
|
|
"""Model tier for a target of n instructions.
|
|
|
|
RAISED AFTER P31 S69's MEASUREMENT (Drew, 2026-09-01) — the old `sonnet if n <= 120` rule was
|
|
costing MORE, not less, because a cheaper agent that fails 53% of the time is billed for every
|
|
failure. Measured over 129 drafting agents in one session, per MATCHED instruction:
|
|
|
|
sonnet 105 agents, 57 MATCH 4,289 tokens / matched instruction
|
|
opus 24 agents, 11 MATCH 2,083 (m1 band alone: 1,291)
|
|
|
|
Opus is 2.1x cheaper per banked instruction overall, 3.3x on its own band, while handling
|
|
functions 3-4x larger. Sonnet's per-agent price is not the cost that matters; cost per BANK is.
|
|
Sonnet held a flat ~47% above 30 instructions, so the band where it pays is genuinely small.
|
|
|
|
Do NOT re-derive a cheap-tier argument from per-agent price. It was tested (S68 A/B) and
|
|
re-measured (S69); escalating SOONER to a higher tier is the standing finding.
|
|
|
|
The TOP of the ladder was wrong too (Drew, 2026-09-01). S69 sent the 347-670 band to opus and it
|
|
returned the session's worst number by a wide margin:
|
|
|
|
m1 opus 191-347 ins 10/15 MATCH 1,291 tokens / matched instruction
|
|
m2 opus 347-670 ins 1/9 MATCH 7,158 <-- 5.5x worse, 2.92M tokens for ONE bank
|
|
|
|
Above ~350 instructions opus falls off a cliff; that band is Fable's, and Fable measured 3/4 on
|
|
the escalation lane the same session (plus S68's cold-start 85k/93k vs a sonnet median ~271k).
|
|
ESCALATE SOONER: the expensive mistake is running a cheaper tier into a wall, not paying the
|
|
higher tier up front.
|
|
|
|
DREW'S RULING, 2026-09-01, after reading the S69 numbers: NO MORE SONNET AT ALL. Two tiers only.
|
|
Not a threshold to re-tune from per-agent price — sonnet's 53% failure rate is billed in full.
|
|
|
|
THRESHOLD RAISED 150 -> 340 (Drew, 2026-09-02): "use opus mainly and only escalate the difficult
|
|
ones to fable". The S69 table above is the evidence, and it puts opus's cliff at ~350, NOT at 150:
|
|
|
|
m1 opus 191-347 ins 10/15 MATCH 1,291 tokens / matched instruction <-- BEST measured
|
|
m2 opus 347-670 ins 1/9 MATCH 7,158 <-- the cliff
|
|
|
|
So 150 was leaving opus's STRONGEST band to Fable. Fable is now reserved for the two things that
|
|
actually predict difficulty: >340 instructions, and `arm_from_history`'s compiler-internal
|
|
residual signal (§413) at any size. A function that FAILS on opus is re-drawn on opus by default —
|
|
a single failure is not evidence of a wall, and the history escalation will lift it if the notes
|
|
say the residual is compiler-internal.
|
|
"""
|
|
return 'opus' if n <= 340 else 'fable'
|
|
|
|
|
|
# THE RESIDUAL CLASS PREDICTS DIFFICULTY BETTER THAN `nins` DOES (Drew, 2026-09-02, S71).
|
|
# Measured over the S71 wave's own agent runs: wall-clock and iteration count track the RESIDUAL,
|
|
# not the size. A 26-instruction function took 18 minutes and 31 tool calls (`func_80181294`,
|
|
# still NEAR); a 122-instruction one took 80 seconds and 10 (`func_8017DB98`). The 20-30 minute
|
|
# runs were all compiler-internal residuals — scheduling ties, birthing boost, register colouring —
|
|
# where each hypothesis costs a compile-and-measure cycle:
|
|
#
|
|
# func_80185D44 47 ins opus 21 min 48 tool calls (LUID contradiction, read cc1 -dS)
|
|
# func_80185F4C 60 ins opus 22 min 33
|
|
# func_800D24D0 141 ins opus 33 min 51
|
|
#
|
|
# `arm_for` keys on size alone, so a 47-instruction regalloc wall was STRUCTURALLY unable to be
|
|
# drawn at the higher tier, and nothing escalates mid-run. Now that every pack carries the
|
|
# function's own history (§411), the prior residual class is known AT DRAW TIME — and 1,352 of the
|
|
# 3,147 functions with history (43%) have a note naming one of these classes.
|
|
_WALL_RE = re.compile(
|
|
r'permuter|regalloc|register (?:alloc|colou?ring|pressure)|schedule[- ]reorder|SCHEDULE-'
|
|
r'|birthing|LUID|sched1|sched2|scheduler-internal|cross_?jump|delay[- ]slot|colou?ring',
|
|
re.I)
|
|
|
|
|
|
def arm_from_history(binary, fn, n, _cache={}):
|
|
"""`fable` when this function's own journal history names a compiler-internal residual.
|
|
|
|
Escalating SOONER is the standing finding (see arm_for); this applies it to the axis that
|
|
actually predicts cost. Falls back to the size ladder when there is no history, and never
|
|
DOWNGRADES what the size ladder chose."""
|
|
if not _cache:
|
|
try:
|
|
sys.path.insert(0, os.path.join(REPO, 'tools'))
|
|
import journal_notes
|
|
_cache['idx'] = journal_notes.load()
|
|
_cache['mod'] = journal_notes
|
|
except Exception:
|
|
_cache['idx'] = None
|
|
base = arm_for(n)
|
|
idx = _cache.get('idx')
|
|
if not idx or base == 'fable':
|
|
return base
|
|
rows = _cache['mod'].notes_for(idx, binary, fn)
|
|
if rows and _WALL_RE.search(" ".join(r.get('note') or '' for r in rows)):
|
|
return 'fable'
|
|
return base
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--prefix', required=True, help='wave dir prefix; waves are <prefix>1, <prefix>2, ...')
|
|
ap.add_argument('--waves', type=int, default=1)
|
|
ap.add_argument('--per-wave', type=int, default=100)
|
|
ap.add_argument('--min-nins', type=int, default=0)
|
|
ap.add_argument('--max-nins', type=int, default=10 ** 9)
|
|
ap.add_argument('--main', action='store_true', help='draw from main (default: excluded — main has its own lane, R43)')
|
|
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('--redraw-open', action='store_true',
|
|
help='also draw open stubs the ledger has already seen. The ledger records what was ATTEMPTED; a stub still open is still unbanked work.')
|
|
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}
|
|
# 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]
|
|
# NORMALISE TO (binary, fn). `exclude_audit.parse` returns 4-tuples (it carries the WALL pin
|
|
# and the entry's note), and the membership tests below are 2-tuples — building `skip` from the
|
|
# raw rows silently matched NOTHING and excluded NOTHING while reporting success. Caught by
|
|
# measuring the pool instead of trusting the run: it came back 88+45 = the FULL frontier.
|
|
skip = {(b, f) for b, f, *_ in ex_rows}
|
|
|
|
# `main` HAS NO src/main/ DIRECTORY — its TUs are TOP-LEVEL src/*.c (P31 S76).
|
|
# This list is built from src/* DIRECTORIES, so 'main' was never in it, and the filter below
|
|
# could only ever KEEP a 'main' that was already present. `--main` was therefore a NO-OP: every
|
|
# mixed draw in this project's history contributed ZERO main functions, while the tool printed
|
|
# 'main: refusing 49 LINKED subseg(s)' and looked like it was doing the work. `--only-main`
|
|
# worked purely because it overwrote the list. Measured: 59 open main stubs — the actual
|
|
# frontier, and 47 of the 50 functions the S76y wave had to be assembled by hand from
|
|
# corpus.stubs because the draw could not see them.
|
|
# A flag that changes nothing is worse than a missing flag: it answers the question you asked.
|
|
bins = sorted(os.path.basename(p) for p in glob.glob('src/*') if os.path.isdir(p))
|
|
if a.only_main:
|
|
a.main = True
|
|
bins = [b for b in bins if b != 'shared' and b != 'main']
|
|
if a.main:
|
|
bins.append('main') # ADD it; it is never in the directory listing
|
|
if a.only_main:
|
|
bins = ['main']
|
|
|
|
# LINKED SUBSEGS ARE REFUSED, NOT MERELY AVOIDED (P31 S66 — R43/R34).
|
|
# 960 of main's 1,099 INCLUDE_ASM lines live in the 49 subsegs whose TUs the linker script never
|
|
# references; the bytes come from linked PsyQ SDK objects and are already byte-identical. But
|
|
# Makefile:595 globs every src/*.c into OBJS, so those TUs ARE still compiled -- as unplaced
|
|
# inputs. Therefore ANY C written into one of them compiles, links, and leaves the SHA1 green
|
|
# WHETHER OR NOT IT IS CORRECT: a wave drawn from the raw 1,099 would mint up to 960 gate-green
|
|
# FALSE MATCHES, and the whole-binary byte gate -- our sole arbiter (G3/P9) -- is structurally
|
|
# blind to it. progress.linked_subsegs() derives the set from the Makefile's own psyq_integrate
|
|
# calls, so it tracks the live link, not a hardcoded list (the reduction is machine-local:
|
|
# .run/obj40 is gitignored, and on a fresh clone those stubs really ARE the link path).
|
|
linked = set()
|
|
if a.main:
|
|
import progress
|
|
progress.set_binary('main')
|
|
linked = set(progress.LINKED_SEGS)
|
|
print('main: refusing %d LINKED subseg(s) — their INCLUDE_ASM is dead text and a draft there '
|
|
'would gate GREEN while wrong' % len(linked), file=sys.stderr)
|
|
|
|
pool, refused = [], []
|
|
|
|
redrawable = []
|
|
for b in bins:
|
|
try:
|
|
st = corpus.stubs(b)
|
|
except Exception as e: # a refusing oracle is EXCLUDED LOUDLY (R32)
|
|
refused.append((b, str(e)[:80])); continue
|
|
for _, s in st.items():
|
|
if b == 'main' and s.region in linked:
|
|
continue
|
|
n = corpus.s_ins_count(s.asm_path)
|
|
if not (a.min_nins <= n <= a.max_nins):
|
|
continue
|
|
if (b, s.symbol) in skip:
|
|
continue
|
|
if (b, s.symbol) in drawn:
|
|
# THE LEDGER RECORDS WHAT WAS ATTEMPTED, NOT A PROPERTY OF THE FUNCTION (P31 S76).
|
|
# This is the exclude-list lesson (S72) in a second place: a stub that is STILL
|
|
# OPEN after being drawn is, by definition, unbanked work — the draw failed, or
|
|
# the draft was never gated, or the blocker has since been fixed. Filtering it
|
|
# forever means the frontier shrinks to nothing while the work remains.
|
|
# Measured here: after two S76 draws the tool reported `population: 0` with 51
|
|
# open stubs on disk — a TRUE number about a scope far narrower than the reader
|
|
# believes, the session's dominant defect class (`silently-narrowed-tool-scope`).
|
|
redrawable.append((b, s.symbol))
|
|
if not a.redraw_open:
|
|
continue
|
|
pool.append(dict(name=s.symbol, addr='0x%08x' % s.addr, nins=n, binary=b,
|
|
sub=s.asm_dir, asm=s.asm_path, tu=s.path,
|
|
cls='FRONTIER', arm=arm_from_history(b, s.symbol, n), **{'from': 'draw_waves'}))
|
|
pool.sort(key=lambda t: (t['nins'], t['binary'], t['name']))
|
|
|
|
if a.main:
|
|
_mainpool = sum(1 for t in pool if t['binary'] == 'main')
|
|
_mainseen = sum(1 for b, _f in redrawable if b == 'main') + _mainpool
|
|
if not _mainseen:
|
|
print('*** draw_waves: --main was requested and main contributed ZERO stubs. That is a '
|
|
'DEFECT, not a fact — main had 59 open stubs when this assertion was written. '
|
|
'Do not draw against this pool.', file=sys.stderr)
|
|
sys.exit(4)
|
|
print(' main: %d stub(s) reached the pool (%d seen incl. ledgered)'
|
|
% (_mainpool, _mainseen), file=sys.stderr)
|
|
print('population: %d open stub(s) in [%d,%d] ins, %s, over %d binaries (%d oracle refusals: %s)'
|
|
% (len(pool), a.min_nins, a.max_nins,
|
|
'undrawn + previously-drawn' if a.redraw_open else 'undrawn',
|
|
len(bins), len(refused), refused[:2]), file=sys.stderr)
|
|
# NEVER REPORT A POPULATION WITHOUT WHAT IT EXCLUDES (R41). An empty pool beside a non-empty
|
|
# frontier reads as "the work is done"; it means "the ledger has seen them all".
|
|
if redrawable and not a.redraw_open:
|
|
print(' NOTE: %d further open stub(s) were filtered ONLY because the ledger has seen them '
|
|
'before — they are still unbanked work. Pass --redraw-open to include them.%s'
|
|
% (len(redrawable), '' if pool else
|
|
' <-- the pool is EMPTY for this reason alone; the frontier is NOT.'),
|
|
file=sys.stderr)
|
|
|
|
waves, cur, used_names = [], [], set()
|
|
deferred = []
|
|
for t in pool:
|
|
if len(waves) == a.waves and len(cur) >= a.per_wave:
|
|
break
|
|
if t['name'] in used_names: # name-keyed packs: push the collision later
|
|
deferred.append(t); continue
|
|
cur.append(t); used_names.add(t['name'])
|
|
if len(cur) >= a.per_wave:
|
|
waves.append(cur); cur, used_names = [], set()
|
|
# a deferred collision can go in the NEXT wave
|
|
keep = []
|
|
for d in deferred:
|
|
if len(cur) < a.per_wave and d['name'] not in used_names:
|
|
cur.append(d); used_names.add(d['name'])
|
|
else:
|
|
keep.append(d)
|
|
deferred = keep
|
|
if len(waves) >= a.waves:
|
|
break
|
|
if cur and len(waves) < a.waves:
|
|
waves.append(cur)
|
|
|
|
total = sum(len(w) for w in waves)
|
|
print('drawing %d wave(s), %d target(s) total (%d name-collision(s) deferred, %d left in pool)'
|
|
% (len(waves), total, len(deferred), len(pool) - total), file=sys.stderr)
|
|
|
|
for i, w in enumerate(waves, 1):
|
|
d = '%s%d' % (a.prefix, i)
|
|
band = collections.Counter('<=17' if t['nins'] <= 17 else '18-50' if t['nins'] <= 50
|
|
else '51-120' if t['nins'] <= 120 else '>120' for t in w)
|
|
arms = collections.Counter(t['arm'] for t in w)
|
|
print(' %s: %d target(s) bands=%s arms=%s nins %d..%d'
|
|
% (d, len(w), dict(band), dict(arms), w[0]['nins'], w[-1]['nins']), file=sys.stderr)
|
|
if a.dry:
|
|
continue
|
|
os.makedirs(d, exist_ok=True)
|
|
json.dump(w, open(os.path.join(d, 'targets.json'), 'w'), indent=1)
|
|
for t in w:
|
|
ledger['%s:%s' % (t['binary'], t['name'])] = {'wave': d, 'nins': t['nins'], 'arm': t['arm']}
|
|
if not a.dry:
|
|
json.dump(ledger, open(a.ledger, 'w'), indent=0)
|
|
print('ledger: %d key(s)' % len(ledger), file=sys.stderr)
|
|
print(' '.join('%s%d' % (a.prefix, i) for i in range(1, len(waves) + 1)))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|