diff --git a/Makefile b/Makefile index d3282cf9e..9b39bfc86 100644 --- a/Makefile +++ b/Makefile @@ -140,7 +140,7 @@ CC1_SMOKE_FLAGS := -quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-lin BINUTILS_WARN_MAJOR := 2 BINUTILS_WARN_MINOR := 38 -.PHONY: help check-env extract build check expected clean report sig-refresh sig-overlays sig-resident build-all check-all audit-corpus audit-cdecl audit-binaries audit-text-sources tools-health +.PHONY: help check-env extract build check expected clean report sig-refresh sig-overlays sig-resident build-all check-all audit-corpus audit-cdecl audit-binaries audit-text-sources audit-digest tools-health # ----------------------------------------------------------------------------- help: @@ -193,6 +193,14 @@ audit-cdecl: audit-binaries: $(VENV_PY) tools/audit_binaries.py +# P30 S1e: the committed fleet digest must still describe the CURRENT tree. A digest generated from +# a working tree that later changed (work reverted before the commit landed) is BYTE-INVISIBLE — +# check-all stays 140/140 over it — and the next honest regeneration then reads as a REGRESSION that +# never happened. That cost a session-opening false alarm and gated the best-performing lever on a +# phantom. R34: the byte-gate is a null oracle for documents, so this is a second one that disagrees. +audit-digest: + $(VENV_PY) tools/audit_digest.py + # P30 S28: every tracked C source must be TEXT. A raw NUL inside a char literal (`''` instead # of `'\0'`) COMPILES — the fleet stayed byte-identical — but grep treats the file as BINARY and # reports nothing, silently, so the file vanishes from every grep-based audit and hand-search. The @@ -221,6 +229,9 @@ tools-health: $(MAKE) --no-print-directory audit-binaries $(MAKE) --no-print-directory audit-text-sources $(MAKE) --no-print-directory report BINARY=main + # AFTER report (which regenerates the digest), so this asserts the freshly-written digest agrees + # with the tree — and, on a tree whose digest was committed stale, says so instead of staying green. + $(MAKE) --no-print-directory audit-digest # The cookbook index is DERIVED (R33) and self-asserts its coverage (R32). Stale = agents can't # find documented idioms and re-derive them at full token cost (measured, P30 wave 1). $(VENV_PY) tools/cookbook_index.py --check diff --git a/docs/SETUP.md b/docs/SETUP.md index a91650e4e..7a1b4e5db 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -448,7 +448,7 @@ Modern cpp preprocesses → **vintage cc1** compiles to asm → **maspsx** emula - **`make report` is genuinely fail-closed** — any of its gates failing exits non-zero (verified by a negative control: the same broken gate exits 0 under the old `-c`, non-zero under `-ec`). - **`make check-all` / `extract-all` assert COVERAGE (`pass == N`), not the absence of a failure marker** — the old `fail == 0` form was a *vacuous pass* on an empty pipeline. `check-all`'s `pass=$(grep -c …)` carries `|| true` (grep -c exits 1 on zero matches, which `-e` would otherwise treat as fatal — it would fail check-all exactly when nothing failed). - **`make check-env` opts OUT** (`set +e` at the top of its recipe) — its contract is accumulate-every-failure-and-report, which `-e` would truncate at the first missing tool. It is the **only** intended opt-out; add `set +e` to a recipe only with the same justification. -- **`make tools-health`** (new) = regenerate the byte-derived sigs (`sig-overlays` + `sig-resident`) then run `audit-corpus` + `audit-cdecl` + `report`, fail-closed — the deliberate pre-matching ritual the roadmap's standing invariant names. Deliberately NOT a prerequisite of `report`/`build` (audit-cdecl cross-compiles every C declaration through real gcc, ~minutes). `audit-cdecl` ≈ several minutes; `audit-corpus` ≈ 7 s. +- **`make tools-health`** (new) = regenerate the byte-derived sigs (`sig-overlays` + `sig-resident`) then run `audit-corpus` + `audit-cdecl` + `audit-binaries` + `report` + `audit-digest`, fail-closed — the deliberate pre-matching ritual the roadmap's standing invariant names. Deliberately NOT a prerequisite of `report`/`build` (audit-cdecl cross-compiles every C declaration through real gcc, ~minutes). `audit-cdecl` ≈ several minutes; `audit-corpus` ≈ 7 s. - **`make sig-resident`** (Phase-27 T10) signs the resident flat blob with `sig_image` (byte-derived) so `make audit-corpus`'s second, independent boundary oracle (R34) now covers the **resident** — probed clean (0 phantom/truncated). `sig-overlays` derives its payload list from `config/overlays.mk` (not a `0.4.dec` glob, which dropped the 4 SC07 index-1 overlays). **main** stays a boundary blind spot — `sig_image` can't sign the PS-X EXE yet (header offset + interleaved islands + one text range); scoped + deferred in `docs/second-oracle.md`. `progress.py --fleet` now reports a separate **MAIN game-code weighted** line (provisional, from a LINKED-excluding Ghidra sig) — the metrics-contract "main in the denominators", honestly un-folded. ### §6.4 asm-differ + baseline discipline @@ -721,6 +721,7 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo | | `tools/serve_local.py` | **Serve the fine-tuned model on the GPU** (base+LoRA via Unsloth, `.venv-train`, OpenAI endpoint) — the in-repo replacement for LM Studio. Run: `LD_LIBRARY_PATH=$(ls -d .venv-train/lib/python3.12/site-packages/nvidia/*/lib \| tr '\n' :) .venv-train/bin/python tools/serve_local.py --adapter models/bfm-match-7b-v3 --name bfm-match-7b-v3 --port 1234`. (Prebuilt `llama-cpp-python` CUDA wheels SIGILL on this no-AVX-512 CPU; the Unsloth/torch path is reliable, no build.) | | | `tools/export_pairs.py` / `format_finetune.py` / `train_lora.py` / `eval_lora.py` | The corpus→LoRA pipeline (`.venv-train`): mine (asm↔C) pairs incl. the `engine_core.h` **macro bodies** + `engine_types.h` structs (corpus-v3) → Qwen chat-template + compile-filter → Unsloth QLoRA (3080 Ti) → held-out gate-true eval. Datasets/weights gitignored (`datasets/`, `models/`, `.venv-train/`). | | **The derived oracles** (Phase 26-A tooling audit; R33 before R32 — *the best outcome is a deleted scanner, not a fixed regex*) | `tools/corpus.py` + **`make audit-corpus`** | THE corpus oracle. Derives from the FILESYSTEM (which `.c` files make a binary; the `INCLUDE_ASM` line is self-describing — its first argument *is* the asm subdir) and from the PROVEN INVARIANT (`matched = sig − stubs`, never re-parsed from C). Killed ~10 hand-maintained layout models. `audit-corpus` is a **second oracle that can disagree**: it cross-checks splat's boundaries against `sig_image`'s independent ones (0 phantom + 0 truncated since A4; was 193 unmatchable slices). | +| | `tools/audit_digest.py` + **`make audit-digest`** | **(P30 S1e, cookbook §140)** The **scoreboard** oracle: recomputes the three headline metrics from the CURRENT tree and fails if the committed `docs/progress.fleet.md` disagrees. Wired into `tools-health` AFTER `report`. Exists because a digest generated from a working tree that later changed (work reverted before the commit landed) is **byte-invisible** — `check-all` stays 140/140 over it forever (R34: the byte-gate is a null oracle for DOCUMENTS) — and the next honest regeneration then reads as a REGRESSION that never happened. That is exactly what the `commit:1426` digest did: overstated **+7,879 ins / +130 unique fns**, which parked the phase's best lever on a phantom for a session. Compares **integers, not the printed percentages** (the staleness rendered as "94.4%" on both sides). Negative-control-proven against that stale digest. Same task hardened `progress.py stub_addrs`, which wrapped the fail-closed `corpus.stubs` in a bare `except` → empty stub set → `matched = sig − stubs` credited EVERY function: byte-witnessed reporting **instr 100.00% / distinct 100.00%** in a tree with no `asm/`. The identical swallow was fixed in `cast_call_sites.tu_for` + `reconcile_tu.tu_for`, where it silently reconciled drafts against the default `.c` instead of the jr/-O0 split TU — the very bug `cast_call_sites`' docstring exists to fix. | | | `tools/cdecl.py` + **`make audit-cdecl`** | **THE C-declaration oracle (cookbook §51g).** ONE recursive-descent parser of C's **declarator grammar**, replacing fifteen tools' private regex models — models that disagreed with each other and were, all fifteen, blind to fn-ptr/jump-table decls (`extern void (*D_X[])(void);`), sized arrays (`[4]`), and multi-declarators (where the *whole line* was dropped). Total by construction, not by shape enumeration. **Two statement paths, because the inputs differ:** `tu_statements()` derives a TU's file scope from **`cpp`** (a decl inside a `DEFINE_func_*` macro body declares nothing until invoked — §8c; 54 ms/TU), and `split_statements()` is a **span-preserving** raw split for drafts (which get rewritten). API: `parse` / `scope` / `tu_scope` / `Declarator{name,kind,type,params,pnames,is_proto,is_definition}`. Verified: **2,952,246 depth-0 statements → 2,731,521 declarators, 0 parser defects**; **50,405 distinct declarations round-tripped through the real cross-gcc, 0 rejected**; residue adjudicated NOT-C *by gcc*, not by opinion. **Phase-27 T4 — the canonical draft-typedef strip:** `typedef_names(tu_path)` (the names a TU declares as typedefs, robust `tu_statements`-based so a coverage gap can't crash the byte-gate) + `strip_provided_typedefs(draft, provided)` (drop a draft's self-contained typedefs the target already supplies, splitting multi-typedef lines and covering scalar AND struct typedefs). Replaced **six** copied scalar-name regexes with complementary holes: `harvest_verify` now strips per-TU (unblocks the 39 struct-typedef drafts `_TD` dropped) and **surfaces cc1 stderr** so a `redefinition`/`conflicting types` failure reports as **PLUMBING**, not a byte mismatch (`.run/harvest_failed.classified.txt`); `masked_diff.strip_scalar_typedefs()` (used by `match_one`/`p16_permute`) fixes the multi-typedef-line skip that discarded 42 masked-MATCH drafts over whitespace (`func_8015C030` → `MATCH (23 ins)` unedited). `canon_sig_reconcile`/`eval_lora`/`format_finetune` keep their own copies for now (migrate per-bank, byte-gated — the audit-prescribed cadence). | | | Phase 26-A tool-hygiene close (A9d–A10) | **DELETED** (R33, dead Phase-17 chain): `tools/census_conflict_callees.py` + `tools/derive_canonical_sigs.py` — `reconcile_tu`/`cdecl` answer their question from the build. **`overlay_src_split.py`**: `scan_construct` force_decl latch fixed (no longer swallows a def sharing a line with leading externs) + `hidden_definitions()` R32 coverage oracle wired into `selftest`. **`jr_isolate_all.py` `jr_inventory`**: `banked` DERIVED FROM THE IMAGE (`family_remap.reloc_targets` owns-a-carve) not a gitignored roster (R33) + curated-name via `addr_of` + 1:1 carve-ownership assert. **`family_remap.reloc_targets`**: optional `data=` param (read the image once, pass to N calls). **`backlog.py`**: `BACKLOG_NO_RENDER` env so parallel `gate_stage` workers skip the render race (append is atomic). `reconcile_tu` confirmed live on BOTH banking paths (`gate_stage` + `jtbl_family_bank.recover`→`bank_exemplar`). | diff --git a/docs/cookbook-index.md b/docs/cookbook-index.md index e06230d76..4207c558c 100644 --- a/docs/cookbook-index.md +++ b/docs/cookbook-index.md @@ -2,7 +2,7 @@ > **Generated by `tools/cookbook_index.py` — do not hand-edit** (R33). Regenerate after adding a cookbook section. > -> `docs/matching-cookbook.md` is ~716 KB / 393 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses. +> `docs/matching-cookbook.md` is ~716 KB / 398 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses. **How to use:** name what you SEE in the diff (a stolen delay slot, an extra `la`, a swapped register pair, a `conflicting types` error), find that symptom below, read those sections first. If nothing fits, THEN grind — and add a section when you win. @@ -58,7 +58,7 @@ - **§3-The** — attribution primitive (use this before calling anything a scheduling residual) L6050 - **§3-The** — scheduling rules (refining §135-2 and §135-4) L8902 -### register allocation & pins (32) +### register allocation & pins (33) - **§10** — Closing the regalloc/scheduling hard tail by hand (LZSS, Phase 7 session F — the full close) L835 - **Residual** — A — commutative `|`/`&`/`+` result lands in the wrong source-operand register L856 @@ -92,6 +92,7 @@ - **§86** — Pinned-exemplar templatability is a PER-FAMILY property, not a per-member rate; and the §42e pin guard is now over-conservative (Phase 29 SESSION-20) L6582 - **§3-Two** — further notes worth keeping L8241 - **§137** — REGALLOC-PERM is a TWO-COMPILE ARITHMETIC PROBLEM, not a permuter job L9334 +- **§3-The** — same swallow, twice more, in the integration spine L9699 ### CSE / redundancy / rematerialization (2) @@ -342,7 +343,7 @@ - **§136c** — SIBLING-FIRST is a DERIVATION shortcut, not just a conflict fix (the fastest route in a family wave) L9059 - **§138** — The propagation lanes: a gate refusal is a DECLARATION, and which lever you owe depends on blast radius L9405 -### integration / TU plumbing (35) +### integration / TU plumbing (36) - **§8c** — Splitting a TU means rebuilding its DECLARATION ENVIRONMENT, not moving text (Phase 26 session 6) L437 - **§8d** — Templating a body INTO a TU must not CHANGE its declaration environment — demote the carried data externs (Phase 26 session 8, byte-proven on `func_8015AE2C` ×133) L483 @@ -379,8 +380,9 @@ - **§3-The** — integration idioms (these decide whether a byte-correct draft BANKS) L8797 - **§3-The** — declaration surface (integration, not codegen) L8931 - **Reconciling** — a gate-refused draft: which way you edit depends on WHERE the TU's decl is L9555 +- **§3-The** — same swallow, twice more, in the integration spine L9699 -### build graph, splat & the harness (96) +### build graph, splat & the harness (97) - **§4** — Flag/toolchain gotchas L190 - **Build** — mechanism — per-file opt override (splat resegmentation) L288 @@ -478,8 +480,9 @@ - **§134** — again, in a second tool — and the waiter rule corrected L9516 - **Reconciling** — a gate-refused draft: which way you edit depends on WHERE the TU's decl is L9555 - **§139** — A GATE THAT GREPS FOR VERDICTS MUST ASSERT 1:1 ACCOUNTING; and a `--src` filter must not survive a carve (P30 S38, wave 6: 10 of 16 drafts vanished) L9584 +- **§140** — A METRIC IS NOT A MEASUREMENT UNTIL IT IS REPRODUCIBLE FROM THE COMMITTED TREE (P30 S1e: a phantom regression that gated the session's best lever) L9650 -### process, measurement & doctrine (53) +### process, measurement & doctrine (54) - **§8e** — The jtbl ALIGNMENT LAW + the pad-spec filter — multi-table .rodata spans (Phase 29, byte-proven; `.run/probe_jtbl/verdict.md`) L530 - **§3-The** — mechanism: game-code dedup is SOURCE-LEVEL, not an object swap (R-D1, the key lesson) L926 @@ -534,8 +537,9 @@ - **§136h** — CORRECTION: the zero-crack pool does NOT "refill with cheap work" (my error, byte-measured) L9230 - **§136j** — The failure MIX flips with function size (measured across four bands, one session) L9292 - **Rank** — the lane by measured concentration, not by class count L9471 +- **§140** — A METRIC IS NOT A MEASUREMENT UNTIL IT IS REPRODUCIBLE FROM THE COMMITTED TREE (P30 S1e: a phantom regression that gated the session's best lever) L9650 -### (unbucketed — title matched no symptom vocabulary) (112) +### (unbucketed — title matched no symptom vocabulary) (115) - **§3-How** — to use this L30 - **§1** — Idiom catalog (asm pattern → C that produces it) L39 @@ -649,6 +653,9 @@ - **STEP** — 0 of sibling-first: grep `src/` for a distinctive LITERAL from the `.s` L9536 - **§3-The** — generalisation — three corollaries worth more than the bug L9616 - **§3-And** — the inverse-lookup trap, same session L9633 +- **§3-The** — three-line proof (do this before diagnosing any metric movement) L9670 +- **§3-The** — two instrument defects it exposed L9679 +- **§3-Two** — wrong mechanisms I chased first, and why they were wrong L9710 ## All sections, in order @@ -1046,3 +1053,8 @@ - **§139** — A GATE THAT GREPS FOR VERDICTS MUST ASSERT 1:1 ACCOUNTING; and a `--src` filter must not survive a carve (P30 S38, wave 6: 10 of 16 drafts vanished) L9584 - **§3-The** — generalisation — three corollaries worth more than the bug L9616 - **§3-And** — the inverse-lookup trap, same session L9633 +- **§140** — A METRIC IS NOT A MEASUREMENT UNTIL IT IS REPRODUCIBLE FROM THE COMMITTED TREE (P30 S1e: a phantom regression that gated the session's best lever) L9650 +- **§3-The** — three-line proof (do this before diagnosing any metric movement) L9670 +- **§3-The** — two instrument defects it exposed L9679 +- **§3-The** — same swallow, twice more, in the integration spine L9699 +- **§3-Two** — wrong mechanisms I chased first, and why they were wrong L9710 diff --git a/docs/decision-log.md b/docs/decision-log.md index ca16e02f6..b6eb358c3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -2033,3 +2033,64 @@ no releases, and it requires a separate per-game repo to produce a playable resu reference, a risk as a dependency. **Verdict: PARKED for Gen3. Do not evaluate again before Gen2 exit.** + +--- + +## 2026-08-04 (P30 S1e) — "distinct-code FELL" was a stale digest, not a regression; the alias lever is UNGATED + +**Context + belief.** S38 closed with the def-side asm-label alias cracking a 208-conflict class +138/138 — the phase's best-performing lever. Its checkpoint then gated it in bold: *"distinct-code +FELL 89.3 → 89.2 — UNEXPLAINED. Do NOT scale the alias lever until it is resolved. The BYTES are +proven (R22); the ACCOUNTING is not."* The recorded lead was `progress.py:423`'s `SIG` regex booking +`void aF80146A6C(…)` under the alias name — with the honest caveat, written at the time, that a pure +naming artifact would move fn-count and distinct-code *together*, and these had diverged. + +**What failed.** Two mechanisms, both mine, both asserted before being derived (R14): +1. *The recorded lead.* Real blindness — but it feeds `classify()`, which computes **fn-count only**. + Neither weighted metric ever sees a C identifier; they derive from `matched = sig − corpus.stubs`. +2. *"The harvest reverted functions to INCLUDE_ASM."* Attractive because that is **byte-neutral** + (INCLUDE_ASM pastes the original asm), so R22 would stay 140/140 across genuine coverage loss — + the R34 blind spot. Refuted by one grep: **483 stub lines removed, 0 added.** + +**The pivot.** Stop hypothesizing; prove the arithmetic. Identical sigs (both denominators unchanged) ++ unchanged `tools/` + zero `INCLUDE_ASM` additions ⇒ HEAD's stub set is a strict subset of the +prior commit's ⇒ HEAD's matched set is a superset ⇒ **both numerators are forbidden to fall.** A +reported fall is therefore a statement about the *digest*, not the tree. + +**The byte/measurement-grounded why.** Reconstructing each commit's stub set from its own committed +tree (0 unresolved symbols): + +| | instr | distinct | unique fns | +|---|---|---|---| +| `commit:1426` **true** | 12,394,533 | 5,022,306 | 77,895 | +| `commit:1426` *as committed* | 12,402,412 | 5,029,324 | **78,025** | +| `HEAD` true **= committed** | 12,405,402 | 5,025,082 | 77,952 | + +True delta: **instr +10,869, distinct +2,776 ins / +57 unique fns — everything rose.** The +`commit:1426` digest was **committed stale** (generated from a working tree still holding work that was +reverted before the commit landed; overstated +7,879 ins / +130 unique fns, never regenerated). The +next honest digest was lower than the stale one, so the metric *appeared* to fall. + +**Consequences (what changed).** +- **The alias lever is UNGATED** — the blocker was a phantom. It is the phase's cheapest large lever + and should be scaled (S4 onward), subject only to the §61 small-batch discipline the two real R22 + failures taught. +- `progress.py stub_addrs` no longer swallows `corpus.stubs`' refusal. The old bare `except` turned a + fail-closed oracle into a guess: byte-witnessed reporting **instr 100.00% / distinct 100.00%** in a + tree with no `asm/`. +- **`make audit-digest` is new** (in `tools-health`, after `report`): recomputes the three headline + metrics from the current tree and fails if the committed digest disagrees. Compares **integers, not + percentages** — the +7,879-instruction staleness printed as "94.4%" both before and after. +- The same swallow was found twice more in the **integration spine** (`cast_call_sites.tu_for`, + `reconcile_tu.tu_for`), where it silently reconciled drafts against the default `.c` instead of + the jr/-O0 split TU — the exact bug `cast_call_sites`' own docstring says it exists to fix. Both now + propagate. Given the phase's ~24k PLUMBING vs 4,917 DIFF base rate, that class presents as a + codegen wall. + +**Hindsight — the better path.** The three greps (denominators / `tools/` diff / `+INCLUDE_ASM` +count) cost under a minute and settle the question *before* any hypothesis is formed. The general +form, now cookbook §140: **a committed number is a claim about a tree; if it cannot be recomputed +from that tree it is not evidence, and it must never gate a lever.** The deeper repeat is that this +is the *fourth* consecutive phase where a "wall" resolved to our own instruments — and this time the +instrument was the scoreboard itself, which is the one nobody thought to audit because the byte-gate +is green over it by construction (R34). diff --git a/docs/matching-cookbook.md b/docs/matching-cookbook.md index de4887169..144c93eb3 100644 --- a/docs/matching-cookbook.md +++ b/docs/matching-cookbook.md @@ -9644,3 +9644,83 @@ family up BY. Symptom lines for the index: **"a gate reports fewer verdicts than it had drafts"** · **"a tool reports NO FAMILY for a head another tool just enumerated"** · **"a gate result got worse after a revert"** (→ re-extract, then re-measure). + +--- + +## §140 — A METRIC IS NOT A MEASUREMENT UNTIL IT IS REPRODUCIBLE FROM THE COMMITTED TREE (P30 S1e: a phantom regression that gated the session's best lever) + +**The alarm.** S38's checkpoint recorded, in bold: *"distinct-code FELL 89.3 → 89.2 across the last +commit — UNEXPLAINED. Do NOT scale the alias lever until it is resolved. The BYTES are proven (R22); +the ACCOUNTING is not."* The def-side asm-label alias had just cracked a 208-conflict class 138/138 +— the best-performing lever of the phase — and it was parked on that one line. + +**The regression never happened.** True values, recomputed from each commit's own tree: + +| | instr | distinct | unique fns | +|---|---|---|---| +| `commit:1426` **true** | 12,394,533 | 5,022,306 | 77,895 | +| `commit:1426` *as committed* | 12,402,412 | 5,029,324 | **78,025** | +| `HEAD` true **= committed** | 12,405,402 | 5,025,082 | 77,952 | + +The real delta over that span is **instr +10,869, distinct +2,776 ins / +57 unique fns — everything +rose.** The `843` digest was **committed stale**: generated from a working tree that still held work +REVERTED before the commit landed (overstating by +7,879 ins / +130 unique fns), and never +regenerated. The next honest digest was lower than the stale one, so the metric *appeared* to fall. + +### The three-line proof (do this before diagnosing any metric movement) + +The weighted metrics are `matched = sig − corpus.stubs`. So if, between two commits, (a) the sigs are +identical (both **denominators** unchanged is sufficient evidence), (b) `tools/` is unchanged, and +(c) `git diff A B -- src/ | grep -c '^+.*INCLUDE_ASM('` is **0** — then HEAD's stub set is a strict +subset of A's, HEAD's matched set is a superset, and **both numerators are mathematically forbidden +to fall.** A reported fall is then a statement about the *digest*, not the tree. Three greps settle +it before a single hypothesis is formed; I burned two wrong mechanisms first (see below). + +### The two instrument defects it exposed + +**(1) `progress.py stub_addrs` swallowed the oracle's refusal.** It wrapped `corpus.stubs` in +`except Exception: return set()` — and an empty stub set does not mean "no stubs", it means "the +oracle could not answer", after which `matched = sig − stubs` credits **every** function as banked. +Byte-witnessed: running the metric in a tree with no `asm/` made `corpus.stubs` raise its correct, +coverage-asserted `CorpusError` for all 140 binaries, and the tool reported **instr 100.00% / +distinct-code 100.00%** — a complete decomp, out of a swallowed error. `corpus.stubs` is +*deliberately* fail-closed ("it refuses to answer rather than guess 'banked'"); a bare `except` +around a fail-closed oracle reinstates precisely the guess it refuses to make. **Fixed: propagates.** + +**(2) Nothing ever re-checked a committed digest.** R34, again: the whole-binary byte-gate is a +perfect CORRECTNESS oracle and a **null oracle for documents** — a stale digest is byte-irrelevant, +so `check-all` stays 140/140 across it forever, and `audit-corpus`/`audit-binaries` assert things +about the CODE. **Fixed: `make audit-digest`** (`tools/audit_digest.py`, wired into `tools-health` +after `report`) recomputes the three headline metrics from the current tree and fails if the +committed digest disagrees. Negative-control-proven against the known-stale `843` digest. Note it +compares **integers, not the printed percentages**: the +7,879-instruction staleness rendered as +"94.4%" both before and after, so a percentage comparison would have seen nothing. + +### The same swallow, twice more, in the integration spine + +`cast_call_sites.tu_for` and `reconcile_tu.tu_for` had the identical `except Exception: pass` around +`corpus.stubs`, falling back to the default `src//.c`. `cast_call_sites`' own docstring, three +lines above, says the function exists *because* "the recovery passes were reconciling against a +DIFFERENT TRANSLATION UNIT than the one that would compile the code, and the heavy Phase-26 cores +live in exactly those jr files" — so the swallow silently reinstated the bug the function was written +to fix. A wrong-TU reconcile fails the gate, and this phase's base rate is **~24k PLUMBING vs 4,917 +DIFF**, so it would present as a codegen wall. Both now propagate `CorpusError` while keeping the +`ValueError` fallback for curated (non-`func_ADDR`) names. + +### Two wrong mechanisms I chased first, and why they were wrong + +- **The recorded lead — `progress.py:423`'s `SIG` regex** (it books `void aF80146A6C(…)` under the + alias name). Real blindness, but it feeds `classify()`, which computes **fn-count only**. Neither + weighted metric ever sees a C identifier. *A lead that names a function must be checked against + which metric that function actually feeds.* +- **"The harvest reverted functions to INCLUDE_ASM"** — plausible because reverting to `INCLUDE_ASM` + is **byte-neutral** (it pastes the original asm), so R22 would stay green over real coverage loss. + Refuted in one grep: 483 stub lines removed, **0 added**. + +**Symptom lines for the index:** **"a progress metric fell but the byte-gate is green"** · **"two +metrics moved in opposite directions"** · **"a digest disagrees with the tree it describes"** · +**"a coverage oracle reports 100%"** · **"a recovery pass reconciled against the wrong TU"**. + +**The law:** *a committed number is a claim about a tree; if it cannot be recomputed from that tree, +it is not evidence — and it must never gate a lever.* (R32/R34/R35; and R14 — I asserted two +mechanisms before deriving either.) diff --git a/phase-ends/CURRENT_PHASE.md b/phase-ends/CURRENT_PHASE.md index b89c2604b..89fc672d1 100644 --- a/phase-ends/CURRENT_PHASE.md +++ b/phase-ends/CURRENT_PHASE.md @@ -191,8 +191,9 @@ stub on a named wall/behemoth/queue ledger** — 140/140 byte-identical througho ## FLEET — R22 **140 passed / 0 failed of 140** **96.46% fn-count · 94.4% instr-weighted · 89.2% distinct-code** (77,952 uniq) · 0 NON_MATCHING. Session opened 96.28 / 94.1 / 88.7 ⇒ **+37,166 instructions**, ~0 agent tokens after the opening wave. -**⚠️ distinct-code FELL 89.3 → 89.2 across the last commit — UNEXPLAINED. See task #11 (S1e); do NOT -scale the alias lever until it is resolved. The BYTES are proven (R22); the ACCOUNTING is not.** +**✅ RESOLVED S39 (S1e) — the "distinct-code FELL 89.3 → 89.2" alarm was a STALE COMMITTED DIGEST, not +a regression. True delta over that span: instr +10,869 · distinct +2,776 ins / +57 uniq — everything +ROSE. THE ALIAS LEVER IS UNGATED. Guard added: `make audit-digest` (in `tools-health`). See §140.** ## 🔑 THE SESSION'S BIGGEST FIND — the def-side asm-label alias is a CLASS lever The dominant sweep blocker was `conflicting types for func_80146A6C` (**208 of ~398** conflicts). @@ -232,16 +233,17 @@ Both passed their per-binary/per-draft gate and FAILED the clean-tree rebuild: | **S2** jr families | DONE — 10 members; 7 families now CLASSIFIED | | **S3** the whale | DONE-PARTIAL — **137/138**; `ov_SC07_010` open; the 61 SC07 -O0 members NOT attempted | | **S1d** the alias class | **138/138 on family 1**; generalised harvest committed w/ the accounting caveat | -| **S1e** (NEW #11) · S4 · S5 · S6 · S7 | pending | +| **S1e** (#11) | ✅ **RESOLVED — THE REGRESSION NEVER HAPPENED (S39).** The `commit:1426` digest was committed **STALE** (generated from a tree still holding work reverted before the commit landed; overstated **+7,879 ins / +130 uniq**, never regenerated), so the next honest digest read as a fall. True delta 843→HEAD: **instr +10,869 · distinct +2,776 ins / +57 uniq — everything ROSE.** HEAD's digest reproduces EXACTLY. **⇒ THE ALIAS LEVER IS UNGATED — scale it (§61 small batches).** Both recorded leads were wrong (R14): `progress.py:423`'s `SIG` feeds **fn-count only**, and "reverted to INCLUDE_ASM" died on one grep (483 removed, **0 added**). Fixes: `stub_addrs` no longer swallows `corpus.stubs` (the bare `except` byte-witnessed reporting **100.00%/100.00%** in a tree with no `asm/`); **NEW `make audit-digest`** in `tools-health` (integers, not percentages — the staleness printed as "94.4%" both sides), negative-control-proven vs the stale digest; the SAME swallow fixed in `cast_call_sites.tu_for` + `reconcile_tu.tu_for`, where it reconciled against the **wrong TU** (the bug that file's own docstring exists to fix). cookbook **§140** · decision-log 2026-08-04. | +| S4 · S5 · S6 · S7 | pending | ## ▶ RESUME HERE -1. **S1e (task #11) FIRST** — resolve the distinct-code drop before scaling the alias lever. Lead: - `progress.py:423`'s `SIG` regex records the identifier before the paren, so `void aF80146A6C(...)` - is recorded as `aF80146A6C`; the same scanner's docstring documents that exact blindness for K&R - defs ("silently erased ~190k banked instructions"). **But that does NOT explain fn-count RISING - while distinct-code FELL** — under a pure naming artifact they move together. Fix by resolving a - definition through its `__asm__` label; `overlay_src_split.asm_label_aliases` already does this - (R33, reuse it). +1. ~~**S1e (task #11) FIRST**~~ ✅ **DONE (S39)** — no regression existed; the alias lever is ungated + and is the cheapest large lever on the board. Both recorded leads were wrong (R14): the + `progress.py:423` `SIG` lead feeds **fn-count only** (neither weighted metric sees a C + identifier — they derive from `matched = sig − corpus.stubs`), and the "reverted to INCLUDE_ASM" + theory died on one grep (483 removed, 0 added). Still worth doing opportunistically: the `SIG` + alias blindness is REAL for fn-count — resolve a def through its `__asm__` label via + `overlay_src_split.asm_label_aliases` (R33, reuse it) rather than a new regex. 2. **`ov_SC06_030` + `ov_SC07_010`** — both reverted, both re-attemptable (see the failures above). 3. **S4** (task #6) + wave 6's 3 still-failing alias drafts (the three LARGEST — size-correlated). 4. **S5 the wave** (task #7) — 1,689 h_norm clusters / 326,261 ins at 2.7×. **VERIFY Fable's pool diff --git a/tools/audit_digest.py b/tools/audit_digest.py new file mode 100644 index 000000000..a0c10e2fd --- /dev/null +++ b/tools/audit_digest.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""tools/audit_digest.py — does the COMMITTED fleet digest still describe the CURRENT tree? + +WHY THIS EXISTS (Phase 30 S1e) +=============================== +`docs/progress.fleet.md` is the project's headline instrument: the burn-down, the roadmap's +re-baselines, and every "is this lever paying?" call are read off its three numbers. It is written +by `tools/progress.py --fleet` from whatever tree happened to be on disk at that moment — and then +COMMITTED, at which point nothing ever checks it again. + +That gap produced a real, costly false alarm. The S38 digest committed at `commit:1426` claimed: + + instr 12402412 distinct 5029324 (78025 uniq) + +The same metric recomputed from `commit:1426`'s own committed tree gives: + + instr 12394533 distinct 5022306 (77895 uniq) + +— overstated by +7,879 instructions and +130 unique functions, because the digest was generated +from a working tree that still held banked work which was REVERTED before the commit landed, and +was never regenerated afterwards. The next honest digest was therefore LOWER than the stale one, so +the metric appeared to FALL (distinct-code 89.3 -> 89.2) while the tree had in fact only gained +(true delta over that span: instr +10,869, distinct +2,776 ins / +57 unique fns — everything rose). + +A session then opened with "distinct-code FELL — UNEXPLAINED; do NOT scale the alias lever until it +is resolved", i.e. the project's best-performing lever was gated on a phantom regression, and the +recorded lead (a name-scanning regex in classify()) pointed at a function that does not feed either +weighted metric at all. + +WHY THE EXISTING GATES CANNOT SEE IT (R34) +========================================== +The whole-binary byte-gate is a perfect CORRECTNESS oracle and a NULL oracle for documents: a stale +digest is byte-irrelevant, so `make check-all` stays 140/140 across it forever. `audit-corpus` and +`audit-binaries` assert things about the CODE. Nothing asserted that the number we publish about the +tree is a number OF that tree. This is that second, disagreeing oracle — and its only job is to +disagree when the digest and the tree have drifted apart. + +WHAT IT ASSERTS +=============== +Recompute the three headline metrics from the CURRENT tree and require the committed digest to +match. A mismatch is not "the numbers moved" — it is "the published number was never true of this +tree", and the fix is always the same: `make report`. + +Exit 0 = the digest describes this tree. Exit 1 = it does not (message says which metric drifted). +Usage: tools/audit_digest.py [--fix] +""" +import re +import sys +import os + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "tools")) + +DIGEST = os.path.join(REPO, "docs/progress.fleet.md") + +# The three headline lines, exactly as progress.fleet() writes them. Parsed as integers (the +# printed percentages are rounded to 1dp, so they are far too coarse to catch a 130-function drift — +# the +7,879-instruction staleness above still rendered as "94.4%" both before and after). +_LINES = { + "fn-count": re.compile(r'^FLEET fn-count byte-ident:\s*(\d+)\s*/\s*(\d+)', re.M), + "instr": re.compile(r'^FLEET instr-weighted\s*:\s*(\d+)\s*/\s*(\d+)', re.M), + "distinct": re.compile(r'^FLEET distinct-code\(uniq\):\s*(\d+)\s*/\s*(\d+)\s*=\s*[\d.]+%\s*\((\d+)/(\d+)', re.M), +} + + +def committed(): + """The three metrics as published in docs/progress.fleet.md.""" + if not os.path.exists(DIGEST): + raise SystemExit(f"audit-digest: {os.path.relpath(DIGEST, REPO)} does not exist — run `make report`.") + txt = open(DIGEST).read() + out = {} + for key, rx in _LINES.items(): + m = rx.search(txt) + if not m: + # R32: a line we cannot read is a DEFECT, not a skip — a silently-unparsed headline is + # exactly the blindness this file exists to remove. + raise SystemExit(f"audit-digest: cannot parse the '{key}' line out of " + f"{os.path.relpath(DIGEST, REPO)} — the digest format changed and this " + f"oracle went blind. Fix the pattern, do not delete the check.") + g = [int(x) for x in m.groups()] + out[key] = tuple(g) + return out + + +def live(): + """The same three metrics, recomputed from the CURRENT tree.""" + import progress + # fn-count comes from the per-binary classification; the weighted pair from the derived stub + # oracle. Both are recomputed here exactly as `progress.fleet()` does it. + order = [b for b in ("main", "resident") if b in progress.BINARIES] + \ + sorted(b for b in progress.BINARIES if b not in ("main", "resident")) + rows = [progress.report(b, write=False) for b in order] + byte = sum(r["byteident"] for r in rows) + match = sum(r["matchable"] for r in rows) + wm = progress.weighted_metrics() + if not wm: + raise SystemExit("audit-digest: no .run/sig.*.jsonl — run `make sig-overlays sig-resident`.") + return { + "fn-count": (byte, match), + "instr": (wm["fleet_m"], wm["fleet_t"]), + "distinct": (wm["dedup_m"], wm["dedup_t"], wm["dedup_fns"], wm["dedup_total_fns"]), + } + + +def main(): + fix = "--fix" in sys.argv + have, want = committed(), live() + bad = [k for k in want if have.get(k) != want[k]] + if not bad: + print(f"audit-digest: OK — docs/progress.fleet.md describes the current tree " + f"(instr {want['instr'][0]}/{want['instr'][1]}, " + f"distinct {want['distinct'][2]}/{want['distinct'][3]} uniq).") + return 0 + print("audit-digest: FAIL — the committed digest does not describe the current tree.") + print(" A stale digest is BYTE-INVISIBLE (check-all stays green over it), and a later honest") + print(" regeneration then reads as a REGRESSION that never happened. See this file's header.") + for k in bad: + print(f" {k:9s} committed {have.get(k)} actual {want[k]}") + if fix: + print(" --fix: regenerating…") + import progress + progress.fleet() + return 0 + print(" Fix: `make report` (regenerates the digest), then commit it WITH the work it describes.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/cast_call_sites.py b/tools/cast_call_sites.py index dfec9291d..543407fe0 100644 --- a/tools/cast_call_sites.py +++ b/tools/cast_call_sites.py @@ -69,11 +69,19 @@ def tu_for(overlay, fn, override=None): if override: return override try: - st = _corpus.stubs(overlay).get(int(fn[5:], 16)) - if st: - return os.path.join(REPO, st.path) - except Exception: - pass + addr = int(fn[5:], 16) # a curated (non-func_ADDR) name has no address here + except ValueError: + return os.path.join(REPO, f'src/{overlay}/{overlay}.c') + # CorpusError PROPAGATES (R32/R35, P30 S1e). This was a bare `except Exception: pass`, which on + # any oracle failure fell through to the default `.c` — silently reinstating the exact bug + # the docstring above says this function exists to fix (reconciling against the WRONG TU, where + # the heavy Phase-26 jr cores live). A wrong-TU reconcile fails the gate, and this phase's base + # rate is ~24k PLUMBING vs 4,917 DIFF, so it would read as a codegen wall. Verified safe: at + # P30 S1e HEAD, corpus.stubs raises for 0 of 140 binaries — if it fires, the tree really is + # inconsistent and reconciling through it is worse than stopping. + st = _corpus.stubs(overlay).get(addr) + if st: + return os.path.join(REPO, st.path) return os.path.join(REPO, f'src/{overlay}/{overlay}.c') # A function declaration line (prototype ending in `;`, NOT a definition): optional `extern`, diff --git a/tools/progress.py b/tools/progress.py index dbbc6ca0c..5aa2a4b4e 100644 --- a/tools/progress.py +++ b/tools/progress.py @@ -637,10 +637,17 @@ def weighted_metrics(): return None def stub_addrs(binary): - try: - return set(corpus.stubs(binary)) # {addr:int -> Stub}; the derived INCLUDE_ASM set (R33) - except Exception: - return set() + # FAIL LOUD (R32/R35). This was `except Exception: return set()` — and an empty stub set does + # not mean "no stubs", it means "the oracle could not answer": `matched = sig − stubs` then + # credits EVERY function in that binary as banked. Byte-witnessed during P30 S1e: running + # this metric in a tree with no `asm/` made corpus.stubs raise its (correct, coverage-asserted) + # CorpusError for all 140 binaries, the swallow turned each into an empty set, and the tool + # cheerfully reported **instr-weighted 100.00% / distinct-code 100.00%** — a 100%-complete + # decomp, from a swallowed error. corpus.stubs is deliberately fail-closed ("it refuses to + # answer rather than guess 'banked'"); wrapping it in a bare except reinstated exactly the + # guess it refuses to make. A loud failure nobody counts is as invisible as a silent one + # (R32) — so here it is neither swallowed nor merely logged: it aborts the metric. + return set(corpus.stubs(binary)) # {addr:int -> Stub}; the derived INCLUDE_ASM set (R33) fm = ft = 0 cls_nins, matched_cls = {}, set() diff --git a/tools/reconcile_tu.py b/tools/reconcile_tu.py index ef632b255..31f291660 100644 --- a/tools/reconcile_tu.py +++ b/tools/reconcile_tu.py @@ -72,11 +72,15 @@ def tu_for(overlay, fn, override=None): if override: return override try: - st = corpus.stubs(overlay).get(int(fn[5:], 16)) - if st: - return os.path.join(REPO, st.path) - except Exception: - pass + addr = int(fn[5:], 16) # a curated (non-func_ADDR) name has no address here + except ValueError: + return os.path.join(REPO, f'src/{overlay}/{overlay}.c') + # CorpusError PROPAGATES (R32/R35, P30 S1e) — see the identical note in cast_call_sites.tu_for. + # A swallow here silently reconciles the draft against the DEFAULT `.c` instead of the jr/-O0 + # split TU that actually compiles it, which is a §51g LAW 10 violation dressed up as a gate refusal. + st = corpus.stubs(overlay).get(addr) + if st: + return os.path.join(REPO, st.path) return os.path.join(REPO, f'src/{overlay}/{overlay}.c')