Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion benchmarks/repsel_census/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,39 @@
"alloc_contexts": {},
"alloc_buckets": {}
},
{
"name": "fixture_ptr_shape_cjs_iife",
"role": "liveness",
"source": "benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts",
"floors": {
"ptr-shape": 1,
"ptr-shape-consumed": 1,
"ptr-numarray": 0,
"canonical-i32": 0,
"canonical-u32": 0,
"canonical-str": 0,
"int-valued-ta": 0,
"spec-abi-entry": 0,
"spec-abi-taptr-slot": 0
},
"candidates": {
"ptr-shape": 3,
"ptr-numarray": 0,
"canonical-slot": 1,
"int-valued-ta": 0,
"spec-abi": 0
},
"unconsumed_mechanisms": {},
"consumption_sites": {
"class_field_get.shape_proven_load": 1,
"ptr_shape_set": 1
},
"alloc_contexts": {},
"alloc_buckets": {
"ptr-shape | return | rule 1 (provenance) \u2014 already served by return-shape": 1,
"ptr-shape | return | rule 1 (provenance)": 1
}
},
{
"name": "fixture_alloc_buckets",
"role": "liveness",
Expand Down Expand Up @@ -805,5 +838,5 @@
"alloc_buckets": {}
}
],
"generated_at": "2026-08-01T09:57:46.100718Z"
"generated_at": "2026-08-02T04:34:29.412488Z"
}
73 changes: 73 additions & 0 deletions benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Liveness fixture for the `Ptr<Shape>` census key **inside an IIFE** (#7170 R1).
//
// `fixture_ptr_shape.ts` is the same proof at module scope, where `mk` and
// `run` are `hir.functions` entries. This file wraps identical code in
// `(function () { … })()` and nothing else. That one difference is what Perry's
// own `cjs_wrap` does to *every* CommonJS module (`compile/cjs_wrap/wrap.rs`,
// `const _cjs = (function() { … })();`), and #7170 §6 measured what it costs:
//
// probe wrapper result for `const p = mk(i)`
// p7_esm.ts none selected, and consumed
// p8_iife.ts (function(){ … })() NOT EVEN A CANDIDATE
//
// Inside the IIFE `mk` is not a function declaration the compiler can see —
// it lowers to `Stmt::Let { init: Expr::Closure }`, and `mk(i)` to
// `Call { callee: LocalGet(id) }`. #7107's producer walked `hir.functions`
// (empty here) and its caller-side seed accepted only `Expr::FuncRef`, so the
// whole return-shape mechanism was structurally unreachable across CommonJS —
// 91.6% of dependency-JS allocation sites (#7170 §2).
//
// This fixture is what makes that reachability falsifiable. Reverting either
// half of R1 — the closure arm of `collect_return_shape_functions`, or
// `callee_names_one_function`'s `LocalGet` arm — takes its `ptr-shape` count
// to zero while `fixture_ptr_shape.ts` stays green, because that one is at
// module scope and never needed either.
//
// Do not "tidy" this file:
//
// * Removing the IIFE turns it back into `fixture_ptr_shape.ts` and it stops
// testing anything R1 added.
// * Removing `p.x = p.x + 1` makes the object non-escaping, `escape_news.rs`
// deletes it outright, and the promotion becomes `unconsumed —
// scalar_replaced` (#7170 §6.1). The `ptr-shape-consumed` floor is what
// catches that, and the store is what satisfies it.
// * Reassigning `mk`, or declaring it twice, disqualifies the callee binding
// (`single_binding_closure_locals`) and takes the count to zero.
// * Deleting `maybe` removes the fixture's only UNSERVED return-position
// allocation, and with it the census's ability to catch
// `codegen/closure.rs` reporting every closure as served. See
// ALLOC_BUCKET_FLOORS in `scripts/compiler_output_harness/repsel_census.py`
// — this file is the only workload that lands both bucket rows in a
// `closure` region, and no compiler unit test can reach that wiring
// (they all set the report scope by hand).

const _cjs = (function () {
function mk(i: number) {
return { x: i, y: i + 1 };
}
// Deliberately NOT a return-shape producer: the second return is not a fresh
// allocation, so the returns disagree and `producer_return_class` refuses.
// Its `{ tag: n }` is therefore an unserved return-position allocation in a
// closure region — the anti-vacuity half of the served classification.
function maybe(n: number) {
if (n > 2) {
return { tag: n };
}
return null;
}
function run(n: number): number {
let total = 0;
for (let i = 0; i < n; i++) {
const p = mk(i);
p.x = p.x + 1;
total = total + p.x + p.y;
}
if (maybe(n) !== null) {
total = total + 1;
}
return total;
}
return run(4);
})();

console.log("ptr_shape_cjs_iife:" + _cjs);
82 changes: 82 additions & 0 deletions changelog.d/7233-repsel-cjs-iife-return-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
### Representation selection: `Ptr<Shape>` return-shape facts now reach inside Perry's CommonJS IIFE (#7170 R1)

`compile/cjs_wrap/wrap.rs` emits every CommonJS module body inside
`const _cjs = (function () { … })();`. Inside that wrapper a module-level
`function` declaration never reaches `hir.functions` — it lowers to
`Stmt::Let { init: Expr::Closure }`, and a call to it to
`Call { callee: LocalGet(id) }`. #7107's return-shape mechanism walked
`hir.functions` for producers and accepted only a bare `Expr::FuncRef` callee
for consumers, so it was **structurally unreachable across the whole CommonJS
ecosystem**: 91.6 % of dependency-JS `Ptr<Shape>` allocation sites sit in
`closure` regions (#7170 §2/§6). This is the third time Perry's own CJS
scaffolding turned out to be the wall, after #7139 (the wrap preamble arming
the rule-5 barrier) and #7152/#7171 (`__cjs_module`).

Both halves are extended, because both missed it:

* **Producer** (`collectors/ptr_shape_returns.rs`): every `Expr::Closure` in
the module is now a candidate body, keyed by the `FuncId` the closure already
carries — the same module-wide `fresh_func` counter as `hir.functions`, so no
key can mean two things. A `Function` and a closure are the same thing to
this proof but carry differently-spelled context flags
(`Function::was_plain_async` versus `Module::async_step_closures`), so both
are projected onto one `ProducerBody` view and the closure arm cannot prove
something weaker than the function arm.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* **Consumer**: an `Expr::LocalGet` callee resolves through a new module-wide
binding proof, `collectors/spec_abi_sites.rs::single_binding_closure_locals`
— exactly one `Stmt::Let` with a closure init, never reassigned at any depth
in any body, never also a parameter or a `catch` binding. That is the same
statement `Expr::FuncRef` makes directly, and it is the only property the
seed needs of a callee: *which body runs*. Deliberately **not**
`FnCtx::local_closure_func_ids`, which `lower_call` pairs with a runtime
`js_typed_feedback_closure_direct_call_guard` because it is populated in
statement order.

Box-backed bindings are admitted on purpose: a hoisted inner `function`
referenced from a sibling closure is `PreallocateBoxes`-boxed by construction
(`lower_decl/block.rs`), and that is the entire dependency-JS population.
Freshness is unchanged — the full Phase 3b proof still re-runs over the
producer's body.

**Measured, on a real transpiled CommonJS module**, both arms pinned at one
SHA and compared on emitted IR with call sites checked: `Ptr<Shape>` goes from
`selected 0 / consumed 0` to `selected 1 / consumed 2`, and the emitted IR
loses **25 opaque `js_*` call sites** — `js_object_get_field_by_name_f64`
19 → 13, and three whole typed-feedback guard diamonds
(`js_typed_feedback_object_get_field_by_name_f64` /
`observe_property_get` / `record_guard_pass` / `record_guard_fail` /
`record_fallback_call`, each 10 → 7). Three call sites are *added* and are
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reconcile the emitted-call-site totals.

The listed reductions account for 21 calls: 6 from js_object_get_field_by_name_f64 and 15 across the five listed guard-diamond operations. This does not support the stated total of 25. List the four omitted calls or correct the headline total.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7233-repsel-cjs-iife-return-shape.md` around lines 44 - 48,
Reconcile the emitted-call-site accounting in the changelog entry: the
documented reductions total 21, not 25, based on the six fewer
js_object_get_field_by_name_f64 calls and the 15 fewer calls across the five
guard-diamond operations. Either add the four omitted call sites to the
breakdown or change the headline total to match the listed reductions.

reported as the promotion's own cost: the guard-free store emits a direct
`js_write_barrier_slot` where `js_put_value_set_dyn_ic` did the barrier
internally, plus one slot-layout note and one string addref.

**On the 197-module dependency corpus the mechanism fires 10 more times and
promotes nothing more**, and that is reported rather than smoothed: the
`return` allocation bucket moves 231 → 221 unserved and 4 → 14 served, while
corpus `selected`/`consumed` stay at 3/11. The producers R1 reaches are
*exported* helpers with no same-module `const x = f(…)` call site — #7170 R2's
cross-module half, which is structurally blocked because
`Expr::ExternFuncRef` carries no `FuncId`. A throwaway instrumented compiler
put a number on the residual wall: over the same corpus the first refusing
conjunct is the **return form** in 1397 of 1971 refusals, while
"can fall off the end" refuses exactly **one** body — so widening the producer
to conditional returns whose arms agree (R0 §3b measured 88 such sites) is the
next increment, not more consumer reach.
Comment on lines +59 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve the measurement-status contradiction.

This fragment reports a refusal distribution of 1,397/1,971 and one fall-through body. The PR objectives state that refined producer-refusal distributions remain unmeasured. Keep the quantified claim only if this measurement is now part of the reported evidence; otherwise remove it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7233-repsel-cjs-iife-return-shape.md` around lines 59 - 64,
Resolve the contradiction in the changelog entry around the refusal
distribution: either document this instrumented-compiler measurement as part of
the reported evidence and align the PR objectives accordingly, or remove the
quantified figures “1397 of 1971,” “one,” and “88 such sites” if refined
producer-refusal distributions remain unmeasured. Preserve the surrounding
explanation of prioritizing conditional returns only after the status is
consistent.


Also fixes a latent hole this proof would otherwise have inherited:
`Expr::WithSet` carries its fallback `LocalId` in `WithSetFallback` rather than
in a child expression, so `spec_abi_sites::record_expr_use` — whose every other
arm delegates to the exhaustive `walk_expr_children` — had never recorded
`with (o) { x = v }` as a reassignment. `reassigned_locals` had been wrong
about that since it was written; the fix can only make it more conservative,
and only in a module containing `with`.

New gates: census liveness fixture
`benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts` (floors held
in code) and behavioural gap test
`test-files/test_gap_repsel_cjs_iife_return_shape.ts`, registered in
`test-parity/gc_repsel_corpus.txt`. The fixture deliberately lands **two**
allocation buckets from inside one IIFE — a served return and an unserved one —
because the served flag for a closure region is set in `codegen/closure.rs`,
which no compiler unit test can reach: hard-coding it `false` or `true` was a
green hole across all 526 of them and is red only in the census.
Comment on lines +79 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The unit-test count disagrees with the census file.

This line says the sabotage arms were "a green hole across all 526 of them". The docstring added to scripts/compiler_output_harness/repsel_census.py at line 343 says "leaves all 515 of them green". Both sentences describe the same compiler unit-test suite. Align the two numbers, or drop the exact count from one of them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7233-repsel-cjs-iife-return-shape.md` around lines 79 - 82, Align
the compiler unit-test count in the changelog passage describing the hard-coded
served flag with the 515 count documented in repsel_census.py, or remove the
exact count from that passage. Keep the explanation of the green-hole behavior
and census-only failure unchanged.

13 changes: 12 additions & 1 deletion crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,18 @@ pub(super) fn compile_closure(
.get(&func_id)
.cloned()
.unwrap_or_else(|| format!("closure#{func_id}"));
let _opt_report_scope = crate::opt_report::enter_closure(&opt_report_name, func_id);
// #7170 R1: a closure CAN carry a return-shape fact now — the CommonJS
// wrapper's IIFE makes every module-level `function` declaration one — so
// the served classification has to be told, exactly as
// `codegen/function.rs` tells it. Read by nothing but the report.
let _opt_report_scope = crate::opt_report::enter_closure(
&opt_report_name,
func_id,
cross_module
.module_dispatch
.return_shape_class(func_id)
.is_some(),
);
let native_facts = crate::collectors::collect_native_region_fact_graph(
body,
&[],
Expand Down
59 changes: 49 additions & 10 deletions crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,22 +477,27 @@ fn a_return_in_a_plain_function_is_still_a_rule_1_denial() {
);
}

/// #7170 §6, as a test rather than a comment: **91.6% of dependency-JS
/// allocation sites are in closure regions, and none of them is served.**
/// `collect_return_shape_functions` issues facts only for `hir.functions`
/// entries and the caller-side seed only fires on a bare `Expr::FuncRef`
/// callee, which a closure call never is. A classifier that keyed servedness
/// on the syntax of the returns alone — rather than on the fact — would mark
/// this whole population served and delete the wall it is supposed to measure.
/// A closure region **without** a return-shape fact is an ordinary rule-1
/// denial — the closure half of `a_return_in_a_plain_function_…`.
///
/// R0 asserted the stronger statement (*no* closure is ever served) and that
/// was the honest measurement then: `collect_return_shape_functions` issued
/// facts only for `hir.functions` entries and the caller-side seed only fired
/// on a bare `Expr::FuncRef` callee, which a closure call never is. #7170 R1
/// makes both halves reach a closure, so the surviving assertion is the one
/// that still bites: servedness keys on the FACT, never on the syntax of the
/// returns. A classifier that read the return shape alone would mark this
/// whole population — 91.6% of dependency-JS allocation sites (#7170 §2) —
/// served and delete the wall it is supposed to measure.
#[test]
fn a_closure_region_never_reports_a_served_return() {
fn a_closure_region_without_the_fact_is_still_a_rule_1_denial() {
let c = class_with_fields("C", &["x"]);
let mut classes = HashMap::new();
classes.insert("C".to_string(), &c);
let stmts = vec![Stmt::Return(Some(new_c()))];

let session = Session::start();
let _guard = crate::opt_report::enter_closure("mk", 7);
let _guard = crate::opt_report::enter_closure("mk", 7, false);
let _ = run(&stmts, &classes);
drop(_guard);
let entries = session.entries();
Expand All @@ -502,8 +507,42 @@ fn a_closure_region_never_reports_a_served_return() {
assert_eq!(
rows[0].rule.as_deref(),
Some("rule 1 (provenance)"),
"a closure body cannot carry a return-shape fact"
"servedness must come from the fact, not from the return's syntax"
);
assert_eq!(
rows[0].tier,
Some(crate::opt_report::Tier::CompilerLimitation)
);
}

/// #7170 R1: a closure region **with** a return-shape fact reports its return
/// site as served, exactly as a function region does.
///
/// This is what stops `codegen/closure.rs` reverting to R0's hard-coded
/// `false`: with the flag pinned off, every CommonJS module's producer sites
/// go back into the rule-1 bucket schedulers read, while the compiler is in
/// fact serving them. Paired with the test above, the two directions cannot
/// both be satisfied by a constant.
#[test]
fn a_closure_region_with_the_fact_reports_a_served_return() {
let c = class_with_fields("C", &["x"]);
let mut classes = HashMap::new();
classes.insert("C".to_string(), &c);
let stmts = vec![Stmt::Return(Some(new_c()))];

let session = Session::start();
let _guard = crate::opt_report::enter_closure("mk", 7, true);
let _ = run(&stmts, &classes);
drop(_guard);
let entries = session.entries();

let rows = alloc_rows(&entries);
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].rule.as_deref(),
Some("rule 1 (provenance) — already served by return-shape"),
);
assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served));
}

/// Only the RETURN position is served, across both nesting shapes a `return`
Expand Down
Loading
Loading