Files
Drew T 505a50a9b6 fix(draw_waves): --main was a no-op; every mixed draw saw ZERO main functions
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.
2026-09-03 14:47:46 -06:00

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()