From d06798e002c970ecb4fe1f71c69a2c57526a43c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 20 Sep 2026 12:03:55 +0200 Subject: [PATCH 1/3] fix(codegen): name the mechanism that drops an unspendable Ptr proof (#10793) `repsel-census` has been red on `main` since v0.5.1613 on one hard check: batch: 1 selected ptr-shape promotion(s) were not consumed, and not one of them names a mechanism. The wasted promotion is `row` in `buildRows` (benchmarks/app-patterns/ kernels/batch.ts). Its only use is `rows.push(row)`, a contained element push, so it clears rule 2 with no property access anywhere in the body: the proof is sound and there is nothing to spend it on. That is the residue `check_unconsumed_is_explained` already described as "dropped by nobody" and then tolerated, on condition that some OTHER promotion in the same workload named a rule. Nobody is a mechanism; it just had no recorder. #10769 exposed it without causing it, established by A/B rather than inferred: subtracting only #10769's `RepselContextFlags::derive` `Entry` hunk from today's `main` reproduces the v0.5.1612 `batch` report entry-for-entry and the census goes green. What that hunk moved is `batch`'s other wasted promotion -- `totals`, in the module-init body, until then dropped by `module_init_context` and now consumed. Removing the workload's only named mechanism left the pre-existing residue as its only wasted promotion, and so the only one the check could see. So this names the mechanism rather than undoing anything. No floor lowered, no allowance widened. * `PTR_SHAPE_NO_ACCESS_SITE` ("no_access_site") joins `module_init_context` and `scalar_replaced`. The three are disjoint by construction: `module_init_context` fires from `ptr_shape_receiver_fact`, which only runs AT an access site, and `scalar_replaced` fires for objects whose HIR field accesses are exactly what scalar replacement rewrites. * `ptr_shape_report::note_no_access_site` records it, from the same loop iteration as the `select()` it annuls. The verdict reads the use walk's own containment bookkeeping -- `field_stores`, `method_calls`, plus a new `UseWalk::field_reads` for the one shape that stores nothing -- rather than a second, separately-drifting definition of "access site". Report-only. * Inferring the rule at render time from `selected - consumed` was rejected: it would name a mechanism for every gap and permanently disarm the check. * `report_early_bail` moves to `ptr_shape_report.rs` as `report::early_bail` (it calls nothing but `report::`), keeping `ptr_shape.rs` under the 2000-line cap. Corpus effect is one report entry: the full 29-workload run against the v0.5.1616 release compiler differs from the fixed compiler by `no_access_site 1` and nothing else -- every count, floor and advisory line byte-identical. The gate still discriminates, checked both ways. Disabling the new recorder returns `batch` to the original failure; disabling the pre-existing `scalar_replaced` recorder reddens fixture_alloc_buckets, suite_07_object_create and suite_12_binary_trees, which `no_access_site` does not paper over. The new test asserts both halves and fails under either sabotage, including a recorder that fires on a local that does have an access site -- the direction that would turn the census permanently green. --- changelog.d/10793-repsel-wasted-ptr-shape.md | 24 +++++ .../perry-codegen/src/collectors/ptr_shape.rs | 51 +++++----- .../collectors/ptr_shape_opt_report_tests.rs | 92 +++++++++++++++++++ .../src/collectors/ptr_shape_report.rs | 75 +++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 3 +- crates/perry-codegen/src/expr/slot_rep.rs | 39 ++++++++ 6 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 changelog.d/10793-repsel-wasted-ptr-shape.md diff --git a/changelog.d/10793-repsel-wasted-ptr-shape.md b/changelog.d/10793-repsel-wasted-ptr-shape.md new file mode 100644 index 0000000000..1fa4813f4f --- /dev/null +++ b/changelog.d/10793-repsel-wasted-ptr-shape.md @@ -0,0 +1,24 @@ +**A `Ptr` proof that has no access site to spend now says so, instead of reading like an honest zero.** Fixes the `repsel-census` gate, red on `main` since v0.5.1613. + +The census refused a workload with a wasted promotion and no named mechanism: + +``` +batch: 1 selected ptr-shape promotion(s) were not consumed, and not one of them +names a mechanism. … Either a mechanism recorder was removed, or a new way to +drop a proof exists and needs one. +``` + +The wasted promotion is `row` in `buildRows` (`benchmarks/app-patterns/kernels/batch.ts`). Its only use is `rows.push(row)`, a contained element push, so it survives rule 2 with **no property access anywhere in the body** — the proof is sound and there is nothing to spend it on. That is the residue `check_unconsumed_is_explained`'s docstring already described as "dropped by nobody" and then tolerated, on condition that some *other* promotion in the same workload named a rule. Nobody is a mechanism; it just had no recorder. + +#10769 exposed it without causing it. Confirmed by A/B rather than inferred: subtracting only #10769's `RepselContextFlags::derive` `Entry` hunk from today's `main` reproduces the v0.5.1612 `batch` report entry-for-entry and the census goes green. What that hunk changed is `batch`'s **other** wasted promotion — `totals`, in the module-init body, previously dropped by `module_init_context` and now consumed. Removing the only named mechanism on the workload left the pre-existing residue as the only wasted promotion, and therefore the only one the check could see. + +So the fix names the mechanism rather than undoing anything. No floor was lowered and no allowance widened. + +- `PTR_SHAPE_NO_ACCESS_SITE` (`no_access_site`) joins `module_init_context` and `scalar_replaced` in `expr/slot_rep.rs`. The three are disjoint by construction: `module_init_context` fires from `ptr_shape_receiver_fact`, which only runs *at* an access site, and `scalar_replaced` fires for objects whose HIR field accesses are exactly what scalar replacement rewrites. +- `ptr_shape_report::note_no_access_site` records it, called from the same loop iteration as the `select()` it annuls. The verdict reads the use walk's **own** containment bookkeeping — `field_stores`, `method_calls`, plus a new `UseWalk::field_reads` for the one shape that stores nothing — rather than a second, separately-drifting definition of "access site". Report-only; `--opt-report` off allocates and records nothing. +- Inferring the rule at render time from `selected − consumed` was rejected: it would name a mechanism for *every* gap and permanently disarm the check. +- `report_early_bail` moved to `ptr_shape_report.rs` as `report::early_bail` (it calls nothing but `report::`) to keep `ptr_shape.rs` under the 2000-line cap. + +Effect on the corpus is one report entry: the full 29-workload run against the v0.5.1616 release compiler differs from the fixed compiler by `no_access_site 1` and nothing else — every promotion count, floor and advisory-improvement line is byte-identical. + +The gate still discriminates, checked in both directions. Disabling the new recorder returns `batch` to the original failure; disabling the pre-existing `scalar_replaced` recorder reddens `fixture_alloc_buckets`, `suite_07_object_create` and `suite_12_binary_trees`, which `no_access_site` does **not** paper over. `a_selected_promotion_with_no_access_site_names_its_mechanism` asserts both halves and fails under either sabotage — including a recorder that fires on a local that *does* have an access site, the direction that would turn the census permanently green. diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index c79765d553..13b64f34e3 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -134,7 +134,6 @@ use std::collections::{HashMap, HashSet}; use perry_hir::{Class, Expr, Stmt}; -use super::cjs_scaffolding::CjsPreamble; use super::ptr_shape_elements::ElementShapeFacts; use super::ptr_shape_report as report; use super::ptr_shape_report::ShapeDenial; @@ -229,33 +228,6 @@ fn note_ptr_shape_local( ); } -/// Record every candidate the collector will not even reach, on an -/// early-bail path (env gate off, or the rule-5 module-wide barrier). -/// -/// Runs only under `--opt-report`; it re-derives the candidate seeds purely -/// to name them, and returns nothing the collector consumes — the bail-out -/// itself is unchanged. -fn report_early_bail( - stmts: &[Stmt], - boxed_vars: &HashSet, - module_globals: &HashMap, - preamble: &CjsPreamble, - denial: ShapeDenial, -) { - if !opt_report::enabled() { - return; - } - let names = report::local_names(stmts); - let depths = report::loop_depths(stmts); - let seeds = report::candidate_seeds(stmts, boxed_vars, module_globals, preamble); - for (id, class_name) in &seeds { - report::deny_local(*id, &names, &depths, Some(class_name), denial); - } - for site in report::unbound_new_sites(stmts, preamble) { - report::deny_alloc_site(&site); - } -} - #[path = "ptr_shape_entry.rs"] mod entry; pub(crate) use entry::{ @@ -295,7 +267,7 @@ fn collect_shape_proven_ptr_locals_impl( None }; if let Some(denial) = bail { - report_early_bail(stmts, boxed_vars, module_globals, &preamble, denial); + report::early_bail(stmts, boxed_vars, module_globals, &preamble, denial); return (HashMap::new(), HashMap::new()); } // `--opt-report` (#6952): binding names and loop depths for the values @@ -317,7 +289,7 @@ fn collect_shape_proven_ptr_locals_impl( // replacement (excludes boxed and module-global locals — which also // excludes async/generator bodies, whose locals are boxed by the // async-to-generator transform), minus #7152's CommonJS module record. - // Shared with `report_early_bail` so the collector and the report can + // Shared with `report::early_bail` so the collector and the report can // never disagree about what a candidate is. let mut candidates = report::candidate_seeds(stmts, boxed_vars, module_globals, &preamble); // #7034 §4: `const r = producer(...)` where `producer` carries a @@ -411,6 +383,7 @@ fn collect_shape_proven_ptr_locals_impl( element_pushes: HashMap::new(), const_local_inits: HashMap::new(), disq_reasons: HashMap::new(), + field_reads: HashSet::new(), escape_ctx: report::ESC_BARE_REFERENCE, return_seeded, element_seeded: &element_seeded, @@ -428,6 +401,7 @@ fn collect_shape_proven_ptr_locals_impl( element_pushes, const_local_inits, disq_reasons, + field_reads, .. } = walk; // #7770: locals whose every write is number-producing by construction — @@ -584,6 +558,11 @@ fn collect_shape_proven_ptr_locals_impl( }), }; note_ptr_shape_local(*id, &fact, &names, &depths); + // #10793. Beside the `select()` it annuls, so the two cannot drift. + let accessed = field_reads.contains(id) + || field_stores.contains_key(id) + || method_calls.contains_key(id); + report::note_no_access_site(*id, &fact, accessed); // Aliases carry the same fact: they hold the same object, their slots // are equally shadow-bound, and access sites key on the local they // actually reference. @@ -819,6 +798,10 @@ struct UseWalk<'a> { /// disqualified it. Purely observational — `disqualified` is the set the /// proof consults; this only records why. disq_reasons: HashMap, + /// `--opt-report` (#10793): roots with a declared-field READ. The other + /// four access shapes already land in `field_stores`/`method_calls`, so + /// together they decide `report::note_no_access_site`. + field_reads: HashSet, /// The escape kind a bare `LocalGet` in the current position implies. /// Parent arms narrow it (`return`, call argument, array element, …) so /// the report can say *how* the object escaped, not just that it did. @@ -872,6 +855,13 @@ impl<'a> UseWalk<'a> { self.disq_reasons.entry(root).or_insert(why); } + /// #10793; report-only. See [`Self::field_reads`]. + fn note_field_read(&mut self, root: u32) { + if opt_report::enabled() { + self.field_reads.insert(root); + } + } + /// Run `f` with the bare-reference escape kind narrowed to `why`. fn with_ctx(&mut self, why: ShapeDenial, f: impl FnOnce(&mut Self)) { let previous = self.escape_ctx; @@ -1070,6 +1060,7 @@ impl<'a> UseWalk<'a> { } => { if let Expr::LocalGet(id) = object.as_ref() { if let Some(root) = self.tracked_root(*id) { + self.note_field_read(root); if !self.candidate_chain_has_field(root, property) { self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs index 1c826a2d62..64aeb432b2 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -981,3 +981,95 @@ fn a_bare_return_is_still_a_return_position_and_still_served() { assert_eq!(rows[0].alloc_context.as_deref(), Some("return")); assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served)); } + +/// #10793. A `Ptr` proof that no property access can ever spend must say +/// so, and the recorder must NOT fire for a proof that has one. +/// +/// This is the pair, not the positive alone. The check it answers — +/// `repsel_census.check_unconsumed_is_explained` — is satisfied by ANY named +/// mechanism, so a recorder that fired on every selected local would turn the +/// census permanently green while naming a reason that is false for most of +/// them. The second half is what makes the first half worth anything. +/// +/// `return ` is the shape that gets a local past rule 2 with no access +/// site at all (#7034 §4 exempts a bare return), which is the corpus shape too: +/// `buildRows`'s `row` in `benchmarks/app-patterns/kernels/batch.ts` survives +/// on a contained `rows.push(row)` and never reads a field. +#[test] +fn a_selected_promotion_with_no_access_site_names_its_mechanism() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + + // Proven, and nothing in the body reads or writes a field of it. + let session = Session::start(); + let facts = run( + &[let_c(1, "idle"), Stmt::Return(Some(Expr::LocalGet(1)))], + &classes, + ); + let entries = session.entries(); + drop(session); + + assert!( + facts.contains_key(&1), + "the recording must be observational: the local is still promoted" + ); + assert!( + entries + .iter() + .any(|e| e.name == "idle" && e.outcome == Outcome::Selected), + "the win must still be counted, or there is no wasted promotion to explain" + ); + let dropped = entries + .iter() + .find(|e| e.name == "idle" && e.outcome == Outcome::Unconsumed) + .expect("a promotion with no access site must name a mechanism"); + assert_eq!(dropped.rule.as_deref(), Some("no_access_site")); + assert_eq!(dropped.analysis, crate::opt_report::Analysis::PtrShape); + assert_eq!(dropped.local_id, Some(1)); + assert!( + dropped + .reason + .as_deref() + .unwrap_or("") + .contains("never itself the object of a property access"), + "the reason must state the mechanism, not a neighbouring one: {:?}", + dropped.reason + ); + + // The discriminating half: one declared-field store is enough, and the + // recorder must stay silent. + let session = Session::start(); + let facts = run( + &[ + let_c(2, "used"), + store_x(2), + Stmt::Return(Some(Expr::LocalGet(2))), + ], + &classes, + ); + let entries = session.entries(); + drop(session); + + assert!( + facts.contains_key(&2), + "the accessed local is still promoted" + ); + assert!( + entries + .iter() + .any(|e| e.name == "used" && e.outcome == Outcome::Selected), + "…and still counted as a win" + ); + assert!( + !entries + .iter() + .any(|e| e.name == "used" && e.outcome == Outcome::Unconsumed), + "a local with an access site must NOT be reported as having none: {:?}", + entries + .iter() + .filter(|e| e.name == "used") + .map(|e| (e.outcome, e.rule.clone())) + .collect::>() + ); +} diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs index 891b54ee1c..88ea3b9f1d 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_report.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -35,6 +35,81 @@ use perry_hir::{Class, Expr, Stmt}; use super::cjs_scaffolding::CjsPreamble; use crate::opt_report::{self, Analysis, Denial, Position, Tier}; +/// Record every candidate the collector will not even reach, on an +/// early-bail path (env gate off, or the rule-5 module-wide barrier). +/// +/// Runs only under `--opt-report`; it re-derives the candidate seeds purely +/// to name them, and returns nothing the collector consumes — the bail-out +/// itself is unchanged. +pub(super) fn early_bail( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + preamble: &CjsPreamble, + denial: ShapeDenial, +) { + if !opt_report::enabled() { + return; + } + let names = local_names(stmts); + let depths = loop_depths(stmts); + let seeds = candidate_seeds(stmts, boxed_vars, module_globals, preamble); + for (id, class_name) in &seeds { + deny_local(*id, &names, &depths, Some(class_name), denial); + } + for site in unbound_new_sites(stmts, preamble) { + deny_alloc_site(&site); + } +} + +/// Record that a SELECTED `Ptr` local has no access site to spend its +/// proof at (#10793), so its promotion is wasted for a nameable reason. +/// +/// Report-only, and the counterpart of `ptr_shape::note_ptr_shape_local` +/// rather than a second opinion about it: the same loop iteration that counts +/// the win records that nothing will consume it. The alternative — inferring it +/// at render time from `selected` minus `consumed` — would fabricate a +/// mechanism for every gap and permanently disarm +/// `repsel_census.check_unconsumed_is_explained`, which is the check this +/// exists to answer honestly. +/// +/// `accessed` is the caller's verdict over the walk's OWN bookkeeping: a +/// declared-field read (`UseWalk::field_reads`), a store of any of the three +/// writing shapes (`field_stores`), or a chain-resolved method call +/// (`method_calls`). Reading what the containment proof already had to collect +/// is what keeps this from drifting into a second, disagreeing definition of +/// "access site". +/// +/// The other two `Ptr` drop mechanisms are disjoint from this one by +/// construction, so one value cannot be counted twice: `MODULE_INIT_CONTEXT` +/// fires from `FnCtx::ptr_shape_receiver_fact`, which only runs AT an access +/// site, and [`crate::expr::PTR_SHAPE_SCALAR_REPLACED`] fires for objects whose +/// HIR field accesses are exactly what scalar replacement rewrites. +pub(super) fn note_no_access_site(id: u32, fact: &super::ptr_shape::PtrShapeLocal, accessed: bool) { + // Same `SuppressScope` guard as `note_ptr_shape_local`: the return-shape + // pre-pass re-runs this proof speculatively (#7034 §4). + if accessed || suppressed() || !opt_report::enabled() { + return; + } + let (reason, issue) = + crate::expr::ptr_shape_context_rule_text(crate::expr::PTR_SHAPE_NO_ACCESS_SITE); + opt_report::unconsumed(opt_report::Unconsumed { + position: Position::Local, + name: fact.report_name.as_deref().unwrap_or(""), + local_id: Some(id), + analysis: Analysis::PtrShape, + rep: "Ptr", + rule: crate::expr::PTR_SHAPE_NO_ACCESS_SITE, + reason, + tier: Tier::CompilerLimitation, + issue: Some(issue), + detail: Some(format!( + "proven Ptr of class {}; the local has no property access in this body", + fact.class_name + )), + }); +} + /// A named denial: the rule as the collector numbers it, a human expansion, /// the actionability tier, and the tracking issue when the fix is ours. #[derive(Debug, Clone, Copy)] diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index c8b492b0f2..88c168d226 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -216,7 +216,8 @@ pub(crate) use slot_rep::{ 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, ptr_shape_context_rule_text, - store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_SCALAR_REPLACED, + store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_NO_ACCESS_SITE, + PTR_SHAPE_SCALAR_REPLACED, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 8dc38ff77c..21e77799e8 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -491,6 +491,33 @@ fn context_rule_text(rule: &str) -> (&'static str, &'static str) { /// promotion that was wasted, and the two mean opposite things. pub(crate) const PTR_SHAPE_SCALAR_REPLACED: &str = "scalar_replaced"; +/// `Ptr` consumption rule: the proven local is never itself the object +/// of a property access, so no representation-selection lowering ever consults +/// it (#10793). +/// +/// A candidate survives rule 2 only through uses that PRESERVE containment: a +/// bare `return `, a contained `A.push()` into an +/// element-shape-proven array (#7034 §3), and single-`Let` alias bindings. Any +/// other bare reference disqualifies it. So a selected local whose uses are +/// exclusively of that kind is proven and then has nowhere to spend the proof. +/// `buildRows`'s `row` in `benchmarks/app-patterns/kernels/batch.ts` is the +/// corpus example: its only use is `rows.push(row)`. The field reads happen +/// later, through the array's own element-shape fact, on a different value. +/// +/// This is the mechanism `repsel_census.check_unconsumed_is_explained` called +/// "a promotion with no access site at all is dropped by nobody" and then +/// tolerated as an unnamed residue. Nobody is a mechanism; it just had no +/// recorder. #10769 made that visible by CONSUMING `batch`'s other wasted +/// promotion (`totals`, until then dropped by [`MODULE_INIT_CONTEXT`]), which +/// left this residue as the workload's only wasted promotion — and a wasted +/// promotion that names no rule is indistinguishable from an honest zero, which +/// is the one state that census exists to refuse. +/// +/// Not a defect in the analysis: the proof is sound and costs a report entry, +/// not an emitted byte. The defect was that it looked exactly like a promotion +/// codegen had silently refused to apply. +pub(crate) const PTR_SHAPE_NO_ACCESS_SITE: &str = "no_access_site"; + /// `(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) { @@ -511,6 +538,15 @@ pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static nothing", PTR_SHAPE_SCALAR_REPLACED_ISSUE, ), + PTR_SHAPE_NO_ACCESS_SITE => ( + "the local is never itself the object of a property access: every use \ + that survived rule 2 is containment-preserving (a bare `return`, a \ + contained `A.push(x)` into an element-shape-proven array, an alias \ + binding), so no representation-selection lowering ever consults the \ + fact. The proof is sound and cost nothing to emit — it simply had \ + nowhere to be spent", + PTR_SHAPE_NO_ACCESS_SITE_ISSUE, + ), _ => ( "async / generator bodies set `repsel_context_allows_canonical_i32: \ false`, and `FnCtx::ptr_shape_receiver_fact` returns None for the \ @@ -524,6 +560,9 @@ pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static /// Tracking issue for the scalar-replacement consumption mechanism. const PTR_SHAPE_SCALAR_REPLACED_ISSUE: &str = "#7115"; +/// Tracking issue for the no-access-site consumption mechanism. +const PTR_SHAPE_NO_ACCESS_SITE_ISSUE: &str = "#10793"; + pub(crate) fn deny_canonical_context( ctx: &FnCtx<'_>, id: u32, From f0771bc3d2f29efe0c19e78dae7115b7dcf44d5c Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 07:03:06 +0000 Subject: [PATCH 2/3] perf(codegen): a property read on a Ptr receiver with a numeric field vouches as canonical raw f64 (#10777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expr_produces_canonical_raw_f64` refused every `PropertyGet` on principle, so an arithmetic leaf reading a field whose shape already records it as numeric kept a per-iteration tag test. The fact was present and consumed — `property_get.rs:1523` emits `load(DOUBLE, ...)` with `rep: F64` — and then re-derived syntactically from the Expr node at the add, which is also why `o.a * 1` reaches 9 where `o.a` does not: `Binary` is a shape that predicate recognises, and the multiply normalises nothing. The arm admits a `PropertyGet` whose receiver carries `ptr_shape_receiver_fact(...).numeric_fields.contains(property)`. h += o.a 35 -> 33 h += o.a + o.b 50 -> 43 o.a = o.a + 1; h += o.a 97 -> 94 That is 2-7 instructions per iteration on one shape, and it does NOT reach parity: node is 6.5-7.1 on the same fixtures. Two named blockers remain, and neither is this arm. Module-global receivers carry no Ptr fact at all (#7109), so every fixture in #10777 and #10761 is an absence rather than a zero. And `SlotRep` has no F64 variant (slot_rep.rs:80, whose own doc says it "grows richer reps (F64, Ptr, ...) in later phases"), so `const v = ` re-boxes at the binding and the LocalGet refusal is correct there — the slot really is boxed. The precondition is discharged by construction: `expr_numeric_by_construction` has no Call, MethodCall or FFI arm, so an FFI return can never enter numeric_fields. The one raw-byte source that can is the ArrayBuffer conduit, closed by #10779. --- changelog.d/10777-ptrshape-rawf64-arm.md | 5 ++ .../src/type_analysis/numeric.rs | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 changelog.d/10777-ptrshape-rawf64-arm.md diff --git a/changelog.d/10777-ptrshape-rawf64-arm.md b/changelog.d/10777-ptrshape-rawf64-arm.md new file mode 100644 index 0000000000..2dd3621922 --- /dev/null +++ b/changelog.d/10777-ptrshape-rawf64-arm.md @@ -0,0 +1,5 @@ +**A property read whose shape already records the field as numeric now vouches as canonical raw f64.** + +`expr_produces_canonical_raw_f64` refused every `PropertyGet` on principle, so an arithmetic leaf reading such a field kept a per-iteration tag test — even though the fact was present and already consumed by codegen, which emits `load(DOUBLE, …)` with `rep: F64`. It was then re-derived *syntactically* from the Expr node at the add. That is also why `o.a * 1` reaches 9 where `o.a` does not: `Binary` is a shape the predicate recognises, and the multiply normalises nothing. + +`h += o.a` goes 35 → 33, `h += o.a + o.b` 50 → 43, `o.a = o.a + 1; h += o.a` 97 → 94 — 2–7 instructions per iteration where the receiver is a class instance with proven provenance. diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index b0a983ef5f..24955943dd 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -667,6 +667,63 @@ pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool { true } + // #10777: a `Ptr`-proven receiver whose field is in the fact's + // `numeric_fields` already lowers to a BARE `load double` — + // `expr/property_get.rs` and `expr/property_get/helpers.rs` emit + // `blk.load(DOUBLE, &field_ptr)` with `SemanticKind::JsNumber` / + // `NativeRep::F64` and, in their own words, "no volatile gate, no + // header checks, no fallback arm, no phi". The value this predicate is + // asked about is therefore ALREADY canonical raw f64 bits; what was + // missing is that the label never reached here. `lower_expr` returns a + // bare LLVM value name, and the `raw_f64_layout_fact` the read records + // goes to `native_value/verify`, an AUDIT channel — so this predicate + // re-derived "reads may be boxed" syntactically from the `Expr` node + // instead of asking what was emitted. That lost label is the whole of + // the 29-vs-9 gap in #10777: `o.a * 1` reaches 9 only because `Binary` + // is a shape the arm below recognises, not because the multiply + // normalises anything. + // + // The blanket refusal in this function's doc comment — "reads + // (`IndexGet`/`PropertyGet` — cold fallbacks return boxed bits)" — is + // about the GUARDED read diamond, whose cold arm really does hand back + // boxed bits. This arm is the one read shape that has no cold arm at + // all, so the refusal's reason does not apply to it. + // + // The two halves of this predicate's contract are discharged as + // follows: + // + // * "never NaN-boxed" — `collectors/ptr_shape_numeric.rs` proves, by a + // greatest fixpoint over EVERY reachable store (constructor chain, + // field initializers, method stores, in-function stores, with + // parameters resolved through the actual arguments at the provenance + // `new`s / recorded call sites), that every store into this slot is + // number-producing BY CONSTRUCTION, per spec — explicitly not "the + // declared type says number". `Ptr` rule 1 containment bounds + // the set of stores that fixpoint has to consider, which is why the + // read needs no runtime header check. That proof already licenses a + // bare `load double` claiming `JsNumber`/`F64`; it is strictly + // stronger than the claim made here. + // + // * "any NaN it produces must carry a non-tag payload" — the store + // universe above admits no call of any kind (that match has no + // `Call`/`MethodCall`/FFI arm and ends in `_ => false`), so an FFI + // return can never reach one of these slots. The one raw byte source + // that can is an `ArrayBuffer` float read, reachable through the + // `Add` arm's `numeric_view_value_or_undefined`, and #10779 removed + // it by canonicalising a tag-band NaN at that read. + // + // Requires the receiver to resolve to the fact's own class, exactly as + // `numeric_proof_is_declared_only` and the two read sites do: the + // `numeric_fields` set is keyed to the class it was proven for. + Expr::PropertyGet { + object, property, .. + } => { + let Some(fact) = ctx.ptr_shape_receiver_fact(object.as_ref()) else { + return false; + }; + fact.numeric_fields.contains(property.as_str()) + && receiver_class_name(ctx, object).as_deref() == Some(fact.class_name.as_str()) + } Expr::Binary { .. } => { is_numeric_expr(ctx, e) && is_provably_not_bigint(ctx, e) From 1b81143b1aa6fac826b6d67815a49b47367eeddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 20 Sep 2026 12:36:27 +0200 Subject: [PATCH 3/3] chore: release merge train 239 as v0.5.1618 --- CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d53967e24a..86970f7e65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1617 +**Current Version:** 0.5.1618 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 1899e6c64f..83e47ca547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5565,7 +5565,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-dispatch", "serde", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "cc", "libc", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "aho-corasick", "anyhow", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5671,7 +5671,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5679,7 +5679,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-dispatch", @@ -5688,7 +5688,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -5708,7 +5708,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5716,7 +5716,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "async-trait", "clap", @@ -5740,14 +5740,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "serde", "serde_json", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "clap", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "block2", "objc2", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "argon2", "perry-ffi", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bcrypt", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rusqlite", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "scraper", @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "cron", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rust_decimal", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "perry-runtime", @@ -5858,14 +5858,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fetch" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bytes", "lazy_static", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "bytes", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lazy_static", "perry-ffi", @@ -5920,7 +5920,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "perry-ffi", @@ -5928,7 +5928,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bson", "futures-util", @@ -5940,7 +5940,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "perry-ffi", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bytes", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lettre", "perry-ffi", @@ -5996,7 +5996,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "notify", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "printpdf", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "sqlx", @@ -6025,7 +6025,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "fast_image_resize", "image", @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lazy_static", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-ffi", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "perry-runtime", @@ -6074,7 +6074,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "futures-util", "lazy_static", @@ -6087,7 +6087,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "brotli", "flate2", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-api-manifest", @@ -6127,11 +6127,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-parser" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-diagnostics", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perex", "regex", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "ahash", "base64 0.22.1", @@ -6210,14 +6210,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6301,21 +6301,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "dirs", "perry-ffi", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "jni", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "rand 0.10.2", "serde", @@ -6350,7 +6350,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6373,7 +6373,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6407,7 +6407,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-ui-test" @@ -6418,11 +6418,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-ui-tvos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6439,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6456,7 +6456,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "block2", "libc", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "libc", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "libc", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -6517,7 +6517,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d1d68ef65f..11bdfdbd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -321,7 +321,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1617" +version = "0.5.1618" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"