diff --git a/docs/SETUP.md b/docs/SETUP.md index 2d2f90e365..5f473d13e9 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -736,6 +736,7 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo | | `tools/stub_invariant_audit.py` | **(Phase 31 S62 T1, cookbook §301)** Regression test for the masked comparer: INCLUDE_ASM pastes the original bytes, so `diff_object_s(object, .s)` must be 0 for every stub; quotes the denominator (stubs with internal-`j` relocs). Run after ANY change to `masked_diff.py`. | | | `tools/interleave_check.py` | **(Phase 31 S62 T2, cookbook §302)** Order ⇔ yaml consistency for an overlay's jtbl carve: the `_JTBL_INTERLEAVE` order must equal the yaml subseg sequence position by position. `--fix` regenerates the order from the yaml (never hand-edit it). Run after ANY restore of overlays.mk or a splat yaml (two of the five S61 reds and four of the six clean-sweep failures were exactly this). | | | `tools/pads_audit.py` | **(Phase 31 S62 T2, cookbook §302)** DERIVES each object's `JTBL_PADS` from the bytes: compiles the TU to count its tables, walks the retail words inside the yaml carve with those sizes (pad 4 ⇔ one zero word), reports SPEC-DRIFT / CARVE-DRIFT / ok with the derived spec. Reproduced 15/15 correct specs as its negative control; replaced S61's blind value searches. | +| | `tools/claude_wave_packs.py` · `tools/workflows/claude_wave_draft.js` · `tools/wave_judge.py` | **(Phase 31 S62 T4)** The Claude-subagent wave harness that ran the model-ladder probe: identical packs from `api_agent` (SYS + user_msg + law-1c warm start + gate feedback) → a Workflow of drafting agents per (arm, fn) with CLI equivalents of grep/read_file/match_one/submit, writing only under `.run///` → `wave_judge.py` gates each arm with `sweep_parallel` (banked = removed INCLUDE_ASM lines in `git diff`; tree reset between arms; `--union` leaves the cheapest winning drafts in the tree). `tools/workflows/claude_wave_distill.js` = the R16 distill (extract per byte-proven transcript → verify vs the cookbook). Routing rule from T4: ≤50 Sonnet, 51–120 Sonnet, >120 Opus, Opus on residue; haiku retired; DeepSeek optional at ~$0.03/fn. | | | `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 ` for every `md_*` object — 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. | | | `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). | diff --git a/tools/claude_wave_packs.py b/tools/claude_wave_packs.py new file mode 100644 index 0000000000..49a8f17373 --- /dev/null +++ b/tools/claude_wave_packs.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""tools/claude_wave_packs.py — build the per-function PACKS a Claude-subagent wave drafts from (P31 S62 T4). + +The pack is EXACTLY what tools/api_agent.py sends a paid model: api_agent.user_msg(target, card) + +prior_draft (law-1c filtered: symbols must overlap the target .s) + gate_feedback, plus api_agent.SYS +written once. Identical packs are what made the T4 arms comparable; keep it that way. + +Usage: claude_wave_packs.py [--cards cards.json] + targets.json: [{name, binary, nins, sub, asm, tu}, ...] (api_agent's target shape; see + tools/main_queue_rebuild.py / .run/t4/targets.json for how to derive one from corpus) + out_dir/SYS.md + out_dir/packs/.md are produced; the workflow script + tools/workflows/claude_wave_draft.js points its agents at them (edit REPO/paths at the top). +Then: Workflow(scriptPath=tools/workflows/claude_wave_draft.js, args={targets:[{name,binary,nins,sub}...]}) + -> drafts in .run///.c -> tools/wave_judge.py [--union] (whole-binary gate, + banked = removed INCLUDE_ASM lines in git diff; tree reset between arms; --union leaves the best + cheapest arm's drafts in the tree for the commit). Routing rule measured in T4 (CURRENT_PHASE S62): + <=50 ins Sonnet (+DeepSeek if funded), 51-120 Sonnet, >120 Opus, Opus on every residue.""" +import sys, os, json, glob +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))); os.chdir(REPO) +sys.path.insert(0, 'tools') +for k, v in (('API_BASE', 'x'), ('API_KEY', 'x'), ('MODEL', 'x'), ('MAXTOK', '8000')): + os.environ.setdefault(k, v) +import api_agent as A + +def main(): + if len(sys.argv) < 3: + sys.exit(__doc__) + targets = json.load(open(sys.argv[1])); out = sys.argv[2] + cards = {} + if '--cards' in sys.argv: + for c in json.load(open(sys.argv[sys.argv.index('--cards') + 1])): + if c.get('fn'): cards[c['fn']] = c + else: # newest per-fn card across every wave card file + for f in sorted(glob.glob('.run/wave_*_cards.json'), key=os.path.getmtime): + try: + for e in json.load(open(f)): + if isinstance(e, dict) and e.get('fn'): cards[e['fn']] = e + except Exception: + pass + os.makedirs(os.path.join(out, 'packs'), exist_ok=True) + open(os.path.join(out, 'SYS.md'), 'w').write(A.SYS) + n = 0 + for t in targets: + um = A.user_msg(t, cards.get(t['name'])) + pd, where = A.prior_draft(t) + if pd: + um += ("\n\nA PREVIOUS ATTEMPT on this exact function left this body behind (%s). The whole-binary " + "byte-gate did NOT accept it, so it is wrong somewhere — but it is usually wrong in ONE place; " + "keep what matches the .s, fix what does not. Discard it if it is a different function's body." + "\n\n```c\n" % where) + pd.strip() + "\n```\n" + gf = A.gate_feedback(t) + if gf: + um += '\n\n' + gf + '\n' + open(os.path.join(out, 'packs', t['name'] + '.md'), 'w').write(um); n += 1 + print('packs: %d written to %s (cards matched %d/%d)' % (n, out, sum(1 for t in targets if t['name'] in cards), len(targets))) + +if __name__ == '__main__': + main() diff --git a/tools/wave_judge.py b/tools/wave_judge.py new file mode 100644 index 0000000000..3f1dcac052 --- /dev/null +++ b/tools/wave_judge.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""T4 judge — per-arm whole-binary gate (G3/P9, exit codes checked per R53), tree reset between arms, +then the union (best arm per fn) gated once and left in the tree for the commit. Reads targets from +.run/t4/targets.json and drafts from .run/t4//.c. Writes .run/t4/judge.json. +Usage: judge.py [arms...] (default haiku sonnet opus ds)""" +import sys, os, json, glob, subprocess, shutil, time +REPO = '/home/musashi/bfm-decomp'; os.chdir(REPO) +sys.path.insert(0, 'tools'); import corpus +UNION = '--union' in sys.argv +ARMS = [a for a in sys.argv[1:] if not a.startswith('--')] or ['haiku', 'sonnet', 'opus', 'ds'] +targets = json.load(open('.run/t4/targets.json')) +T = {t['name']: t for t in targets} +def sh(cmd, timeout=3600): + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) +def open_fns(): + out = {} + for b in {t['binary'] for t in targets}: + s = corpus.stubs(b); out[b] = {x.symbol for x in (s.values() if isinstance(s, dict) else s)} + return out +def reset_tree(): + sh(['git', 'checkout', '-q', '--', 'src/', 'config/']); sh(['git', 'clean', '-fq', 'src/']) + r = sh(['git', 'status', '--porcelain', '--', 'src/', 'config/']) + assert not r.stdout.strip(), 'tree not clean after reset:\n' + r.stdout +def stage(arm, fns): + d = '.run/t4/stage_%s' % arm; shutil.rmtree(d, ignore_errors=True) + n = 0 + for fn in fns: + src = '.run/t4/%s/%s.c' % (arm, fn) + if not os.path.exists(src) or os.path.getsize(src) == 0: continue + b = T[fn]['binary']; os.makedirs('%s/%s' % (d, b), exist_ok=True) + shutil.copy(src, '%s/%s/%s.c' % (d, b, fn)); n += 1 + return d, n +def gate(arm, fns): + before = open_fns() + d, n = stage(arm, fns) + if n == 0: return {}, 'no drafts' + bins = ','.join(sorted({T[f]['binary'] for f in fns if os.path.exists('.run/t4/%s/%s.c' % (arm, f))})) + t0 = time.time() + r = sh(['flock', '.run/auto/draw.lock', '.venv/bin/python', 'tools/sweep_parallel.py', '--drafts', d, '--only', bins, '-j', '4'], 7200) + open('.run/t4/gate_%s.log' % arm, 'w').write(r.stdout + r.stderr) + # banked = INCLUDE_ASM lines the gate removed (git diff; immune to corpus's per-process cache) + d = sh(['git', 'diff', '-U0', '--', 'src/']).stdout + import re as _re + removed = set(_re.findall(r'^-INCLUDE_ASM\("[^"]+",\s*(func_\w+)\);', d, _re.M)) + banked = {fn for fn in fns if fn in removed} + return banked, 'rc=%d %ds %d staged' % (r.returncode, time.time() - t0, n) +def main(): + reset_tree() + res = {'arms': {}, 'fns': {}} + if os.path.exists('.run/t4/judge.json'): + old = json.load(open('.run/t4/judge.json')); res['arms'] = old.get('arms', {}) + for a, v in res['arms'].items(): + for f in v.get('banked', []): res['fns'].setdefault(f, []).append(a) + for arm in ARMS: + fns = [t['name'] for t in targets] + banked, note = gate(arm, fns) + res['arms'][arm] = {'banked': sorted(banked), 'n_banked': len(banked), 'drafts': sum(1 for f in fns if os.path.exists('.run/t4/%s/%s.c' % (arm, f))), 'note': note} + print('%-7s banked %2d / %2d drafts (%s)' % (arm, len(banked), res['arms'][arm]['drafts'], note), flush=True) + for f in banked: res['fns'].setdefault(f, []).append(arm) + reset_tree() + # union: cheapest winning arm per fn (ladder order), gated once and LEFT IN THE TREE for the commit + order = ['haiku', 'sonnet', 'ds', 'opus'] + pick = {} + for f, arms in res['fns'].items(): + pick[f] = sorted(arms, key=lambda a: order.index(a) if a in order else 99)[0] + ud = '.run/t4/stage_union'; shutil.rmtree(ud, ignore_errors=True) + for f, a in pick.items(): + b = T[f]['binary']; os.makedirs('%s/%s' % (ud, b), exist_ok=True) + shutil.copy('.run/t4/%s/%s.c' % (a, f), '%s/%s/%s.c' % (ud, b, f)) + if pick and UNION: + bins = ','.join(sorted({T[f]['binary'] for f in pick})) + r = sh(['flock', '.run/auto/draw.lock', '.venv/bin/python', 'tools/sweep_parallel.py', '--drafts', ud, '--only', bins, '-j', '4'], 7200) + open('.run/t4/gate_union.log', 'w').write(r.stdout + r.stderr) + d = sh(['git', 'diff', '-U0', '--', 'src/']).stdout + import re as _re + removed = set(_re.findall(r'^-INCLUDE_ASM\("[^"]+",\s*(func_\w+)\);', d, _re.M)) + res['union'] = {'picked': pick, 'banked': sorted(f for f in pick if f in removed), 'rc': r.returncode} + print('union: %d picked, %d banked (rc=%d) — LEFT IN TREE for the commit' % (len(pick), len(res['union']['banked']), r.returncode)) + json.dump(res, open('.run/t4/judge.json', 'w'), indent=1) + # per-band table (R41: denominators) + band = lambda n: '<=50' if n <= 50 else ('51-120' if n <= 120 else '>120') + for arm in res['arms']: + row = {} + for t in targets: + k = band(t['nins']); row.setdefault(k, [0, 0]); row[k][1] += 1 + if t['name'] in res['arms'][arm]['banked']: row[k][0] += 1 + print('%-7s ' % arm + ' '.join('%s: %d/%d' % (k, v[0], v[1]) for k, v in sorted(row.items()))) +if __name__ == '__main__': + main() diff --git a/tools/workflows/claude_wave_distill.js b/tools/workflows/claude_wave_distill.js new file mode 100644 index 0000000000..b4512bd4a1 --- /dev/null +++ b/tools/workflows/claude_wave_distill.js @@ -0,0 +1,78 @@ +export const meta = { + name: 't4-idiom-distill', + description: 'R16 flywheel: distill the unlocking lesson from each byte-proven T4 transcript, verify novelty against the cookbook, return §-formatted candidates', + phases: [ + { title: 'Extract', detail: '31 transcripts -> one lesson each' }, + { title: 'Verify', detail: 'novel claims grepped against docs/matching-cookbook.md' }, + ], +} +const REPO = '/home/musashi/bfm-decomp' +const WFDIR = '/home/musashi/.claude/projects/-home-musashi-bfm-decomp/b0026d2d-2724-4aed-813a-3deef224d210/subagents/workflows/wf_c691ef44-320/' +const LESSON = { + type: 'object', + properties: { + fn: { type: 'string' }, arm: { type: 'string' }, + title: { type: 'string' }, + mechanism: { type: 'string' }, + asm_tell: { type: 'string' }, + c_shape: { type: 'string' }, + evidence: { type: 'string' }, + cookbook_refs_used: { type: 'array', items: { type: 'string' } }, + novel_claim: { type: 'boolean' }, + trivial: { type: 'boolean' }, + }, + required: ['fn', 'arm', 'title', 'mechanism', 'asm_tell', 'c_shape', 'evidence', 'cookbook_refs_used', 'novel_claim', 'trivial'], +} +const VERDICT = { + type: 'object', + properties: { + fn: { type: 'string' }, title: { type: 'string' }, + verdict: { type: 'string', enum: ['COVERED', 'NEW', 'ADDENDUM', 'REFUTED'] }, + covered_by: { type: 'string' }, + entry_markdown: { type: 'string' }, + why: { type: 'string' }, + }, + required: ['fn', 'title', 'verdict', 'covered_by', 'entry_markdown', 'why'], +} +const lessons = await pipeline( + args.targets, + t => agent( +`You are distilling ONE byte-proven decompilation transcript for the project's matching cookbook (docs/matching-cookbook.md, gcc-2.7.2 MIPS idioms). Repo: ${REPO}. +Transcript (JSONL, an agent's full tool trace): ${t.transcript.replace("WF/", WFDIR)} +Function: ${t.fn} (arm ${t.arm}); the whole-binary byte-gate ACCEPTED the final draft, so the final body is ground truth. The agent's own closing note was: "${t.note}" + +Read the transcript. Find the moment(s) the masked diff (match_one closeness) dropped to 0 and what C change caused it. Report ONE lesson — the most generalizable one — as: + title: <=12 words, symptom-keyed (what you see in the diff) + mechanism: why gcc-2.7.2 emits it (one or two sentences; say "unknown" honestly if the transcript only shows the fix) + asm_tell: the instruction pattern that signals it + c_shape: the C construct that reproduces it (a short snippet is fine) + evidence: the closeness path (e.g. "closeness 7 -> 0 after X"; quote the exact match_one line(s)) + cookbook_refs_used: every § the agent grepped/cited that mattered + novel_claim: true only if the transcript did NOT get the fix from an existing cookbook §; false if it merely applied one + trivial: true if the lesson is "read the asm / fix a typo / use the right symbol" (no compiler law) +Read only; write nothing.`, + { label: `extract:${t.arm}:${t.fn}`, phase: 'Extract', model: 'sonnet', schema: LESSON }), + (lesson, t) => { + if (!lesson || lesson.trivial || !lesson.novel_claim) return lesson ? { ...lesson, verdict: lesson.trivial ? 'TRIVIAL' : 'APPLIED-EXISTING' } : null + return agent( +`You are the cookbook verifier for the Brave Fencer Musashi decompilation (repo ${REPO}). A distiller claims a NEW gcc-2.7.2 idiom from a byte-proven transcript: + fn: ${t.fn} title: ${lesson.title} + mechanism: ${lesson.mechanism} + asm_tell: ${lesson.asm_tell} + c_shape: ${lesson.c_shape} + evidence: ${lesson.evidence} + refs the agent used: ${JSON.stringify(lesson.cookbook_refs_used)} +Check docs/matching-cookbook.md (grep for the mechanism, the instruction pattern, the C construct; docs/cookbook-index.md is symptom-keyed) and decide: + COVERED — an existing § already states this law (name it in covered_by) + ADDENDUM — an existing § is close but this adds a byte-proven refinement (name it; entry_markdown = a 3-8 line addendum paragraph starting "**Addendum (P31 S62 T4, ${t.fn}):**") + NEW — no § states it; entry_markdown = a full section in the book's style, header "## §NNN — (P31 S62 T4; byte-proven ${t.fn})" with NNN left literally as NNN, then the tell, the mechanism, the C shape, the evidence line + REFUTED — the claim contradicts a byte-proven § or the evidence does not support it (say why) +Quote the § numbers you checked in why. Read only; write nothing.`, + { label: `verify:${t.fn}`, phase: 'Verify', model: 'opus', schema: VERDICT }).then(v => ({ ...lesson, ...(v || { verdict: 'UNVERIFIED', why: 'verifier returned null' }) })) + } +) +const out = lessons.filter(Boolean) +const tally = {} +for (const l of out) tally[l.verdict] = (tally[l.verdict] || 0) + 1 +log('verdicts: ' + JSON.stringify(tally)) +return { lessons: out } \ No newline at end of file diff --git a/tools/workflows/claude_wave_draft.js b/tools/workflows/claude_wave_draft.js new file mode 100644 index 0000000000..b02d2dc90f --- /dev/null +++ b/tools/workflows/claude_wave_draft.js @@ -0,0 +1,57 @@ +export const meta = { + name: 't4-model-ladder-probe', + description: 'T4 calibration probe: 20 stratified fns x Claude arms (haiku/sonnet/opus) from identical packs; drafts to .run/t4/<arm>/', + phases: [ + { title: 'Draft', detail: '20 fns x 3 arms = 60 drafting agents, identical packs, local match_one oracle only' }, + ], +} +const REPO = '/home/musashi/bfm-decomp' +const ARMS = [ + { arm: 'haiku', model: 'haiku' }, + { arm: 'sonnet', model: 'sonnet' }, + { arm: 'opus', model: 'opus' }, +] +const TARGETS = args.targets +const VERDICT = { + type: 'object', + properties: { + fn: { type: 'string' }, + arm: { type: 'string' }, + status: { type: 'string', enum: ['MATCH', 'NEAR', 'FAIL', 'NO-DRAFT'] }, + closeness: { type: ['integer', 'null'] }, + compiles: { type: 'boolean' }, + draft_path: { type: 'string' }, + note: { type: 'string' }, + }, + required: ['fn', 'arm', 'status', 'closeness', 'compiles', 'draft_path', 'note'], +} +phase('Draft') +const jobs = [] +for (const a of ARMS) for (const t of TARGETS) jobs.push({ a, t }) +const results = await parallel(jobs.map(({ a, t }) => () => agent( +`You are one arm of a controlled calibration probe in the Brave Fencer Musashi decompilation repo at ${REPO}. +Byte-match ONE PlayStation function: ${t.name} (${t.nins} instructions, binary ${t.binary}). + +FIRST read these two files in full — they are your complete brief and your ground truth pointers: + ${REPO}/.run/WAVE/SYS.md (the laws; it names tools read_file/grep/match_one/submit — use the CLI equivalents below) + ${REPO}/.run/WAVE/packs/${t.name}.md (the target pack: asm path, destination TU, card fuel, a warm-start body, any gate feedback) + +CLI equivalents (run from ${REPO}): + read_file -> cat / sed -n + grep -> rg or grep -n (start at docs/cookbook-index.md; \`.venv/bin/python tools/cookbook_index.py --resolve N\` resolves a line number) + match_one -> .venv/bin/python tools/match_one.py ${t.name} --c <your_draft.c> --asm-subdir ${t.sub} --json + (prints JSON with status MATCH/near/... and closeness = masked mismatched instruction count; it is free — use it often) + submit -> write your FINAL draft to ${REPO}/.run/WAVE/${a.arm}/${t.name}.c (mkdir -p the dir) and return the JSON verdict. + +HARD RULES: never modify anything under src/, config/, include/, asm/, build/ or run make; never touch other arms' dirs; write only to .run/WAVE/${a.arm}/ and scratch under .run/WAVE/${a.arm}/scratch_${t.name}/. Budget: up to ~24 compile/match_one iterations, then stop honestly. +Your final answer is the JSON verdict only: fn, arm="${a.arm}", status (MATCH if match_one says MATCH; NEAR if it compiles with closeness>0; FAIL if it never compiled; NO-DRAFT if you wrote nothing), closeness (integer or null), compiles, draft_path, note (one line: what blocked you, or which cookbook § unlocked it).`, + { label: `${a.arm}:${t.name}`, phase: 'Draft', model: a.model, schema: VERDICT } +).then(v => v || { fn: t.name, arm: a.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent returned null' }) + .catch(e => ({ fn: t.name, arm: a.arm, status: 'NO-DRAFT', closeness: null, compiles: false, draft_path: '', note: 'agent error: ' + String(e).slice(0, 120) })))) +const byArm = {} +for (const r of results.filter(Boolean)) { (byArm[r.arm] = byArm[r.arm] || []).push(r) } +for (const arm of Object.keys(byArm)) { + const rs = byArm[arm] + log(`${arm}: MATCH ${rs.filter(r => r.status === 'MATCH').length} / NEAR ${rs.filter(r => r.status === 'NEAR').length} / FAIL ${rs.filter(r => r.status === 'FAIL').length} / NO-DRAFT ${rs.filter(r => r.status === 'NO-DRAFT').length} of ${rs.length}`) +} +return { results } \ No newline at end of file