From b478b3a5d9d5ccdf45d924e64f6a61bad1fab8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 06:54:40 +0200 Subject: [PATCH] test(repsel): count CONSUMED promotions, not selected ones The promotion census (#7104, #7113) counted `select()` calls. That is the wrong quantity: a promotion can be selected, reported as a win, and produce literally nothing. `batch.ts` reports two Ptr promotions and applies one. `totals` is proven, counted as a win, and keeps the guarded diamond at every access site; #7107's entire 1,532-byte saving came from `acc`. That was found by reading emitted IR, never by the report. The three census workloads that promote (07_object_create, 09_method_calls, 12_binary_trees) were proposed as the cleanest available experiment for measuring what a promotion is worth. All three would have measured 0.00% -- each declares its promoted local at module top level, and 07/12 are additionally scalar-replaced. With PERRY_PTR_SHAPE_LOCALS=0 the objects for 07/12 are byte-identical to the default; 09 differs only by two __pshape clones with zero call sites. Corpus-wide, Ptr is now 6 selected / 2 consumed. Four proven and thrown away: two by the module-init context gate (#7109), two by scalar replacement (#7115, filed by this work -- it was undocumented). No unexplained residue. Consumption is recorded at the six codegen sites that COMMIT to the guard-free lowering, never at a select()-adjacent site, so the count stays checkable against IR rather than against another counter. `outcome` (and, for consumed entries, the consuming site) joins `Entry::dedup_key`: without it every consumption record collapsed into its own selection and the tally was pinned at zero. --opt-report schema goes to 2. `selected` keeps its meaning but explicitly stops implying emitted bytes, so this is a meaning change, not an additive field. The census gains a ptr-shape-consumed column with its own floors, its own LIVENESS_FLOORS minimum, and three new failure modes: consumption recorded outside the selected population, consumption counted per access site rather than per value, and wasted promotions that name no mechanism -- the last is what makes deleting a drop-recorder visible. CONSUMPTION_INSTRUMENTED lives in the script, not the regenerable baseline; only ptr-shape is instrumented and the other keys report no consumption data rather than a zero. No existing floor was lowered; batch's ratcheted ptr-shape: 2 is untouched. Byte-neutral: 23/23 workloads identical with the report off vs on, and 23/23 between the pre-change and post-change compilers with the report off. Sabotage-verified in both directions (harness exit codes, not a wrapper shell's): dropping `outcome` from dedup_key, removing all six consumption recorders, removing either mechanism recorder, counting per access site, folding proven-`this` consumption into the local column, and deleting the consumed liveness minimum each turn the gate red; the unmodified tree is green. The five pre-existing PERRY_*_LOCALS=0 sabotages still go red, and CI's sabotage step now also asserts ptr-shape-consumed tracks the compiler. ptr_shape.rs's number-by-construction proof moves to ptr_shape_numeric.rs to stay under the 2000-line gate. Refs #7106, #7107, #7109, #7115 --- .github/workflows/test.yml | 20 + benchmarks/repsel_census/README.md | 73 +++- benchmarks/repsel_census/baseline.json | 94 ++++- changelog.d/7117-repsel-consumed-census.md | 98 +++++ .../src/collectors/proven_this.rs | 3 + .../perry-codegen/src/collectors/ptr_shape.rs | 171 ++------- .../src/collectors/ptr_shape_numeric.rs | 158 ++++++++ .../perry-codegen/src/expr/instance_misc1.rs | 1 + crates/perry-codegen/src/expr/mod.rs | 101 +++++- crates/perry-codegen/src/expr/property_get.rs | 4 + .../src/expr/property_get/helpers.rs | 2 + crates/perry-codegen/src/expr/property_set.rs | 1 + crates/perry-codegen/src/expr/slot_rep.rs | 44 +++ .../property_get/dynamic_dispatch.rs | 1 + crates/perry-codegen/src/opt_report/mod.rs | 211 ++++++++++- crates/perry-codegen/src/opt_report/render.rs | 180 ++++++++- crates/perry-codegen/src/stmt/let_stmt.rs | 46 +++ .../compiler_output_harness/repsel_census.py | 341 +++++++++++++++++- tests/test_repsel_census.py | 234 +++++++++++- 19 files changed, 1602 insertions(+), 181 deletions(-) create mode 100644 changelog.d/7117-repsel-consumed-census.md create mode 100644 crates/perry-codegen/src/collectors/ptr_shape_numeric.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88b786b8d9..7b2873ea80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1149,6 +1149,16 @@ jobs: # workload it exists for — and that only 3 of 17 suite benchmarks promote a # single shape local each. Nothing in CI could have told anyone that. # + # What it counts is CONSUMPTION, not selection. An analysis proving a value + # and codegen emitting something for it are different events, and #7107 found + # by reading IR that `batch.ts` proves two `Ptr` values and applies + # one: `totals` is proven, reported as a win, and keeps the guarded diamond at + # every access site. `07_object_create` and `12_binary_trees` are worse -- they + # report a promotion each while `PERRY_PTR_SHAPE_LOCALS=0` produces a + # byte-identical object. So `ptr-shape` and `ptr-shape-consumed` are separate + # columns with separate floors, and every unconsumed promotion must name the + # mechanism that ate it (#7109 / #7115). + # # Why it can fail (CLAUDE.md, "Four ways a gate can be unable to fail"): # floors alone would not be enough, because the honest floor for # `Ptr` on real code is zero today and a zero floor can never go red. @@ -1246,6 +1256,16 @@ jobs: echo "::error::but not for the reason that proves its subject was live." exit 1 fi + # The consumed column is a SEPARATE counter fed from separate codegen + # sites, so it needs its own liveness assertion. A `ptr-shape-consumed` + # that stayed at its floor while `ptr-shape` went to zero would be a + # number disconnected from the compiler -- and it is the column the + # performance claims now rest on. + if ! printf '%s' "$out" | grep -q "fixture_ptr_shape: ptr-shape-consumed promoted 0"; then + echo "::error::ptr-shape went to zero but ptr-shape-consumed did not." + echo "::error::The consumption counter is not tracking the compiler." + exit 1 + fi echo "Census correctly went red with PERRY_PTR_SHAPE_LOCALS=0." - name: Upload census reports diff --git a/benchmarks/repsel_census/README.md b/benchmarks/repsel_census/README.md index 79c71ad8b2..a6daffdc6a 100644 --- a/benchmarks/repsel_census/README.md +++ b/benchmarks/repsel_census/README.md @@ -29,6 +29,58 @@ produced a byte-identical binary. The census makes that a standing, visible, gated number instead of a discovery. +## Selected is not consumed + +The census counts **consumed** promotions, not selected ones, and the two +columns are separate on purpose. + +`select()` fires when an analysis *proves* a value. Whether codegen then emits +anything different for it is a different question, and for `Ptr` the +answer is usually no: + +| workload | `ptr-shape` | `ptr-shape-consumed` | mechanism | +|---|---|---|---| +| `fixture_ptr_shape` | 1 | 1 | — | +| `batch` | 2 | 1 | `module_init_context` | +| `suite_07_object_create` | 1 | 0 | `scalar_replaced` | +| `suite_09_method_calls` | 1 | 0 | `module_init_context` | +| `suite_12_binary_trees` | 1 | 0 | `scalar_replaced` | + +Six proven, two applied. A promotion goes unconsumed three ways, and every one +of them is recorded at the site where the proof is dropped: + +1. **`module_init_context`** (#7109) — `codegen/entry.rs` sets + `repsel_context_allows_canonical_i32: false` for module-init and + program-entry bodies, and `FnCtx::ptr_shape_receiver_fact` returns `None` for + the whole body when that flag is clear. Every access site falls back to the + guarded diamond. +2. **`async_body` / `generator_body`** (#6328) — the same flag, cleared for a + different reason. +3. **`scalar_replaced`** (#7115) — `collectors/escape_news.rs` deleted the + object outright. This one is the *better* outcome, not a defect; it is listed + because "scalar-replaced" and "promoted but wasted" used to render + identically and mean opposite things. + +**Ground truth is the emitted IR, never a counter.** Every verdict above is +reproducible without the report at all: compile the workload twice, once with +`PERRY_PTR_SHAPE_LOCALS=0`, and compare the objects. + +```bash +perry compile -o /tmp/x --no-link --no-cache # prints the .o path +PERRY_PTR_SHAPE_LOCALS=0 perry compile -o /tmp/x --no-link --no-cache +``` + +Byte-identical objects mean the promotions the report counted as wins changed +nothing. `07_object_create` and `12_binary_trees` are byte-identical today. +`09_method_calls` differs, but only by two `__pshape` clones with **zero call +sites** — which is why the census reports its consumption as 0 and the object +A/B alone would have been misleading. + +Only `ptr-shape` has consumption instrumentation. The other seven census keys +report *no consumption data* rather than a zero +(`CONSUMPTION_INSTRUMENTED` in the script), because "uninstrumented" and "never +applied" are exactly the pair this census exists to keep apart. + ## How it cannot quietly pass Read CLAUDE.md, "★ Four ways a gate can be unable to fail". The fourth applies @@ -74,11 +126,30 @@ Three separate mechanisms, in increasing order of paranoia: Only `suite_01_startup` is allowlisted: it is a lone `console.log`, with no bindings for any analysis to consider. +5. **Consumption coherence checks.** `consumed` may never exceed `selected` for + the same representation — they must describe one population, and Phase 5a's + proven-`this` receiver is consumed without ever being selected, so folding it + in would silently break that. One value consumed at five access sites counts + once. And a workload with wasted promotions must NAME at least one mechanism. + + That last one is what makes deleting a drop-recorder visible. Without it the + consumed column would not move, every floor would still pass, and the census + would go green having lost the only part of the finding that says *why* — + CLAUDE.md failure mode 4, one level in. + Sabotage-verified in both directions. Each of `PERRY_PTR_SHAPE_LOCALS=0`, `PERRY_PTR_NUMARRAY_LOCALS=0`, `PERRY_CANONICAL_I32_LOCALS=0`, `PERRY_CANONICAL_STR_LOCALS=0` and `PERRY_INT_VALUED_LOCALS=0` turns the census red; the default build is green. CI re-runs the first of those on every job so -the property is checked, not just claimed once. +the property is checked, not just claimed once, and additionally asserts that +`ptr-shape-consumed` goes to zero with it — the consumed column is fed from +separate codegen sites and needs its own liveness proof. + +The consumption machinery was sabotage-verified the same way: dropping +`outcome` from `Entry::dedup_key`, removing all six consumption recorders, +removing either mechanism recorder, counting per access site, folding +proven-`this` consumption into the local column, and deleting the consumed +liveness minimum each turn the gate red. ## Editing the fixtures diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index ff870a9fb9..2831bcde7a 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -7,6 +7,7 @@ "source": "benchmarks/repsel_census/fixtures/fixture_ptr_shape.ts", "floors": { "ptr-shape": 1, + "ptr-shape-consumed": 1, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -21,7 +22,8 @@ "canonical-slot": 1, "int-valued-ta": 0, "spec-abi": 1 - } + }, + "unconsumed_mechanisms": {} }, { "name": "fixture_ptr_numarray", @@ -29,6 +31,7 @@ "source": "benchmarks/repsel_census/fixtures/fixture_ptr_numarray.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 1, "canonical-i32": 2, "canonical-u32": 0, @@ -43,7 +46,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 2 - } + }, + "unconsumed_mechanisms": {} }, { "name": "fixture_canonical_slots", @@ -51,6 +55,7 @@ "source": "benchmarks/repsel_census/fixtures/fixture_canonical_slots.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 2, "canonical-u32": 1, @@ -65,7 +70,8 @@ "canonical-slot": 6, "int-valued-ta": 0, "spec-abi": 3 - } + }, + "unconsumed_mechanisms": {} }, { "name": "fixture_int_valued_ta", @@ -73,6 +79,7 @@ "source": "benchmarks/repsel_census/fixtures/fixture_int_valued_ta.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 1, "canonical-u32": 0, @@ -87,7 +94,8 @@ "canonical-slot": 3, "int-valued-ta": 1, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "fixture_spec_abi_taptr", @@ -95,6 +103,7 @@ "source": "benchmarks/repsel_census/fixtures/fixture_spec_abi_taptr.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 2, "canonical-u32": 0, @@ -109,7 +118,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 1 - } + }, + "unconsumed_mechanisms": {} }, { "name": "batch", @@ -117,6 +127,7 @@ "source": "benchmarks/app-patterns/kernels/batch.ts", "floors": { "ptr-shape": 2, + "ptr-shape-consumed": 1, "ptr-numarray": 0, "canonical-i32": 3, "canonical-u32": 0, @@ -131,6 +142,9 @@ "canonical-slot": 5, "int-valued-ta": 0, "spec-abi": 3 + }, + "unconsumed_mechanisms": { + "module_init_context": 1 } }, { @@ -139,6 +153,7 @@ "source": "benchmarks/suite/01_startup.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -153,7 +168,8 @@ "canonical-slot": 0, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_02_loop_overhead", @@ -161,6 +177,7 @@ "source": "benchmarks/suite/02_loop_overhead.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -175,7 +192,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_03_array_write", @@ -183,6 +201,7 @@ "source": "benchmarks/suite/03_array_write.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 1, "canonical-i32": 0, "canonical-u32": 0, @@ -197,7 +216,8 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_04_array_read", @@ -205,6 +225,7 @@ "source": "benchmarks/suite/04_array_read.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 1, "canonical-i32": 0, "canonical-u32": 0, @@ -219,7 +240,8 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_05_fibonacci", @@ -227,6 +249,7 @@ "source": "benchmarks/suite/05_fibonacci.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -241,7 +264,8 @@ "canonical-slot": 1, "int-valued-ta": 0, "spec-abi": 1 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_06_math_intensive", @@ -249,6 +273,7 @@ "source": "benchmarks/suite/06_math_intensive.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -263,7 +288,8 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_07_object_create", @@ -271,6 +297,7 @@ "source": "benchmarks/suite/07_object_create.ts", "floors": { "ptr-shape": 1, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -285,6 +312,9 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 + }, + "unconsumed_mechanisms": { + "scalar_replaced": 1 } }, { @@ -293,6 +323,7 @@ "source": "benchmarks/suite/08_string_concat.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -307,7 +338,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_09_method_calls", @@ -315,6 +347,7 @@ "source": "benchmarks/suite/09_method_calls.ts", "floors": { "ptr-shape": 1, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -329,6 +362,9 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 + }, + "unconsumed_mechanisms": { + "module_init_context": 1 } }, { @@ -337,6 +373,7 @@ "source": "benchmarks/suite/10_nested_loops.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 1, "canonical-i32": 0, "canonical-u32": 0, @@ -351,7 +388,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_11_prime_sieve", @@ -359,6 +397,7 @@ "source": "benchmarks/suite/11_prime_sieve.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -373,7 +412,8 @@ "canonical-slot": 4, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_12_binary_trees", @@ -381,6 +421,7 @@ "source": "benchmarks/suite/12_binary_trees.ts", "floors": { "ptr-shape": 1, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -395,6 +436,9 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 + }, + "unconsumed_mechanisms": { + "scalar_replaced": 1 } }, { @@ -403,6 +447,7 @@ "source": "benchmarks/suite/13_factorial.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -417,7 +462,8 @@ "canonical-slot": 2, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_14_closure", @@ -425,6 +471,7 @@ "source": "benchmarks/suite/14_closure.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -439,7 +486,8 @@ "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_15_mandelbrot", @@ -447,6 +495,7 @@ "source": "benchmarks/suite/15_mandelbrot.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 0, "canonical-u32": 0, @@ -461,7 +510,8 @@ "canonical-slot": 7, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_16_matrix_multiply", @@ -469,6 +519,7 @@ "source": "benchmarks/suite/16_matrix_multiply.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 0, "canonical-i32": 3, "canonical-u32": 0, @@ -483,7 +534,8 @@ "canonical-slot": 7, "int-valued-ta": 0, "spec-abi": 1 - } + }, + "unconsumed_mechanisms": {} }, { "name": "suite_17_loop_data_dependent", @@ -491,6 +543,7 @@ "source": "benchmarks/suite/17_loop_data_dependent.ts", "floors": { "ptr-shape": 0, + "ptr-shape-consumed": 0, "ptr-numarray": 1, "canonical-i32": 0, "canonical-u32": 0, @@ -505,8 +558,9 @@ "canonical-slot": 5, "int-valued-ta": 0, "spec-abi": 0 - } + }, + "unconsumed_mechanisms": {} } ], - "generated_at": "2026-07-31T03:14:57.355324Z" + "generated_at": "2026-07-31T04:32:15.277779Z" } diff --git a/changelog.d/7117-repsel-consumed-census.md b/changelog.d/7117-repsel-consumed-census.md new file mode 100644 index 0000000000..f76c34ea0a --- /dev/null +++ b/changelog.d/7117-repsel-consumed-census.md @@ -0,0 +1,98 @@ +The representation-selection census (#7104, #7113) counted `select()` calls. +That is the wrong quantity: a promotion can be selected, reported as a win, and +produce **literally nothing**. It now counts promotions codegen actually +*consumed*, and names the mechanism behind every one it did not. + +`batch.ts` reports two `Ptr` promotions and applies one. `totals` is +proven, counted as a win, and keeps the guarded diamond at every access site; +the entire 1,532-byte binary saving in #7107 came from `acc` alone. That was +found by reading emitted IR, never by the report. + +Worse, the three census workloads that promote (`07_object_create`, +`09_method_calls`, `12_binary_trees`) were proposed as the cleanest available +experiment for measuring what a promotion is worth. **All three would have +measured 0.00%** — each declares its promoted local at module top level, and +`07`/`12` are additionally scalar-replaced. Compiling them with +`PERRY_PTR_SHAPE_LOCALS=0` and with the default produces a byte-identical +object. `09` differs only by two dead `__pshape` clones with zero call sites. + +## The numbers + +Across the 23-workload corpus, `Ptr`: **6 selected, 2 consumed.** Four +proven and thrown away — two by the module-init context gate (#7109), two by +scalar replacement (#7115). No unexplained residue. + +| workload | selected | consumed | mechanism | +|---|---|---|---| +| `fixture_ptr_shape` | 1 | 1 | — | +| `batch` | 2 | 1 | `module_init_context` | +| `suite_07_object_create` | 1 | 0 | `scalar_replaced` | +| `suite_09_method_calls` | 1 | 0 | `module_init_context` | +| `suite_12_binary_trees` | 1 | 0 | `scalar_replaced` | + +## Three ways a promotion goes unconsumed + +1. **The module-init / program-entry context gate** (#7109). `codegen/entry.rs` + sets `repsel_context_allows_canonical_i32: false`, and + `FnCtx::ptr_shape_receiver_fact` returns `None` for the whole body when that + flag is clear — so an env knob for a *different* representation phase + silently disables `Ptr` consumption too. +2. **The same gate in async / generator bodies** (#6328). +3. **Scalar replacement deleted the object** (`collectors/escape_news.rs`) — + previously undocumented, now **#7115**. Not a defect: deleting the + allocation beats promoting it, and the passes are complementary (one in-loop + field store flips a workload from scalar-replaced to `Ptr`-consumed). + The defect was that "scalar-replaced" and "promoted but wasted" rendered + identically and mean opposite things. + +## What changed + +- `Outcome` gains `Consumed` / `Unconsumed`. Consumption is recorded at the six + codegen sites that *commit* to the guard-free lowering — never at a + `select()`-adjacent site, which would rebuild the same illusion one layer + down. `outcome` (and, for consumed entries, the consuming site) joins + `dedup_key`; without it every consumption record collapsed into its own + selection and the tally was structurally pinned at zero. +- `--opt-report` schema → **2**. `selected` keeps its meaning but explicitly + stops implying emitted bytes, so this is a meaning change, not an additive + field. The text report grows a "Selected but NOT consumed" section. +- The census gains a `ptr-shape-consumed` column with its own floors, its own + liveness minimum in `LIVENESS_FLOORS`, and three new failure modes: + consumption recorded outside the selected population, consumption counted per + access site instead of per value, and **wasted promotions that name no + mechanism** — the last is what makes deleting a drop-recorder visible. +- `CONSUMPTION_INSTRUMENTED` lives in the script, not the regenerable baseline. + Only `ptr-shape` is instrumented; the other seven keys report *no consumption + data* rather than a zero, because "uninstrumented" and "never applied" are the + exact pair this census exists to keep apart. + +No existing floor was lowered; `batch`'s ratcheted `ptr-shape: 2` is untouched +and is now paired with `ptr-shape-consumed: 1`. + +## Verification + +- **Byte-neutral.** 23/23 workloads emit byte-identical objects with the report + off vs on, and 23/23 between the pre-change and post-change compilers with the + report off. +- **Ground truth is IR, not a counter.** Every consumed/unconsumed verdict was + checked against an independent oracle: compile each workload with + `PERRY_PTR_SHAPE_LOCALS=0` and with the default and hash the emitted object. + The census agrees with it on all five promoting workloads, including the one + case where the oracle is a superset (`09` differs only by dead clones). +- **Sabotage-verified in both directions**, all arms checking the *harness's* + exit code: dropping `outcome` from `dedup_key`, removing all six consumption + recorders, removing the context-drop recorder, removing the scalar-replacement + recorder, counting per access site, folding proven-`this` consumption into the + local column, and deleting the consumed liveness minimum each turn the gate + red; the unmodified tree is green. The five pre-existing `PERRY_*_LOCALS=0` + sabotages still go red, and CI's sabotage step now additionally asserts that + `ptr-shape-consumed` tracks the compiler. + +## Not measured + +Consumption is instrumented for `Ptr` only. Canonical i32/u32 moves the +storage, so consumption is structural there; canonical `Str` is a proof-only rep +and can be selected-and-unconsumed exactly like `Ptr`, but its consumers +are the string-op lowerings and were out of scope. Phase 5a's proven-`this` +receiver is consumed but never selected at all, so it is reported separately and +excluded from the column rather than folded in. diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index 6c1fdce7c2..f817a52f88 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -223,6 +223,8 @@ pub(crate) fn method_proven_this( class_name: class.name.clone(), // See the module doc: never claimed for a proven `this`. numeric_fields: HashSet::new(), + // Phase 5a's promoted value is the receiver, not a named binding. + report_name: crate::opt_report::enabled().then(|| String::from("this")), }) } @@ -274,6 +276,7 @@ mod tests { let fact = || PtrShapeLocal { class_name: "C".to_string(), numeric_fields: HashSet::new(), + report_name: None, }; let k = |m: &str| ("C".to_string(), m.to_string()); let mut method_names = HashMap::new(); diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 7383bf0a0b..598eca0592 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -138,6 +138,15 @@ pub struct PtrShapeLocal { /// number-producing: bare loads may claim `JsNumber`/`F64`. Other fields' /// bare loads surface as generic `JsValue` (bit-identical). pub numeric_fields: HashSet, + /// Source binding name, for `--opt-report` only. + /// + /// `Some` exclusively when [`crate::opt_report::enabled`] — an ordinary + /// build allocates nothing for it. It exists because CONSUMPTION is + /// recorded at property-access sites, which see only an + /// `Expr::LocalGet(id)`: without the name on the fact the report could say + /// "some local was consumed" but never "`totals` was **not**", and naming + /// the value is the entire point of the distinction. + pub report_name: Option, } /// Whether an expression node is a §5.2 shape barrier for the module-wide @@ -479,6 +488,13 @@ pub(crate) fn collect_shape_proven_ptr_locals( let fact = PtrShapeLocal { class_name: class_name.clone(), numeric_fields, + // Only when the report is on; an ordinary build allocates nothing. + report_name: opt_report::enabled().then(|| { + names + .get(id) + .cloned() + .unwrap_or_else(|| format!("")) + }), }; note_ptr_shape_local(*id, &fact, &names, &depths); // Aliases carry the same fact: they hold the same object, their slots @@ -1818,155 +1834,12 @@ fn prove_numeric_fields( numeric } -/// Number-by-construction: the expression's runtime value is a JS Number for -/// every input, per spec — never a string/BigInt/bool/undefined/pointer. -fn expr_numeric_by_construction( - e: &Expr, - param_env: &ParamEnv<'_>, - members: &HashSet, - numeric_fields: &HashSet, - not_bigint_locals: &HashSet, - const_local_inits: &HashMap>, - depth: usize, -) -> bool { - if depth > 16 { - return false; - } - use perry_hir::BinaryOp; - let rec = |x: &Expr| { - expr_numeric_by_construction( - x, - param_env, - members, - numeric_fields, - not_bigint_locals, - const_local_inits, - depth + 1, - ) - }; - match e { - Expr::Number(_) | Expr::Integer(_) => true, - Expr::Unary { op, operand } => match op { - perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot => { - rec(operand) - } - _ => false, - }, - Expr::Binary { op, left, right } => match op { - // `+` concatenates strings; both sides must be numbers. - BinaryOp::Add => rec(left) && rec(right), - // `- * / %` produce BigInt only for BigInt⊗BigInt; a provably - // non-BigInt operand forces the Number path. - // `- * / %` produce a BigInt only for BigInt⊗BigInt; mixing a - // BigInt with anything else THROWS (no value is stored). ONE - // provably-non-BigInt operand therefore forces the completed - // result onto the Number path. - BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { - (rec(left) && rec(right)) - || expr_provably_not_bigint(left, not_bigint_locals) - || expr_provably_not_bigint(right, not_bigint_locals) - } - // Same either-side argument for the BigInt-capable bitwise ops. - BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr => { - (rec(left) && rec(right)) - || expr_provably_not_bigint(left, not_bigint_locals) - || expr_provably_not_bigint(right, not_bigint_locals) - } - // `>>>` throws for BigInt operands; result is always a Number. - BinaryOp::UShr => true, - _ => false, - }, - Expr::NumberCoerce(_) - | Expr::ParseFloat(_) - | Expr::ParseInt { .. } - | Expr::MathSqrt(_) - | Expr::MathFloor(_) - | Expr::MathCeil(_) - | Expr::MathRound(_) - | Expr::MathTrunc(_) - | Expr::MathSign(_) - | Expr::MathAbs(_) - | Expr::MathF16round(_) - | Expr::MathPow(..) - | Expr::MathMin(_) - | Expr::MathMax(_) - | Expr::MathMinSpread(_) - | Expr::MathMaxSpread(_) - | Expr::DateNow - | Expr::PerformanceNow => true, - // A proven-numeric field of the SAME object (fixpoint edge): `this` - // inside the candidate's ctor/method contexts (a non-None param env), - // or a tracked member local in function scope. A same-named field of - // a DIFFERENT object proves nothing. - Expr::PropertyGet { - object, property, .. - } if match object.as_ref() { - Expr::This => !matches!(param_env, ParamEnv::None), - Expr::LocalGet(id) => members.contains(id), - _ => false, - } => - { - numeric_fields.contains(property) - } - Expr::Conditional { - then_expr, - else_expr, - .. - } => rec(then_expr) && rec(else_expr), - Expr::Sequence(es) => es.last().map(|x| rec(x)).unwrap_or(false), - // A parameter: numeric iff every recorded call site passes a numeric - // argument at that position (missing argument = `undefined`, not - // numeric). No recorded sites = unproven. - Expr::LocalGet(id) => { - match param_env { - ParamEnv::Sites { param_ids, sites } => { - if let Some(pos) = param_ids.iter().position(|p| p == id) { - return !sites.is_empty() - && sites.iter().all(|args| { - args.get(pos).map(|a| { - expr_numeric_by_construction( - a, - &ParamEnv::None, - members, - numeric_fields, - not_bigint_locals, - const_local_inits, - depth + 1, - ) - }) == Some(true) - }); - } - } - ParamEnv::Resolved(env) => { - if let Some(&ok) = env.get(id) { - return ok; - } - } - ParamEnv::None => { - // A single-Let const temp: chase its init (function - // scope, so no parameter mapping applies to it). - if let Some(Some(init)) = const_local_inits.get(id) { - return expr_numeric_by_construction( - init, - &ParamEnv::None, - members, - numeric_fields, - not_bigint_locals, - const_local_inits, - depth + 1, - ); - } - } - } - false - } - _ => false, - } -} +/// Number-by-construction proof for the numeric-field rule. Split out to +/// stay under the 2000-line CI gate; still a child module, so `use super::*` +/// reaches the collector's private items. +#[path = "ptr_shape_numeric.rs"] +mod numeric; +use numeric::expr_numeric_by_construction; /// Conservative "cannot be a BigInt" for the spec Number-path argument. fn expr_provably_not_bigint(e: &Expr, not_bigint_locals: &HashSet) -> bool { diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs new file mode 100644 index 0000000000..b11ab10c61 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -0,0 +1,158 @@ +//! Number-by-construction proof for `collectors/ptr_shape.rs`'s numeric-field +//! rule: does this expression evaluate to a JS Number for every input, per +//! spec, never a string / BigInt / bool / undefined / pointer? +//! +//! Split out of `ptr_shape.rs` to stay under the 2000-line CI gate; declared +//! there with `#[path]` so it remains a child module and can reach the +//! collector's private items through `use super::*`. + +use super::*; +/// Number-by-construction: the expression's runtime value is a JS Number for +/// every input, per spec — never a string/BigInt/bool/undefined/pointer. +pub(super) fn expr_numeric_by_construction( + e: &Expr, + param_env: &ParamEnv<'_>, + members: &HashSet, + numeric_fields: &HashSet, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + depth: usize, +) -> bool { + if depth > 16 { + return false; + } + use perry_hir::BinaryOp; + let rec = |x: &Expr| { + expr_numeric_by_construction( + x, + param_env, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ) + }; + match e { + Expr::Number(_) | Expr::Integer(_) => true, + Expr::Unary { op, operand } => match op { + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot => { + rec(operand) + } + _ => false, + }, + Expr::Binary { op, left, right } => match op { + // `+` concatenates strings; both sides must be numbers. + BinaryOp::Add => rec(left) && rec(right), + // `- * / %` produce BigInt only for BigInt⊗BigInt; a provably + // non-BigInt operand forces the Number path. + // `- * / %` produce a BigInt only for BigInt⊗BigInt; mixing a + // BigInt with anything else THROWS (no value is stored). ONE + // provably-non-BigInt operand therefore forces the completed + // result onto the Number path. + BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { + (rec(left) && rec(right)) + || expr_provably_not_bigint(left, not_bigint_locals) + || expr_provably_not_bigint(right, not_bigint_locals) + } + // Same either-side argument for the BigInt-capable bitwise ops. + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr => { + (rec(left) && rec(right)) + || expr_provably_not_bigint(left, not_bigint_locals) + || expr_provably_not_bigint(right, not_bigint_locals) + } + // `>>>` throws for BigInt operands; result is always a Number. + BinaryOp::UShr => true, + _ => false, + }, + Expr::NumberCoerce(_) + | Expr::ParseFloat(_) + | Expr::ParseInt { .. } + | Expr::MathSqrt(_) + | Expr::MathFloor(_) + | Expr::MathCeil(_) + | Expr::MathRound(_) + | Expr::MathTrunc(_) + | Expr::MathSign(_) + | Expr::MathAbs(_) + | Expr::MathF16round(_) + | Expr::MathPow(..) + | Expr::MathMin(_) + | Expr::MathMax(_) + | Expr::MathMinSpread(_) + | Expr::MathMaxSpread(_) + | Expr::DateNow + | Expr::PerformanceNow => true, + // A proven-numeric field of the SAME object (fixpoint edge): `this` + // inside the candidate's ctor/method contexts (a non-None param env), + // or a tracked member local in function scope. A same-named field of + // a DIFFERENT object proves nothing. + Expr::PropertyGet { + object, property, .. + } if match object.as_ref() { + Expr::This => !matches!(param_env, ParamEnv::None), + Expr::LocalGet(id) => members.contains(id), + _ => false, + } => + { + numeric_fields.contains(property) + } + Expr::Conditional { + then_expr, + else_expr, + .. + } => rec(then_expr) && rec(else_expr), + Expr::Sequence(es) => es.last().map(|x| rec(x)).unwrap_or(false), + // A parameter: numeric iff every recorded call site passes a numeric + // argument at that position (missing argument = `undefined`, not + // numeric). No recorded sites = unproven. + Expr::LocalGet(id) => { + match param_env { + ParamEnv::Sites { param_ids, sites } => { + if let Some(pos) = param_ids.iter().position(|p| p == id) { + return !sites.is_empty() + && sites.iter().all(|args| { + args.get(pos).map(|a| { + expr_numeric_by_construction( + a, + &ParamEnv::None, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ) + }) == Some(true) + }); + } + } + ParamEnv::Resolved(env) => { + if let Some(&ok) = env.get(id) { + return ok; + } + } + ParamEnv::None => { + // A single-Let const temp: chase its init (function + // scope, so no parameter mapping applies to it). + if let Some(Some(init)) = const_local_inits.get(id) { + return expr_numeric_by_construction( + init, + &ParamEnv::None, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ); + } + } + } + false + } + _ => false, + } +} diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 57256779c5..0a206a77ab 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -1417,6 +1417,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { property, ) { + ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_update"); let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); let header_skip = crate::target_layout::object_header_size_bytes( diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 156321d87b..747dcbc123 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -141,8 +141,9 @@ pub(crate) use slot_rep::{ canonical_str_locals_enabled, collect_canonical_str_ineligible_locals, collect_closure_referenced_locals, deny_canonical_context, deny_canonical_i32, load_canonical_local_boxed, local_is_canonical_str, local_rep_is_canonical_i32, - note_canonical_local, report_context_denial, store_canonical_local_from_double, - CanonicalI32Denial, SlotRep, MODULE_INIT_CONTEXT, + note_canonical_local, ptr_shape_context_rule_text, report_context_denial, + store_canonical_local_from_double, CanonicalI32Denial, SlotRep, MODULE_INIT_CONTEXT, + PTR_SHAPE_SCALAR_REPLACED, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; @@ -1499,6 +1500,15 @@ impl<'a> FnCtx<'a> { e: &perry_hir::Expr, ) -> Option<&crate::collectors::PtrShapeLocal> { if !self.repsel_context_allows_canonical_i32 { + // #7106 follow-up: this early return is the whole of mechanism 2. + // The fact EXISTS — `collect_shape_proven_ptr_locals` already ran + // and already recorded a `select()` for it — and every access site + // below silently falls through to the guarded diamond. Recording + // it is what stops a proven-and-wasted value from reading exactly + // like a proven-and-applied one in the census. + if crate::opt_report::enabled() { + self.report_ptr_shape_context_drop(e); + } return None; } match e { @@ -1508,6 +1518,93 @@ impl<'a> FnCtx<'a> { } } + /// The `Ptr` fact for `e` ignoring the context gate — the proof the + /// analysis actually produced, as opposed to the proof codegen is allowed + /// to act on. Report-only. + fn ptr_shape_fact_ignoring_context( + &self, + e: &perry_hir::Expr, + ) -> Option<&crate::collectors::PtrShapeLocal> { + match e { + perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id), + perry_hir::Expr::This => self.proven_this.as_ref(), + _ => None, + } + } + + /// Record that a selected `Ptr` proof was dropped by the context + /// gate (`repsel_context_allows_canonical_i32 == false`). + /// + /// Deliberately silent when the context permits the representation and only + /// the `PERRY_CANONICAL_I32_LOCALS` bisection knob turned it off: that arm + /// must produce the default build's entries minus the selections, never a + /// class of entry the default build cannot emit (same rule as + /// `slot_rep::body_context_denial`). + fn report_ptr_shape_context_drop(&self, e: &perry_hir::Expr) { + let Some(rule) = self.repsel_context_denial else { + return; + }; + let Some(fact) = self.ptr_shape_fact_ignoring_context(e) else { + return; + }; + let (position, fallback) = match e { + perry_hir::Expr::This => (crate::opt_report::Position::Param, "this"), + _ => (crate::opt_report::Position::Local, ""), + }; + let local_id = match e { + perry_hir::Expr::LocalGet(id) => Some(*id), + _ => None, + }; + let name = fact.report_name.as_deref().unwrap_or(fallback); + let (reason, issue) = crate::expr::ptr_shape_context_rule_text(rule); + crate::opt_report::unconsumed(crate::opt_report::Unconsumed { + position, + name, + local_id, + analysis: crate::opt_report::Analysis::PtrShape, + rep: "Ptr", + rule, + reason, + tier: crate::opt_report::Tier::CompilerLimitation, + issue: Some(issue), + detail: Some(format!( + "proven Ptr of class {}; every access site keeps the guard diamond", + fact.class_name + )), + }); + } + + /// Record that codegen COMMITTED to a `Ptr` lowering for `e`. + /// + /// Call from the taken branch of a site that has already decided to emit + /// the guard-free form — never from the accessor, which answers `Some` at + /// sites that then reject the fact on a class or numeric-field mismatch and + /// emit the guarded diamond anyway. + pub(crate) fn note_ptr_shape_consumed(&self, e: &perry_hir::Expr, site: &'static str) { + if !crate::opt_report::enabled() { + return; + } + let Some(fact) = self.ptr_shape_fact_ignoring_context(e) else { + return; + }; + let (position, fallback) = match e { + perry_hir::Expr::This => (crate::opt_report::Position::Param, "this"), + _ => (crate::opt_report::Position::Local, ""), + }; + let local_id = match e { + perry_hir::Expr::LocalGet(id) => Some(*id), + _ => None, + }; + crate::opt_report::consume( + position, + fact.report_name.as_deref().unwrap_or(fallback), + local_id, + crate::opt_report::Analysis::PtrShape, + "Ptr", + site, + ); + } + pub fn next_loop_proof_scope_id(&mut self) -> u32 { let id = self.next_loop_proof_scope_id; self.next_loop_proof_scope_id = self diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index c44df691a4..9b3c6d21dd 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1316,6 +1316,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .filter(|fact| fact.class_name == class_name) .cloned(); if let Some(fact) = ptr_shape_fact { + ctx.note_ptr_shape_consumed( + object.as_ref(), + "class_field_get.shape_proven_load", + ); let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); let header_skip = diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index bbc8722b6e..41acd3ebb7 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -433,6 +433,7 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( .map(|fact| fact.class_name == class_name && fact.numeric_fields.contains(property)) .unwrap_or(false); if ptr_shape_numeric { + ctx.note_ptr_shape_consumed(object.as_ref(), "class_field_get_number.shape_proven_load"); let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); let header_skip = @@ -504,6 +505,7 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( .map(|fact| fact.class_name == class_name) .unwrap_or(false); if ptr_shape_proven_shape { + ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_get_number"); let recv_box = lower_expr(ctx, object)?; let field_idx_str = field_index.to_string(); let header_skip = diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 4fe3f85a86..9d12aa7193 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -554,6 +554,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .map(|fact| fact.class_name == class_name) .unwrap_or(false); if ptr_shape_proven { + ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_set"); let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple) .to_string(); diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 9ac6ebf45b..1dea0b2ec1 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -383,6 +383,50 @@ fn context_rule_text(rule: &str) -> (&'static str, &'static str) { } } +/// `Ptr` consumption rule: the object was scalar-replaced, so there is +/// no object left for the representation to be applied to. +/// +/// Not a defect. Scalar replacement (`collectors/escape_news.rs`) is a strictly +/// better outcome than promotion when it applies — the allocation disappears +/// entirely — and the two passes are complementary: one in-loop field store is +/// enough to flip a workload from scalar-replaced to `Ptr`-consumed. +/// What is a defect is that it is INDISTINGUISHABLE in the report from a +/// promotion that was wasted, and the two mean opposite things. +pub(crate) const PTR_SHAPE_SCALAR_REPLACED: &str = "scalar_replaced"; + +/// `(reason, issue)` for a rule that stopped a *selected* `Ptr` proof +/// from being consumed by codegen. +pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static str) { + match rule { + MODULE_INIT_CONTEXT => ( + "module-init / program-entry bodies set \ + `repsel_context_allows_canonical_i32: false` (codegen/entry.rs), and \ + `FnCtx::ptr_shape_receiver_fact` returns None for the whole body when \ + that flag is clear — so the shape proof was made, counted as a win, \ + and then dropped at every access site", + "#7109", + ), + PTR_SHAPE_SCALAR_REPLACED => ( + "the object was scalar-replaced (collectors/escape_news.rs): its fields \ + became allocas and the allocation was deleted, so no property access \ + ever reaches a representation-selection lowering. A better outcome \ + than promotion, but the report counted a promotion that emitted \ + nothing", + PTR_SHAPE_SCALAR_REPLACED_ISSUE, + ), + _ => ( + "async / generator bodies set `repsel_context_allows_canonical_i32: \ + false`, and `FnCtx::ptr_shape_receiver_fact` returns None for the \ + whole body when that flag is clear — the async-to-generator transform \ + owns those body locals", + "#6328", + ), + } +} + +/// Tracking issue for the scalar-replacement consumption mechanism. +const PTR_SHAPE_SCALAR_REPLACED_ISSUE: &str = "#7115"; + pub(crate) fn deny_canonical_context( ctx: &FnCtx<'_>, id: u32, diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index ee6d96d104..9865a3b7c7 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -869,6 +869,7 @@ pub(crate) fn try_lower_instance_method_call( .map(|fact| fact.class_name == class_name) .unwrap_or(false); if ptr_shape_receiver && !fallback_fn.starts_with("perry_static_") { + ctx.note_ptr_shape_consumed(object, "ptr_shape_method"); // Prefer the typed-receiver clone (bare gep+load field // access inside the body) when one exists: the receiver // is proven, so only the ARGUMENT value classes need diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index f0d9c7f069..c20c8c2da1 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -296,9 +296,27 @@ impl Tier { #[serde(rename_all = "kebab-case")] pub enum Outcome { /// The proof succeeded — the value got an unboxed representation. + /// + /// **Selection is not application.** A `Selected` entry says an analysis + /// proved the value; it says nothing about whether codegen went on to emit + /// anything different for it. See [`Outcome::Consumed`]. Selected, /// The proof failed — the value stays `Boxed`. Denied, + /// Codegen actually *applied* a selected proof: it emitted the + /// representation-specific, guard-free form at a real access site. + /// + /// This is the outcome that corresponds to emitted bytes. #7107 found by + /// reading IR that `batch.ts` reports two `Ptr` promotions while one + /// of them (`totals`) keeps the guarded diamond at every access site — the + /// entire binary saving came from the other. Counting `select()` calls + /// cannot see that; counting consumption can. + Consumed, + /// Codegen reached a selected value and **dropped the proof**, emitting the + /// same code it would have emitted without the analysis. `rule` names the + /// mechanism; a selected value that is never consumed and never explicitly + /// dropped simply had no access site to apply the proof at. + Unconsumed, } /// One reported value. @@ -361,14 +379,47 @@ impl Entry { /// Identity for de-duplication. A function can be lowered more than once /// (a boxed entry plus a typed clone), which would otherwise double-count /// every denial in it. - fn dedup_key(&self) -> (String, String, String, Position, Analysis, Option) { + /// + /// `outcome` is part of the key, and must stay part of it: a `Consumed` + /// entry and the `Selected` entry for the same value agree on every other + /// field (both carry `rule: None`), so without it the consumption record + /// for a value collapses into its selection record and the consumed tally + /// is structurally pinned at zero — a dead counter of exactly the kind + /// this outcome exists to expose. + fn dedup_key( + &self, + ) -> ( + String, + String, + String, + Position, + Analysis, + Outcome, + Option, + Option, + ) { ( self.module.clone(), self.function.clone(), self.name.clone(), self.position, self.analysis, + self.outcome, self.rule.clone(), + // `detail` participates for CONSUMED entries only, where it names + // the lowering that applied the proof. One value consumed at three + // access sites is genuinely three facts worth reporting, and + // collapsing them here would leave the census's own per-value + // reduction untestable end-to-end — an unexercised branch standing + // between the compiler and the number that gets published. + // + // Every other outcome keeps the pre-existing identity, so denial + // and selection tallies (and therefore the baseline's `candidates` + // column) are unchanged. + match self.outcome { + Outcome::Consumed => self.detail.clone(), + _ => None, + }, ) } } @@ -708,6 +759,121 @@ pub(crate) fn select_explicit( }); } +// ── Consumption (#7106 follow-up) ────────────────────────────────────────── +// +// `select()` records that an analysis PROVED a value. Whether codegen then +// emitted anything different for it is a separate question with a separate +// answer, and the two were conflated until #7107 read the IR by hand. +// +// Nothing here may be recorded from a `select()`-adjacent site. Both entry +// points below are called from the codegen sites that *commit* to (or *drop*) +// the representation-specific lowering — the same `if` whose taken branch is +// what makes the guard diamond disappear from the emitted module. That is what +// keeps the count checkable against IR instead of against another counter. + +/// Module / function / region of the lowering region currently being emitted. +/// +/// Consumption is recorded from inside body lowering, which always runs under +/// the region scope its caller opened (`enter_region` / `enter_closure` guards +/// are held across the whole `lower_*` call). Taking the region from the +/// ambient scope rather than re-deriving it from a function name is what makes +/// `region == module-init` trustworthy — and module-init is the context whose +/// promotions are the ones that go unconsumed. +fn scope_or_unknown() -> (String, String, RegionKind) { + SCOPE.with(|s| match s.borrow().as_ref() { + Some(sc) => (sc.module.clone(), sc.function.clone(), sc.region), + None => ( + String::from(""), + String::from(""), + RegionKind::Function, + ), + }) +} + +/// Record that codegen applied a selected proof at a real access site. +/// +/// `site` names the lowering that consumed it (`class_field_get`, +/// `class_method_call`, …) so a reader can find the emitted sequence. +pub(crate) fn consume( + position: Position, + name: &str, + local_id: Option, + analysis: Analysis, + rep: &str, + site: &str, +) { + if !enabled() { + return; + } + let (module, function, region) = scope_or_unknown(); + push(Entry { + module, + function, + region, + position, + name: name.to_string(), + local_id, + analysis, + outcome: Outcome::Consumed, + rep: rep.to_string(), + rule: None, + reason: None, + tier: None, + issue: None, + loop_depth: 0, + invoked_per_element: None, + detail: Some(format!("consumed at {site}")), + byte_offset: None, + }); +} + +/// Everything an unconsumed record needs beyond the ambient scope. +pub(crate) struct Unconsumed<'a> { + pub position: Position, + pub name: &'a str, + pub local_id: Option, + pub analysis: Analysis, + pub rep: &'a str, + /// The mechanism that dropped the proof, e.g. `module_init_context`. + pub rule: &'a str, + pub reason: &'a str, + pub tier: Tier, + pub issue: Option<&'a str>, + pub detail: Option, +} + +/// Record that codegen reached a selected value and dropped its proof. +/// +/// The distinction from [`deny`] is the whole point: a denial says the analysis +/// refused to prove the value, so no representation was ever selected. This +/// says the analysis DID prove it, the report counted it as a win, and codegen +/// then emitted byte-for-byte what it would have emitted anyway. +pub(crate) fn unconsumed(u: Unconsumed<'_>) { + if !enabled() { + return; + } + let (module, function, region) = scope_or_unknown(); + push(Entry { + module, + function, + region, + position: u.position, + name: u.name.to_string(), + local_id: u.local_id, + analysis: u.analysis, + outcome: Outcome::Unconsumed, + rep: u.rep.to_string(), + rule: Some(u.rule.to_string()), + reason: Some(u.reason.to_string()), + tier: Some(u.tier), + issue: u.issue.map(str::to_string), + loop_depth: 0, + invoked_per_element: None, + detail: u.detail, + byte_offset: None, + }); +} + /// Drain every recorded entry, de-duplicated and ranked. Called once by the /// CLI after module codegen finishes. pub fn take_entries() -> Vec { @@ -788,6 +954,49 @@ mod tests { assert_eq!(a.dedup_key(), b.dedup_key()); } + /// The trap this outcome exists to avoid, at the data-structure level. + /// + /// A `Consumed` entry and the `Selected` entry for the same value agree on + /// module, function, name, position, analysis AND rule (both `None`). If + /// `outcome` is not part of the identity, `take_entries` drops every + /// consumption record as a duplicate of its own selection and the consumed + /// tally is pinned at zero — a dead counter that looks like an honest + /// "nothing was consumed". + #[test] + fn dedup_key_separates_a_consumption_from_its_own_selection() { + let mut selected = entry("f", 0, None); + selected.outcome = Outcome::Selected; + selected.rule = None; + let mut consumed = selected.clone(); + consumed.outcome = Outcome::Consumed; + assert_ne!( + selected.dedup_key(), + consumed.dedup_key(), + "a consumption must survive de-duplication against its selection" + ); + + let kept = { + let mut seen = std::collections::HashSet::new(); + [selected, consumed] + .into_iter() + .filter(|e| seen.insert(e.dedup_key())) + .count() + }; + assert_eq!(kept, 2, "de-duplication swallowed the consumption record"); + } + + /// An `Unconsumed` record must likewise not collapse into the `Denied` + /// record for the same value: they carry opposite meanings (the proof was + /// made and dropped vs the proof was never made) and a workload can + /// legitimately produce both for one binding under different analyses. + #[test] + fn dedup_key_separates_an_unconsumed_record_from_a_denial() { + let denied = entry("f", 0, None); + let mut unconsumed = denied.clone(); + unconsumed.outcome = Outcome::Unconsumed; + assert_ne!(denied.dedup_key(), unconsumed.dedup_key()); + } + #[test] fn dedup_key_separates_different_rules() { let a = entry("f", 0, None); diff --git a/crates/perry-codegen/src/opt_report/render.rs b/crates/perry-codegen/src/opt_report/render.rs index 8854e8a4c3..9b76a5e031 100644 --- a/crates/perry-codegen/src/opt_report/render.rs +++ b/crates/perry-codegen/src/opt_report/render.rs @@ -14,7 +14,14 @@ use super::{Analysis, Entry, Outcome, Tier}; /// Bump when a field is removed or its meaning changes. Additive fields do /// not require a bump — consumers must ignore unknown keys. -pub const SCHEMA_VERSION: u32 = 1; +/// +/// v2 (#7106 follow-up) splits a promotion's *selection* from its +/// *consumption*. `selected` keeps its old meaning (an analysis proved the +/// value) but explicitly stops implying that codegen emitted anything for it; +/// `consumed` / `unconsumed` carry that. A consumer that keyed performance +/// claims off `selected` under v1 was reading a number that does not mean what +/// it looks like, so this is a meaning change and takes a bump. +pub const SCHEMA_VERSION: u32 = 2; /// How many denials to show per tier before collapsing the tail. The cold /// tail is real information but it is not the actionable part. @@ -24,6 +31,12 @@ const MAX_ROWS_PER_TIER: usize = 25; struct AnalysisTally { selected: usize, denied: usize, + /// Selected values codegen actually applied the representation to. + consumed: usize, + /// Selected values codegen reached and dropped the proof for, with a named + /// mechanism. `selected - consumed - unconsumed` is the residue: values + /// with no access site at all, which no mechanism can be blamed for. + unconsumed: usize, } impl AnalysisTally { @@ -39,6 +52,8 @@ fn tally(entries: &[Entry]) -> BTreeMap { match e.outcome { Outcome::Selected => slot.selected += 1, Outcome::Denied => slot.denied += 1, + Outcome::Consumed => slot.consumed += 1, + Outcome::Unconsumed => slot.unconsumed += 1, } } out @@ -94,6 +109,19 @@ pub fn render_text(entries: &[Entry]) -> String { t.denied, t.candidates(), ); + if t.consumed > 0 || t.unconsumed > 0 { + let _ = writeln!( + out, + " {:<16} {:>4} of those selections were CONSUMED by codegen{}", + "", + t.consumed, + if t.unconsumed > 0 { + format!(", {} dropped unused", t.unconsumed) + } else { + String::new() + }, + ); + } } let _ = writeln!( out, @@ -123,6 +151,49 @@ pub fn render_text(entries: &[Entry]) -> String { ); } + // ── Wasted promotions ────────────────────────────────────────────────── + // A selection that codegen dropped is worse than a denial: a denial names a + // rule and shows up as a zero, while this shows up as a WIN. #7107 found by + // reading IR that `batch.ts` reports two `Ptr` promotions and applies + // one. Nothing in the report said so. + let wasted: Vec<&Entry> = entries + .iter() + .filter(|e| e.outcome == Outcome::Unconsumed) + .collect(); + if !wasted.is_empty() { + let _ = writeln!( + out, + "Selected but NOT consumed ({}) — counted as wins, emitted nothing", + wasted.len(), + ); + out.push_str("--------------------------------------------------------------\n"); + for e in wasted.iter().take(MAX_ROWS_PER_TIER) { + let _ = writeln!( + out, + " {} :: {} `{}` -> {} (in {} {})", + e.module, + e.position.as_str(), + e.name, + e.rep, + e.region.as_str(), + e.function, + ); + if let Some(rule) = &e.rule { + let _ = writeln!(out, " dropped by {rule}"); + } + if let Some(reason) = &e.reason { + let _ = writeln!(out, " {reason}"); + } + if let Some(issue) = &e.issue { + let _ = writeln!(out, " tracking: {issue}"); + } + } + if wasted.len() > MAX_ROWS_PER_TIER { + let _ = writeln!(out, " ... and {} more", wasted.len() - MAX_ROWS_PER_TIER); + } + out.push('\n'); + } + // ── Denials, ranked, grouped by actionability tier ───────────────────── let denials: Vec<&Entry> = entries .iter() @@ -236,12 +307,16 @@ struct JsonAnalysis<'a> { rule_source: &'a str, selected: usize, denied: usize, + consumed: usize, + unconsumed: usize, } #[derive(Debug, serde::Serialize)] struct JsonSummary<'a> { selected: usize, denied: usize, + consumed: usize, + unconsumed: usize, by_analysis: Vec>, } @@ -262,6 +337,8 @@ pub fn render_json(entries: &[Entry]) -> String { summary: JsonSummary { selected: tallies.values().map(|t| t.selected).sum(), denied: tallies.values().map(|t| t.denied).sum(), + consumed: tallies.values().map(|t| t.consumed).sum(), + unconsumed: tallies.values().map(|t| t.unconsumed).sum(), // Enumerate `Analysis::ALL`, not the analyses that happen to have // entries: an analysis that recorded nothing must appear with an // explicit `0`. A consumer cannot tell an absent key from a zero @@ -277,6 +354,8 @@ pub fn render_json(entries: &[Entry]) -> String { rule_source: a.rule_source(), selected: t.selected, denied: t.denied, + consumed: t.consumed, + unconsumed: t.unconsumed, } }) .collect(), @@ -449,6 +528,105 @@ mod tests { assert_eq!(numarray["denied"], 0); } + fn with_outcome(analysis: Analysis, name: &str, outcome: Outcome, rule: Option<&str>) -> Entry { + let mut e = selected(analysis, name, "Ptr"); + e.outcome = outcome; + e.rule = rule.map(str::to_string); + e.reason = rule.map(|_| "the context gate dropped it".to_string()); + e.issue = rule.map(|_| "#7109".to_string()); + e + } + + /// The headline case. A selected-and-dropped promotion must be visible as + /// such: before this, `batch.ts` reported two `Ptr` wins, applied + /// one, and nothing in the report said so. + #[test] + fn a_selected_but_unconsumed_promotion_is_stated_explicitly() { + let entries = vec![ + selected(Analysis::PtrShape, "acc", "Ptr"), + selected(Analysis::PtrShape, "totals", "Ptr"), + with_outcome(Analysis::PtrShape, "acc", Outcome::Consumed, None), + with_outcome( + Analysis::PtrShape, + "totals", + Outcome::Unconsumed, + Some("module_init_context"), + ), + ]; + let text = render_text(&entries); + assert!( + text.contains("Selected but NOT consumed (1)"), + "the wasted promotion must have its own headline; got:\n{text}" + ); + assert!( + text.contains("dropped by module_init_context"), + "the mechanism must be named, not just the count; got:\n{text}" + ); + assert!( + text.contains("1 of those selections were CONSUMED by codegen"), + "the summary must separate selection from consumption; got:\n{text}" + ); + } + + /// A build where every promotion is applied must NOT grow the section — + /// otherwise it is noise and stops being read. + #[test] + fn the_wasted_section_is_absent_when_everything_was_consumed() { + let entries = vec![ + selected(Analysis::PtrShape, "acc", "Ptr"), + with_outcome(Analysis::PtrShape, "acc", Outcome::Consumed, None), + ]; + let text = render_text(&entries); + assert!(!text.contains("Selected but NOT consumed"), "got:\n{text}"); + } + + /// The census keys its consumed column off these fields. A report that + /// counts consumption internally but does not SERIALIZE it leaves the + /// census unable to tell a wasted promotion from an applied one — which is + /// the entire failure being fixed. + #[test] + fn json_carries_consumed_and_unconsumed_per_analysis() { + let entries = vec![ + selected(Analysis::PtrShape, "acc", "Ptr"), + selected(Analysis::PtrShape, "totals", "Ptr"), + with_outcome(Analysis::PtrShape, "acc", Outcome::Consumed, None), + with_outcome( + Analysis::PtrShape, + "totals", + Outcome::Unconsumed, + Some("module_init_context"), + ), + ]; + let json: serde_json::Value = serde_json::from_str(&render_json(&entries)).unwrap(); + assert_eq!(json["summary"]["consumed"], 1); + assert_eq!(json["summary"]["unconsumed"], 1); + let row = json["summary"]["by_analysis"] + .as_array() + .unwrap() + .iter() + .find(|a| a["analysis"] == "ptr-shape") + .expect("ptr-shape row"); + assert_eq!(row["selected"], 2, "selection keeps its old meaning"); + assert_eq!(row["consumed"], 1, "and no longer implies application"); + assert_eq!(row["unconsumed"], 1); + // Every analysis must carry the field, so an uninstrumented one reads + // as an explicit zero rather than an absent key. + for a in json["summary"]["by_analysis"].as_array().unwrap() { + assert!(a.get("consumed").is_some(), "missing consumed on {a:?}"); + } + } + + /// `selected` is what a v1 consumer keyed performance claims off, and its + /// meaning changed: it no longer implies emitted bytes. That is a schema + /// break, not an additive field. + #[test] + fn splitting_selection_from_consumption_bumped_the_schema() { + assert!( + SCHEMA_VERSION >= 2, + "the consumed/unconsumed split changes what `selected` means" + ); + } + /// Every analysis must render a distinct `analysis` key: the census keys /// its per-representation counts off this string, so a duplicate would /// silently merge two representations into one number — the aggregate diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 2f1d07c8e4..ad1ed48d48 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -782,6 +782,22 @@ pub(crate) fn lower_let( let scalar_data = collect_scalar_class_data(ctx, class_name); if let Some((all_fields, ctor)) = scalar_data { + // #7106 follow-up, mechanism 3: this binding is about to stop + // being an object at all. If `Ptr` also proved it, the + // report already counted a promotion that cannot emit + // anything — no property access will ever reach a + // representation-selection lowering, because there is no + // property access left. On `07_object_create` and + // `12_binary_trees` that is literally the case: `--opt-report` + // says `selected=1` while both arms of a + // PERRY_PTR_SHAPE_LOCALS A/B emit byte-identical objects. + // + // Scalar replacement winning here is the BETTER outcome, not a + // defect; the defect is that it was indistinguishable in the + // report from a proof that was simply wasted. + if crate::opt_report::enabled() { + note_ptr_shape_scalar_replaced(ctx, id, name); + } // Create per-field allocas. For synthetic anonymous-shape // classes, scalar replacement may only need fields that are // observed after construction; unused constructor stores still @@ -1922,3 +1938,33 @@ fn record_pod_rejection(ctx: &mut FnCtx<'_>, id: u32, reason: String) { vec![format!("reason={}", reason)], ); } + +/// #7106 follow-up: record that a `Ptr`-proven local was scalar-replaced, +/// so its promotion can never be consumed. +/// +/// Report-only; the caller has already gated on `opt_report::enabled()`. The +/// fact is read through the context-free accessor on purpose — whether the +/// enclosing body would have ALLOWED consumption is a different mechanism with +/// a different rule name, and a value can lose to both. +fn note_ptr_shape_scalar_replaced(ctx: &crate::expr::FnCtx<'_>, id: u32, name: &str) { + let Some(fact) = ctx.native_facts.shape_proven_ptr_local(id) else { + return; + }; + let (reason, issue) = + crate::expr::ptr_shape_context_rule_text(crate::expr::PTR_SHAPE_SCALAR_REPLACED); + crate::opt_report::unconsumed(crate::opt_report::Unconsumed { + position: crate::opt_report::Position::Local, + name, + local_id: Some(id), + analysis: crate::opt_report::Analysis::PtrShape, + rep: "Ptr", + rule: crate::expr::PTR_SHAPE_SCALAR_REPLACED, + reason, + tier: crate::opt_report::Tier::CompilerLimitation, + issue: Some(issue), + detail: Some(format!( + "class {} scalar-replaced into per-field allocas; the allocation is gone", + fact.class_name + )), + }); +} diff --git a/scripts/compiler_output_harness/repsel_census.py b/scripts/compiler_output_harness/repsel_census.py index 5de9dc5024..80217800d9 100644 --- a/scripts/compiler_output_harness/repsel_census.py +++ b/scripts/compiler_output_harness/repsel_census.py @@ -79,7 +79,7 @@ #: The `--opt-report` JSON schema this census understands. A bump upstream must #: be a loud failure here, not a silently-empty census. -SUPPORTED_REPORT_SCHEMA = 1 +SUPPORTED_REPORT_SCHEMA = 2 #: Every analysis the report is expected to enumerate. Kept in lockstep with #: `perry_codegen::opt_report::Analysis::ALL`; a missing row means the compiler @@ -94,8 +94,14 @@ ) #: Census keys, one per representation, in report order. +#: +#: `ptr-shape-consumed` is a SEPARATE key from `ptr-shape`, deliberately. See +#: [`CONSUMPTION_INSTRUMENTED`]: `ptr-shape` keeps its old meaning (the analysis +#: proved this many values) and its old ratcheted floors, which must not be +#: reinterpreted retroactively. The new key answers the different question. CENSUS_KEYS: tuple[str, ...] = ( "ptr-shape", + "ptr-shape-consumed", "ptr-numarray", "canonical-i32", "canonical-u32", @@ -105,6 +111,27 @@ "spec-abi-taptr-slot", ) +#: Analyses whose CONSUMPTION is instrumented in the compiler, mapped to their +#: census key. **Held in code, never in the baseline.** +#: +#: A promotion is `selected` when an analysis proves a value. It is `consumed` +#: only when codegen goes on to emit the representation-specific form for it. +#: The two were conflated until #7107 read the emitted IR by hand and found +#: that `batch.ts` reports two `Ptr` promotions and applies exactly one: +#: `totals` is proven, reported as a win, and keeps the guarded diamond at +#: every access site. The whole 1,532-byte binary saving came from the other. +#: +#: This table is why the census does not simply grow a `-consumed` column for +#: every representation. An uninstrumented analysis would report `consumed: 0`, +#: which is indistinguishable from "instrumented and never applied" — the exact +#: ambiguity this census exists to remove, reintroduced one level down. So +#: `consumed` is reported ONLY for analyses that record it, and +#: [`check_consumption_instrumentation`] fails if the compiler starts emitting +#: consumption for an analysis this table does not know about. +CONSUMPTION_INSTRUMENTED: dict[str, str] = { + "ptr-shape": "ptr-shape-consumed", +} + #: `SlotRep` debug spelling -> census key, for the `canonical-slot` analysis. CANONICAL_REPS = { "I32": "canonical-i32", @@ -129,7 +156,13 @@ #: least this many values of this representation.* That is the assertion that #: the instrument is alive, separate from any claim about real-world code. LIVENESS_FLOORS: dict[str, dict[str, int]] = { - "fixture_ptr_shape": {"ptr-shape": 1}, + # The consumed minimum is what makes the consumption counter falsifiable. + # `ptr-shape: 1` alone cannot fail when the counter is dead, and a + # `ptr-shape-consumed` floor of 0 could never go red either -- which is the + # state every non-fixture workload in this corpus is genuinely in. The + # fixture's `p` is a function-body local with an in-loop field store, so it + # is consumed; verified against emitted IR, not against this counter. + "fixture_ptr_shape": {"ptr-shape": 1, "ptr-shape-consumed": 1}, "fixture_ptr_numarray": {"ptr-numarray": 1}, "fixture_canonical_slots": { "canonical-i32": 1, @@ -199,6 +232,16 @@ def census_from_report(report: dict[str, Any]) -> dict[str, Any]: if not isinstance(entries, list): raise HarnessError("--opt-report JSON has no entries list") + missing_consumption = [ + a for a in CONSUMPTION_INSTRUMENTED if "consumed" not in by_analysis.get(a, {}) + ] + if missing_consumption: + raise HarnessError( + f"--opt-report omitted the `consumed` tally for {missing_consumption}. " + "The census counts consumption, not selection; a report that cannot " + "distinguish them is the instrument this census replaced." + ) + counts = {key: 0 for key in CENSUS_KEYS} counts["ptr-shape"] = int(by_analysis["ptr-shape"]["selected"]) counts["ptr-numarray"] = int(by_analysis["ptr-numarray"]["selected"]) @@ -228,6 +271,44 @@ def census_from_report(report: dict[str, Any]) -> dict[str, Any]: "its promotions vanish from the census" ) + # Consumption, counted from ENTRIES rather than the summary tally, and only + # for values in a `local` position. Phase 5a's proven-`this` receiver is a + # real consumption of the same representation but was never `select()`ed, so + # folding it in here would make `consumed` exceed `selected` and quietly + # break the one invariant that says the two columns describe the same + # population. It is reported separately instead (`consumed_receiver`). + consumed_receiver = 0 + unconsumed_mechanisms: dict[str, int] = {} + seen_consumed: set[tuple[str, Any]] = set() + unknown_consumed: set[str] = set() + for entry in entries: + analysis = entry.get("analysis") + outcome = entry.get("outcome") + if outcome == "consumed": + if analysis not in CONSUMPTION_INSTRUMENTED: + unknown_consumed.add(str(analysis)) + continue + if entry.get("position") != "local": + consumed_receiver += 1 + continue + # One value consumed at five access sites is ONE consumed value. + key = (str(analysis), entry.get("local_id"), entry.get("function")) + if key in seen_consumed: + continue + seen_consumed.add(key) + counts[CONSUMPTION_INSTRUMENTED[analysis]] += 1 + elif outcome == "unconsumed": + rule = str(entry.get("rule") or "") + unconsumed_mechanisms[rule] = unconsumed_mechanisms.get(rule, 0) + 1 + if unknown_consumed: + raise HarnessError( + f"--opt-report recorded consumption for analysis/analyses " + f"{sorted(unknown_consumed)}, which CONSUMPTION_INSTRUMENTED does not " + "know about. Add a census key for it: an instrumented analysis whose " + "consumption is not counted is a promotion the census still cannot " + "tell apart from a wasted one." + ) + canonical_total = sum(counts[k] for k in CANONICAL_REPS.values()) reported = int(by_analysis["canonical-slot"]["selected"]) if canonical_total != reported: @@ -240,7 +321,12 @@ def census_from_report(report: dict[str, Any]) -> dict[str, Any]: row: int(by_analysis[row]["selected"]) + int(by_analysis[row]["denied"]) for row in EXPECTED_ANALYSES } - return {"counts": counts, "candidates": candidates} + return { + "counts": counts, + "candidates": candidates, + "unconsumed_mechanisms": unconsumed_mechanisms, + "consumed_receiver": consumed_receiver, + } # ── Running the compiler ─────────────────────────────────────────────────── @@ -442,6 +528,70 @@ def check_instrument_liveness(observed: dict[str, dict[str, Any]]) -> list[str]: ] +def check_consumption_invariant(observed: dict[str, dict[str, Any]]) -> list[str]: + """`consumed` may never exceed `selected` for the same representation. + + Not a style rule — it is the assertion that the two columns describe one + population. If consumption is ever recorded for a value that was never + selected (Phase 5a's proven-`this` receiver is exactly such a value), the + consumed column stops meaning "of the promotions we counted, this many were + applied" and starts meaning nothing in particular, while still looking like + an improvement. + """ + failures: list[str] = [] + for name, entry in sorted(observed.items()): + counts = entry["counts"] + for analysis, consumed_key in CONSUMPTION_INSTRUMENTED.items(): + selected = int(counts.get(analysis, 0)) + consumed = int(counts.get(consumed_key, 0)) + if consumed > selected: + failures.append( + f"{name}: {consumed_key} is {consumed} but only {selected} " + f"{analysis} value(s) were selected. Consumption is being counted " + "for values outside the selected population, so the column no " + "longer means what its name says." + ) + return failures + + +def check_unconsumed_is_explained(observed: dict[str, dict[str, Any]]) -> list[str]: + """A workload with wasted promotions must be able to NAME a mechanism. + + `selected > consumed` says the compiler proved values and emitted nothing + for them. That on its own is a number; it is not yet information. The + mechanism recorders (`module_init_context`, `scalar_replaced`, …) are what + turn it into something a reader can act on or argue with. + + Without this check, deleting a mechanism recorder is invisible: the + consumed column is unchanged, the floors still pass, and the census goes + green having lost the only part of the finding that says WHY. That is + CLAUDE.md failure mode 4 — the gate runs but its subject did not. + + A residue is allowed: `selected - consumed` may exceed the named + mechanisms, because a promotion with no access site at all is dropped by + nobody. What is not allowed is wasted promotions and ZERO named mechanisms. + """ + failures: list[str] = [] + for name, entry in sorted(observed.items()): + counts = entry["counts"] + wasted = sum( + int(counts.get(a, 0)) - int(counts.get(k, 0)) + for a, k in CONSUMPTION_INSTRUMENTED.items() + ) + if wasted <= 0: + continue + if sum(int(v) for v in entry.get("unconsumed_mechanisms", {}).values()) > 0: + continue + failures.append( + f"{name}: {wasted} selected promotion(s) were not consumed, and not one " + "of them names a mechanism. A wasted promotion with no rule attached is " + "the state this census was built to end: it reads exactly like an honest " + "zero. Either a mechanism recorder was removed, or a new way to drop a " + "proof exists and needs one." + ) + return failures + + def check_analysis_reach(observed: dict[str, dict[str, Any]]) -> list[str]: """Every corpus workload must be REACHED by at least one analysis. @@ -509,6 +659,48 @@ def render_table( return "\n".join(lines) +def render_consumption_report( + baseline: dict[str, Any], observed: dict[str, dict[str, Any]] +) -> str: + """Selected vs CONSUMED, and the named mechanism for every wasted promotion. + + This is the honest version of the promotion table. `selected` counts + `select()` calls; `consumed` counts values codegen actually emitted the + representation for. Where they differ, the difference is the compiler + proving things it then throws away. + """ + lines = [] + for analysis, consumed_key in CONSUMPTION_INSTRUMENTED.items(): + selected = sum(int(e["counts"].get(analysis, 0)) for e in observed.values()) + consumed = sum(int(e["counts"].get(consumed_key, 0)) for e in observed.values()) + lines.append( + f" {analysis:<22} {selected} selected, {consumed} consumed " + f"({selected - consumed} proven and thrown away)" + ) + mechanisms: dict[str, int] = {} + receiver = 0 + for entry in observed.values(): + for rule, n in entry.get("unconsumed_mechanisms", {}).items(): + mechanisms[rule] = mechanisms.get(rule, 0) + int(n) + receiver += int(entry.get("consumed_receiver", 0)) + if mechanisms: + lines.append(" mechanisms that dropped a selected promotion:") + for rule, n in sorted(mechanisms.items(), key=lambda kv: (-kv[1], kv[0])): + lines.append(f" {rule:<24} {n}") + if receiver: + lines.append( + f" (plus {receiver} consumption(s) of a proven `this` receiver, which is " + "never counted as a selection at all — see CONSUMPTION_INSTRUMENTED)" + ) + uninstrumented = [k for k in CENSUS_KEYS if k not in CONSUMPTION_INSTRUMENTED + and not k.endswith("-consumed")] + lines.append( + " NOT INSTRUMENTED (no consumption data, reported as absent not as zero): " + + ", ".join(uninstrumented) + ) + return "\n".join(lines) + + def render_zero_report( baseline: dict[str, Any], observed: dict[str, dict[str, Any]] ) -> str: @@ -584,6 +776,9 @@ def census(args: argparse.Namespace) -> int: print("Representation-selection promotion census (#7106)") print("=================================================\n") print(render_table(baseline, observed)) + print("\nSelected vs consumed") + print("--------------------") + print(render_consumption_report(baseline, observed)) print("\nPromotion coverage on non-fixture workloads") print("------------------------------------------") print(render_zero_report(baseline, observed)) @@ -600,6 +795,10 @@ def census(args: argparse.Namespace) -> int: liveness = check_liveness_fixtures(observed) if not partial else [] dead = check_instrument_liveness(observed) if not partial else [] unreached = check_analysis_reach(observed) if not partial else [] + # Always checked, even for a --workload subset: it is an internal + # consistency assertion about the counter, not a corpus-wide claim. + invariant = check_consumption_invariant(observed) + unexplained = check_unconsumed_is_explained(observed) if partial: print( @@ -618,6 +817,8 @@ def census(args: argparse.Namespace) -> int: ("REGRESSION", regressions), ("DEAD INSTRUMENT", liveness + dead), ("UNREACHED BY EVERY ANALYSIS", unreached), + ("CONSUMPTION COUNTER IS INCOHERENT", invariant), + ("WASTED PROMOTION WITH NO NAMED MECHANISM", unexplained), ): if not problems: continue @@ -669,6 +870,8 @@ def _update( ) workload["floors"] = {key: int(counts.get(key, 0)) for key in CENSUS_KEYS} workload["candidates"] = observed[name]["candidates"] + # Context, never gated: which mechanism ate each wasted promotion. + workload["unconsumed_mechanisms"] = observed[name].get("unconsumed_mechanisms", {}) baseline["generated_at"] = utc_now() path.write_text(json.dumps(baseline, indent=2) + "\n", encoding="utf-8") print(f"Wrote {path.relative_to(REPO_ROOT)} ({len(workloads)} workload(s)).") @@ -685,12 +888,20 @@ def self_test(_args: argparse.Namespace) -> int: is that it passes on a good tree is a gate nobody has watched fail. """ report = { - "schema_version": 1, + "schema_version": 2, "summary": { "selected": 3, "denied": 1, "by_analysis": [ - {"analysis": a, "target_rep": a, "rule_source": "x", "selected": s, "denied": d} + { + "analysis": a, + "target_rep": a, + "rule_source": "x", + "selected": s, + "denied": d, + "consumed": 0, + "unconsumed": 0, + } for a, s, d in ( ("ptr-shape", 0, 1), ("ptr-numarray", 1, 0), @@ -710,6 +921,7 @@ def self_test(_args: argparse.Namespace) -> int: result = census_from_report(report) counts = result["counts"] assert counts["ptr-shape"] == 0, counts + assert counts["ptr-shape-consumed"] == 0, counts assert counts["canonical-i32"] == 1, counts assert counts["canonical-str"] == 1, counts assert counts["canonical-u32"] == 0, counts @@ -745,5 +957,124 @@ def self_test(_args: argparse.Namespace) -> int: else: # pragma: no cover - the assertion IS the test raise AssertionError(f"census_from_report accepted {why}") + # ── The consumption column, asserted failing-direction first ─────────── + # + # The whole point of this column is a promotion that is SELECTED and then + # emitted nothing. Build exactly that report and check the census can see + # it, because the pre-#7107 instrument could not. + wasted = { + "schema_version": 2, + "summary": { + "selected": 2, + "denied": 0, + "by_analysis": [ + { + "analysis": a, + "target_rep": a, + "rule_source": "x", + "selected": sel, + "denied": 0, + "consumed": con, + "unconsumed": unc, + } + for a, sel, con, unc in ( + ("ptr-shape", 2, 1, 1), + ("ptr-numarray", 0, 0, 0), + ("canonical-slot", 0, 0, 0), + ("int-valued-ta", 0, 0, 0), + ("spec-abi", 0, 0, 0), + ) + ], + }, + "entries": [ + {"analysis": "ptr-shape", "outcome": "selected", "rep": "Ptr"}, + {"analysis": "ptr-shape", "outcome": "selected", "rep": "Ptr"}, + # `acc`: consumed at three access sites, but it is ONE value. + {"analysis": "ptr-shape", "outcome": "consumed", "rep": "Ptr", + "position": "local", "local_id": 9, "function": "totalsRow"}, + {"analysis": "ptr-shape", "outcome": "consumed", "rep": "Ptr", + "position": "local", "local_id": 9, "function": "totalsRow"}, + {"analysis": "ptr-shape", "outcome": "consumed", "rep": "Ptr", + "position": "local", "local_id": 9, "function": "totalsRow"}, + # `totals`: proven, counted as a win, dropped by the context gate. + {"analysis": "ptr-shape", "outcome": "unconsumed", "rep": "Ptr", + "position": "local", "local_id": 4, "function": "module_init", + "rule": "module_init_context"}, + # A proven `this`, which was never selected: must NOT inflate the + # consumed column, or the invariant below stops holding. + {"analysis": "ptr-shape", "outcome": "consumed", "rep": "Ptr", + "position": "param", "local_id": None, "function": "C.m"}, + ], + } + result = census_from_report(wasted) + counts = result["counts"] + assert counts["ptr-shape"] == 2, counts + assert counts["ptr-shape-consumed"] == 1, ( + "three access sites on ONE local must count as one consumed value, and a " + "proven `this` must not count at all" + ) + assert result["consumed_receiver"] == 1, result + assert result["unconsumed_mechanisms"] == {"module_init_context": 1}, result + + # A floor on the consumed column must be able to go red while the SELECTED + # column stays green. That is the regression the pre-#7107 census could not + # express at all: `batch` selects 2 either way. + regressions, _ = check_workload( + "batch", {"ptr-shape": 2, "ptr-shape-consumed": 2}, counts + ) + assert any("ptr-shape-consumed" in r for r in regressions), regressions + regressions, _ = check_workload("batch", {"ptr-shape": 2}, counts) + assert not regressions, "the selected column alone cannot see the drop" + + # Consumption recorded for a value that was never selected is incoherent. + assert check_consumption_invariant( + {"w": {"counts": {"ptr-shape": 0, "ptr-shape-consumed": 1}}} + ), "consumed > selected must be a failure" + assert not check_consumption_invariant({"w": {"counts": counts}}) + + # An analysis that starts recording consumption without a census key must + # be loud, not silently uncounted. + rogue = json.loads(json.dumps(wasted)) + rogue["entries"].append( + {"analysis": "canonical-slot", "outcome": "consumed", "rep": "I32", + "position": "local", "local_id": 1, "function": "f"} + ) + try: + census_from_report(rogue) + except HarnessError: + pass + else: # pragma: no cover - the assertion IS the test + raise AssertionError("uninstrumented consumption was silently dropped") + + # A report that cannot distinguish selection from consumption is the OLD + # instrument, and must be rejected rather than read as "nothing consumed". + v1 = json.loads(json.dumps(wasted)) + for row in v1["summary"]["by_analysis"]: + del row["consumed"] + try: + census_from_report(v1) + except HarnessError: + pass + else: # pragma: no cover + raise AssertionError("a report with no consumed tally was accepted") + + # A wasted promotion that names no mechanism must be red: that is what + # deleting a drop-recorder looks like, and the consumed column alone + # cannot see it. + assert check_unconsumed_is_explained( + {"w": {"counts": {"ptr-shape": 2, "ptr-shape-consumed": 1}, "unconsumed_mechanisms": {}}} + ), "a wasted promotion with no named mechanism must fail" + assert not check_unconsumed_is_explained( + { + "w": { + "counts": {"ptr-shape": 2, "ptr-shape-consumed": 1}, + "unconsumed_mechanisms": {"module_init_context": 1}, + } + } + ) + assert not check_unconsumed_is_explained( + {"w": {"counts": {"ptr-shape": 1, "ptr-shape-consumed": 1}, "unconsumed_mechanisms": {}}} + ), "nothing wasted means nothing to explain" + print("repsel census self-test OK") return 0 diff --git a/tests/test_repsel_census.py b/tests/test_repsel_census.py index caffe9b0b0..b8dc0f2d2a 100644 --- a/tests/test_repsel_census.py +++ b/tests/test_repsel_census.py @@ -39,10 +39,14 @@ def report( selected: dict[str, int] | None = None, denied: dict[str, int] | None = None, entries: list[dict] | None = None, - schema_version: int = 1, + consumed: dict[str, int] | None = None, + unconsumed: dict[str, int] | None = None, + schema_version: int = 2, ) -> dict: selected = selected or {} denied = denied or {} + consumed = consumed or {} + unconsumed = unconsumed or {} return { "schema_version": schema_version, "summary": { @@ -55,6 +59,8 @@ def report( "rule_source": "x.rs", "selected": selected.get(analysis, 0), "denied": denied.get(analysis, 0), + "consumed": consumed.get(analysis, 0), + "unconsumed": unconsumed.get(analysis, 0), } for analysis in CENSUS.EXPECTED_ANALYSES ], @@ -67,6 +73,29 @@ def win(analysis: str, rep: str) -> dict: return {"analysis": analysis, "outcome": "selected", "rep": rep} +def consumed_entry(analysis: str, local_id, function: str = "f", position: str = "local") -> dict: + return { + "analysis": analysis, + "outcome": "consumed", + "rep": "Ptr", + "position": position, + "local_id": local_id, + "function": function, + } + + +def unconsumed_entry(analysis: str, rule: str, local_id=1) -> dict: + return { + "analysis": analysis, + "outcome": "unconsumed", + "rep": "Ptr", + "position": "local", + "local_id": local_id, + "function": "module_init", + "rule": rule, + } + + class CensusExtraction(unittest.TestCase): def test_every_key_is_present_even_when_zero(self): counts = CENSUS.census_from_report(report())["counts"] @@ -117,7 +146,7 @@ def test_denials_are_not_counted_as_promotions(self): def test_schema_drift_is_loud(self): with self.assertRaises(HarnessError): - CENSUS.census_from_report(report(schema_version=2)) + CENSUS.census_from_report(report(schema_version=99)) def test_a_missing_analysis_row_is_an_error_not_a_zero(self): """An absent key and a zero key are indistinguishable downstream. @@ -269,6 +298,207 @@ def test_the_shipped_baseline_has_no_unexplained_zero_candidate_workload(self): self.assertFalse(CENSUS.check_analysis_reach(observed)) +class Consumption(unittest.TestCase): + """Selection vs consumption (#7107). + + Every test here is written so that it FAILS if consumption collapses back + into selection -- which is the shape the census had before, and which was + green while `batch.ts` proved two `Ptr` values and applied one. + """ + + def test_consumption_is_counted_separately_from_selection(self): + counts = CENSUS.census_from_report( + report( + selected={"ptr-shape": 2}, + consumed={"ptr-shape": 1}, + unconsumed={"ptr-shape": 1}, + entries=[ + win("ptr-shape", "Ptr"), + win("ptr-shape", "Ptr"), + consumed_entry("ptr-shape", 9, "totalsRow"), + unconsumed_entry("ptr-shape", "module_init_context", 4), + ], + ) + ) + self.assertEqual(counts["counts"]["ptr-shape"], 2) + self.assertEqual(counts["counts"]["ptr-shape-consumed"], 1) + self.assertEqual( + counts["unconsumed_mechanisms"], {"module_init_context": 1} + ) + + def test_one_value_consumed_at_many_sites_counts_once(self): + """`acc` is read at three access sites; it is one promoted value. + + Counting access sites would make the consumed column drift upward with + program size and eventually exceed `selected`, at which point it stops + describing the same population and the comparison is meaningless. + """ + counts = CENSUS.census_from_report( + report( + selected={"ptr-shape": 1}, + consumed={"ptr-shape": 3}, + entries=[win("ptr-shape", "Ptr")] + + [consumed_entry("ptr-shape", 9, "totalsRow") for _ in range(3)], + ) + ) + self.assertEqual(counts["counts"]["ptr-shape-consumed"], 1) + + def test_a_proven_this_receiver_does_not_inflate_the_consumed_column(self): + """Phase 5a consumes the representation for a value never selected. + + `suite_09_method_calls` emits two `__pshape` clones whose bodies consume + the proof for `this`. Counting those would report 2 consumed against 1 + selected -- an "improvement" produced entirely by dead code, since those + clones have zero call sites. + """ + counts = CENSUS.census_from_report( + report( + selected={"ptr-shape": 1}, + consumed={"ptr-shape": 2}, + entries=[ + win("ptr-shape", "Ptr"), + consumed_entry("ptr-shape", None, "C.m", position="param"), + consumed_entry("ptr-shape", None, "C.n", position="param"), + ], + ) + ) + self.assertEqual(counts["counts"]["ptr-shape-consumed"], 0) + self.assertEqual(counts["consumed_receiver"], 2) + + def test_consumed_above_selected_is_incoherent(self): + self.assertTrue( + CENSUS.check_consumption_invariant( + {"w": {"counts": {"ptr-shape": 1, "ptr-shape-consumed": 2}}} + ) + ) + + def test_a_report_without_a_consumed_tally_is_rejected(self): + payload = report(selected={"ptr-shape": 1}) + for row in payload["summary"]["by_analysis"]: + row.pop("consumed") + with self.assertRaises(HarnessError): + CENSUS.census_from_report(payload) + + def test_consumption_for_an_uninstrumented_analysis_is_loud(self): + payload = report( + selected={"canonical-slot": 1}, + entries=[ + {"analysis": "canonical-slot", "outcome": "selected", "rep": "I32"}, + consumed_entry("canonical-slot", 1), + ], + ) + with self.assertRaises(HarnessError): + CENSUS.census_from_report(payload) + + def test_a_consumed_floor_can_fail_while_the_selected_floor_passes(self): + """The regression the old census could not express. + + `batch` selects 2 `Ptr` values whether or not codegen applies + either of them. Only the consumed column moves. + """ + counts = {"ptr-shape": 2, "ptr-shape-consumed": 0} + regressions, _ = CENSUS.check_workload( + "batch", {"ptr-shape": 2, "ptr-shape-consumed": 1}, counts + ) + self.assertTrue(any("ptr-shape-consumed" in r for r in regressions)) + regressions, _ = CENSUS.check_workload("batch", {"ptr-shape": 2}, counts) + self.assertFalse(regressions) + + def test_wasted_promotions_must_name_a_mechanism(self): + self.assertTrue( + CENSUS.check_unconsumed_is_explained( + { + "w": { + "counts": {"ptr-shape": 2, "ptr-shape-consumed": 1}, + "unconsumed_mechanisms": {}, + } + } + ) + ) + self.assertFalse( + CENSUS.check_unconsumed_is_explained( + { + "w": { + "counts": {"ptr-shape": 2, "ptr-shape-consumed": 1}, + "unconsumed_mechanisms": {"scalar_replaced": 1}, + } + } + ) + ) + + def test_the_instrumentation_table_lives_in_code_not_the_baseline(self): + """Same rule as LIVENESS_FLOORS and ZERO_CANDIDATE_ALLOWLIST. + + `--update` regenerates the baseline from observation. If which analyses + are instrumented were regenerable, a build that stopped recording + consumption would rewrite itself into a permanently-green gate. + """ + source = ( + CENSUS.REPO_ROOT / "scripts/compiler_output_harness/repsel_census.py" + ).read_text(encoding="utf-8") + self.assertIn("CONSUMPTION_INSTRUMENTED: dict[str, str] = {", source) + baseline = json.loads( + (CENSUS.REPO_ROOT / "benchmarks/repsel_census/baseline.json").read_text() + ) + self.assertNotIn("consumption_instrumented", baseline) + + def test_every_instrumented_analysis_has_a_census_key(self): + for analysis, key in CENSUS.CONSUMPTION_INSTRUMENTED.items(): + self.assertIn(analysis, CENSUS.CENSUS_KEYS) + self.assertIn(key, CENSUS.CENSUS_KEYS) + + def test_every_instrumented_analysis_has_a_nonzero_consumed_liveness_floor(self): + """The gate on the gate. + + A `-consumed` floor of zero can never go red, exactly as a zero + `ptr-shape` floor cannot -- which is why #7104 introduced the fixtures + in the first place. Deleting the consumed minimum from LIVENESS_FLOORS + would leave every other check intact and every census run green, so the + minimum's EXISTENCE has to be asserted somewhere that is not itself the + baseline. + """ + for analysis, consumed_key in CENSUS.CONSUMPTION_INSTRUMENTED.items(): + with_floor = [ + fixture + for fixture, minimums in CENSUS.LIVENESS_FLOORS.items() + if int(minimums.get(consumed_key, 0)) > 0 + ] + self.assertTrue( + with_floor, + f"{analysis} records consumption but no liveness fixture asserts a " + f"nonzero {consumed_key}. Without one the consumed column is a " + "counter nobody has watched go red.", + ) + + def test_the_shipped_baseline_shows_a_consumed_gap(self): + """The finding itself, pinned. + + If this ever passes trivially because every workload consumes what it + selects, that is a real improvement -- delete the test and say so in the + PR. What it must not do is silently stop being true because the counter + died. + """ + baseline = json.loads( + (CENSUS.REPO_ROOT / "benchmarks/repsel_census/baseline.json").read_text() + ) + floors = {w["name"]: w["floors"] for w in baseline["workloads"]} + selected = sum(f.get("ptr-shape", 0) for f in floors.values()) + consumed = sum(f.get("ptr-shape-consumed", 0) for f in floors.values()) + self.assertGreater(selected, 0) + self.assertGreater(consumed, 0, "the consumed counter must not be dead") + self.assertGreaterEqual(selected, consumed) + self.assertEqual( + floors["batch"]["ptr-shape"], + 2, + "#7107's ratcheted selection floor must not be reinterpreted", + ) + self.assertEqual( + floors["batch"]["ptr-shape-consumed"], + 1, + "batch proves two Ptr values and applies one", + ) + + class Baseline(unittest.TestCase): def test_the_shipped_baseline_loads_and_covers_every_fixture(self): data = CENSUS.load_baseline(CENSUS.DEFAULT_BASELINE)