diff --git a/docs/SETUP.md b/docs/SETUP.md index 9ad3b0dc46..4b4a409dba 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -978,6 +978,7 @@ fills fast). Nothing is leaking — but the host does not get the memory back on | tool | what it does | when you need it | |---|---|---| | `tools/gate_main_parallel.py` **(NEW)** | runs the REAL `gate_main` inside N git worktrees to discover which drafts pass, then hands the union to ONE authoritative `gate_main` in the real tree. Workers discover; only the final serial pass banks | a main slate large enough that serial bisection hurts. **Measured: one gate cycle is 16 s**, so MAX_STEPS=24 is ~6.4 min serial and ~90 s across four workers. Run `--negative-control` once per environment first | +| `tools/recover_route.py` **(NEW)** | reads a gate's DROP ledger and names the tool that actually applies — SELF / CALLEE / DATA, each with its ordered ladder; refuses a verbatim draft up front. Wired into `gate_main`'s dropped report | after any gate that drops a draft. Replaces the single hardcoded chain gate_main used to print for every drop, which was the SELF chain and measured RED on a callee (S69) and inert on data. Negative-controlled against all 7 S77 drops whose winning tool was already known | | `tools/sync_tu_decls.py` **(NEW)** | banks a draft the gate refuses by copying the TU's OWN `extern` line for whatever symbol the gate names, re-gating, and repeating | a draft that is byte-correct but rejected on a declaration conflict. Refuses `self_decl_tu` (use `cast_self_callers --sync-decls`) and refuses a NEAR up front, since syncing declarations makes a body COMPILE, never MATCH | **Oracle corrections — re-read any verdict recorded before these:** diff --git a/docs/wave-playbook.md b/docs/wave-playbook.md index 116e2999bd..5ae2145f0c 100644 --- a/docs/wave-playbook.md +++ b/docs/wave-playbook.md @@ -362,6 +362,22 @@ tools/cast_self_callers.py --undo-journal J2 --keep # MANDATORY — Or in one driver: `recover_integration.py --binary B --stages arity,self-cast --max-tier fleet --r22`. +> **DO NOT PICK THE TOOL BY HAND — `tools/recover_route.py` holds the table (P31 S77).** `gate_main` +> used to print the SELF chain above for EVERY drop, whatever the clashing symbol was, and two of the +> three classes are not that chain: a CALLEE drop run through `--any-proto` measured **60 decls +> no-protoed and the binary RED** (S69), and a DATA drop is touched by neither tool in it. Following +> the shape of that text is what sent S77's `func_8006252C` through `scope_demote_drafts` first, +> which aliased a symbol through `__asm__` and BROKE the build — the real blocker was one +> `--sync-decls` away. `gate_main` now routes each drop; run the router standalone with +> `tools/recover_route.py .run/gate_main_dropped.json --drafts `. It refuses a verbatim draft +> outright, and a route is the order to TRY, never a prediction — the byte gate still arbitrates. +> +> | drop | tool | +> |---|---| +> | symbol **is** the function being banked | `cast_self_callers --sync-decls` (§477, 16/16) | +> | symbol is a **different function** | `sync_tu_decls` — adopt the TU's spelling, incl. from its DEFINITION header | +> | symbol is **data** | `sync_tu_decls`, then `scope_demote_drafts` (§8d/§481) | + > **THE UNDO IS NOT OPTIONAL.** The casts go in BEFORE the gate, in preparation. A cast left behind > for a draft that did not bank made `ov_SC07_000` fail to COMPILE at HEAD, so every later gate > verdict on that binary measured a broken baseline rather than a draft (found only because two diff --git a/tools/gate_main.py b/tools/gate_main.py index 7911d7e8ea..f5f3dbadea 100644 --- a/tools/gate_main.py +++ b/tools/gate_main.py @@ -1012,13 +1012,31 @@ def main(): # to disk with the chain spelled out makes the recovery the obvious next command instead # of a paragraph someone has to remember. json.dump(dropped, open('.run/gate_main_dropped.json', 'w'), indent=1) - print(f" -> .run/gate_main_dropped.json ({len(dropped)} to reconcile). The chain is:") - print(f" tools/fix_arity_callers.py --apply --any-proto --funcs " - f"{','.join(d['fn'] for d in dropped)} \\\n" - f" --drafts --journal .run//arity.json") - print(f" tools/cast_self_callers.py --binary main --funcs --drafts " - f"--apply --journal .run//cast.json") - print(f" tools/gate_main.py --apply # the byte-gate arbitrates") + print(f" -> .run/gate_main_dropped.json ({len(dropped)} to reconcile). Routed per drop:") + # ONE CHAIN FOR EVERY DROP WAS WRONG FOR TWO OF THE THREE CLASSES (P31 S77). What used to + # print here was the SELF chain (`fix_arity_callers --any-proto` + `cast_self_callers`) + # regardless of what the clashing symbol actually is. For a CALLEE the playbook already + # records the cost -- §378 does not transfer, `--any-proto` runs unprotected over every call + # site, and S69 measured 60 decls no-protoed and the binary RED. For a DATA symbol neither + # tool touches it at all. Following the shape of this text is what sent S77's func_8006252C + # through `scope_demote_drafts` first, which aliased a symbol through __asm__ and BROKE the + # build, when the real blocker was one `--sync-decls` away. A drop is a ROUTE (S72) -- so + # print the route, not a chain. recover_route is the shared table; the byte gate still + # arbitrates (G3/P9). + try: + import recover_route as _rr + for _d in dropped: + _dd = os.path.dirname(next((e['draft'] for e in slate if e['fn'] == _d['fn']), '') or '') + _kind, _cmds, _why = _rr.route(_d['fn'], _d.get('symbol', ''), _d.get('kept', ''), + _d.get('this', ''), + draft=os.path.join(_dd, _d['fn'] + '.c') if _dd else None, + binary='main', tu=_d.get('file')) + print(f" {_d['fn']:<16} {_kind:<9} on {_d.get('symbol')} — {_why}") + for _c in _cmds: + print(f" {_c}") + except Exception as _e: + print(f" (recover_route unavailable: {type(_e).__name__}: {_e}) " + f"— tools/recover_route.py .run/gate_main_dropped.json") if not a.apply: print("\nDRY RUN. Re-run with --apply to substitute and clean-rebuild.") return diff --git a/tools/recover_route.py b/tools/recover_route.py new file mode 100644 index 0000000000..2a8b24c307 --- /dev/null +++ b/tools/recover_route.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""recover_route.py — given a gate's DROP verdict, name the tool that actually applies. (P31 S77) + +WHY THIS EXISTS, WITH THE MEASUREMENT. `gate_main` writes every dropped draft to +`.run/gate_main_dropped.json` and then prints ONE chain for all of them: + + tools/fix_arity_callers.py --apply --any-proto --funcs ... + tools/cast_self_callers.py --binary main --funcs ... + +That is the SELF-declaration chain (§378), and it is correct for exactly one of the three drop +classes. The playbook already records what the others cost: + + * CALLEE (`conflicting types for func_8012AD44`, a DIFFERENT function): §378 does NOT transfer. + `cast_self_callers` reads the return type off the draft and cannot cast a callee, so + `--any-proto` runs UNPROTECTED and changes argument conversion at every call site. + MEASURED (S69): 60 decls no-protoed, **binary RED**, reverted. + * DATA (`D_800A5E60`, `D_80072960`): neither tool in the printed chain touches a data extern at + all. The fix is to adopt the TU's spelling into the DRAFT (`sync_tu_decls`) or, when the draft + genuinely needs an incompatible type, demote it to block scope (§8d / §481). + +MEASURED COST OF THE WRONG ROUTE, THIS SESSION. `func_8006252C` was dropped on a clash with +`func_8006252C` ITSELF. Following the shape of the printed chain I reached for +`scope_demote_drafts` first: it aliased `D_80078D08` through `__asm__`, the build FAILED, and the +draft looked broken. The actual blocker was the self-declaration, one command away. Three tools, +wrong order, one of them destructive — because the report named a chain instead of a route. + +WHAT THIS DOES NOT DO. It does not predict which tool will WIN. For a DATA clash there are two +plausible fixes and the difference is whether the draft can live with the TU's type at all — not +something a classifier can know. So a route is an ORDERED LADDER and **the byte gate remains the +sole arbiter (G3/P9)**; the ladder just stops you running the destructive one first. + +REFUSALS COME FIRST (R43/R61a). A verbatim draft and a NEAR are not declaration problems, and +routing them to a declaration tool wastes a clean rebuild to learn what two cheap checks already +know. + + tools/recover_route.py .run/gate_main_dropped.json --drafts .run// + tools/recover_route.py --fn func_X --symbol D_Y --kept "..." --this "..." +""" +import argparse +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +sys.path.insert(0, HERE) + +SELF, CALLEE, DATA = 'SELF', 'CALLEE', 'DATA' + + +def symbol_kind(fn, symbol, kept='', this=''): + """SELF / CALLEE / DATA for the symbol a gate named in a DROP. + + The signature strings are `typesig` tuples rendered by gate_main, e.g. + function : ('void', ('void',)) -> a declarator WITH a parameter list + fn-pointer : ('s32 (*', ('* void',)) -> an OBJECT whose type is pointer-to-function + plain data : ('s16', '[]') -> no parameter list at all + A `(*` in the base type is what separates a pointer-to-function OBJECT from a FUNCTION, and it + is the distinction that decides the tool: `cast_self_callers` casts CALL SITES and is + meaningless for an object.""" + if symbol == fn: + return SELF + blob = '%s %s' % (kept, this) + looks_callable = ('(' in blob and ')' in blob and '(*' not in blob) or bool(re.search(r'\(\s*\(', blob)) + if looks_callable and symbol.startswith('func_'): + return CALLEE + if symbol.startswith('func_') and not blob.strip(): + return CALLEE + return DATA + + +def refusal(fn, draft, binary='main', asm_subdir=None): + """A reason this drop should not be routed to a declaration tool at all, or None.""" + if not draft or not os.path.exists(draft): + return None + try: + import draft_prechecks as DP + if DP.is_verbatim_asm_draft(open(draft, errors='replace').read(), fn): + return ("VERBATIM — the draft is the target's own asm in a file-scope __asm__ (§265/§478). " + "It is not a decompile, no declaration fix applies, and the gate refuses it for " + "free. REDRAFT or accept it as a §265 bank via asm_verbatim.py.") + except Exception: + pass + return None + + +def route(fn, symbol, kept='', this='', draft=None, binary='main', tu=None): + """(kind, [ordered commands]) — the ladder to try, cheapest/safest first.""" + stop = refusal(fn, draft) + if stop: + return 'REFUSED', [], stop + kind = symbol_kind(fn, symbol, kept, this) + d = os.path.dirname(draft) if draft else '' + if kind == SELF: + cmds = [ + "tools/cast_self_callers.py --binary %s --funcs %s --drafts %s --sync-decls --apply " + "--journal .run//cast.json" % (binary, fn, d), + "make check BINARY=%s # LAW 1: byte-neutral with NO draft substituted" % binary, + "git commit # the gate checks out the TUs / pins a worktree", + "tools/gate_main.py --apply", + "tools/cast_self_callers.py --undo-journal .run//cast.json --keep ", + ] + why = ("the clash names the function being banked, so the CALL SITES must change too — " + "this is the only class §378 covers, and it banked 16 of 16 in S77 (§477).") + elif kind == CALLEE: + cmds = [ + "tools/sync_tu_decls.py --binary %s --fn %s --draft %s/%s.c --rounds 6 --apply" + % (binary, fn, d, fn), + ] + why = ("the clash names a DIFFERENT function, so §378 does NOT transfer: cast_self_callers " + "reads the return type off the draft and cannot cast a callee, and --any-proto would " + "run unprotected over every call site (S69: 60 decls no-protoed, binary RED). Adopt " + "the TU's spelling into the DRAFT instead — including from its DEFINITION header.") + else: + cmds = [ + "tools/sync_tu_decls.py --binary %s --fn %s --draft %s/%s.c --rounds 6 --apply" + % (binary, fn, d, fn), + "tools/scope_demote_drafts.py --overlay %s --in --out --src-file %s" + % (binary, tu or '.c'), + ] + why = ("a DATA symbol: neither tool in gate_main's printed chain touches one. Try adopting " + "the TU's spelling first; if the draft genuinely needs an incompatible type, demote " + "it to BLOCK scope — legal because `conflicting types' is a SAME-SCOPE error and " + "across scopes it is only a warning (§481/§8d). The byte gate decides which.") + return kind, cmds, why + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("dropped", nargs="?", default=".run/gate_main_dropped.json") + ap.add_argument("--drafts", default=None, help="dir holding .c, so the refusal checks can run") + ap.add_argument("--binary", default="main") + ap.add_argument("--fn"); ap.add_argument("--symbol") + ap.add_argument("--kept", default=""); ap.add_argument("--this", default="") + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + if a.fn and a.symbol: + rows = [{"fn": a.fn, "symbol": a.symbol, "kept": a.kept, "this": a.this}] + else: + p = a.dropped if os.path.isabs(a.dropped) else os.path.join(REPO, a.dropped) + if not os.path.exists(p): + sys.exit("recover_route: no %s — run a gate first (it writes the drop ledger)." % a.dropped) + rows = json.load(open(p)) + + out = [] + for r in rows: + draft = os.path.join(a.drafts, r["fn"] + ".c") if a.drafts else None + kind, cmds, why = route(r["fn"], r.get("symbol", ""), r.get("kept", ""), r.get("this", ""), + draft=draft, binary=a.binary, tu=r.get("file")) + out.append({"fn": r["fn"], "symbol": r.get("symbol"), "kind": kind, "why": why, "cmds": cmds}) + + if a.json: + print(json.dumps(out, indent=1)); return 0 + for o in out: + print("\n%-16s %-14s clash on %s" % (o["fn"], o["kind"], o["symbol"])) + print(" why: %s" % o["why"]) + for c in o["cmds"]: + print(" %s" % c) + if out: + print("\n%d drop(s) routed. The byte gate remains the sole arbiter — a route is the order to " + "TRY, never a prediction of which one wins." % len(out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())