From de2da18c29965438f2c7465c7c39165c001656bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 02:28:27 +0000 Subject: [PATCH 1/5] codegen: Number locals are one scoped set with one query (charter step 5L, P5) A local is Number in scope S iff every write reaching a read in S is Number-producing. The function scope is number_by_construction_locals; a guarded loop clone opens its own scope in the receiver descriptor table (materialize_number_locals), ended by dematerialize_scope like every other scoped payload. type_analysis::local_is_number is the one query. Deleted: ElementShapeLoopFact::numeric_accumulator, StablePackedLoopFact::numeric_accumulators, PackedF64LoopFact and MaskedWindowArrayFact numeric_accumulators, StringWindowArrayFact::numeric_accumulator, and the per-family LocalGet disjunction in is_numeric_expr. Wired to the query: is_numeric_expr, expr_produces_canonical_raw_f64 (the LocalGet arm admitted integer locals only), the shadow-slot mirror skip, the non-pointer shadow value test, temp-root inertness, the Update coerce skip, the bitwise leaf and the declared-only violability test. --- changelog.d/number-locals-one-rule.md | 6 ++ .../src/collectors/receiver_regions.rs | 28 +++++- .../src/collectors/receiver_regions_tests.rs | 20 ++++- .../perry-codegen/src/expr/literals_vars.rs | 2 +- crates/perry-codegen/src/expr/mod.rs | 31 +------ crates/perry-codegen/src/expr/shadow_slot.rs | 17 ++-- crates/perry-codegen/src/rooting/temp_root.rs | 2 +- .../src/stmt/element_shape_loop.rs | 6 +- .../src/stmt/element_shape_native.rs | 4 +- crates/perry-codegen/src/stmt/loops.rs | 17 ++-- .../src/stmt/masked_window_region.rs | 3 - .../src/stmt/stable_packed_loop.rs | 8 +- .../src/stmt/string_length_loop.rs | 6 +- crates/perry-codegen/src/type_analysis.rs | 2 +- .../src/type_analysis/numeric.rs | 85 +++++++------------ .../src/type_analysis/numeric/tests.rs | 27 ++++++ crates/perry-codegen/src/type_analysis/pod.rs | 2 +- 17 files changed, 150 insertions(+), 116 deletions(-) create mode 100644 changelog.d/number-locals-one-rule.md diff --git a/changelog.d/number-locals-one-rule.md b/changelog.d/number-locals-one-rule.md new file mode 100644 index 0000000000..c07cdf3f6b --- /dev/null +++ b/changelog.d/number-locals-one-rule.md @@ -0,0 +1,6 @@ +Codegen now answers "does this local hold a Number here?" with one query over +one scoped set: the function-wide number-by-construction locals plus the locals +a guarded loop clone admitted at its entry. The per-loop-family accumulator +lists are gone, and the answer now also reaches the raw-double store and add +paths, so a reassigned Number accumulator such as `h = h + o.a` no longer takes +a value check on every use. diff --git a/crates/perry-codegen/src/collectors/receiver_regions.rs b/crates/perry-codegen/src/collectors/receiver_regions.rs index 68477914f4..56b483b703 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions.rs @@ -334,6 +334,15 @@ enum ActiveReceiverData { #[derive(Debug, Default)] pub(crate) struct ReceiverDescriptorTable { entries: Vec, + /// 5L (step5 DESIGN §4.1): the scoped Number-local sets, innermost last. + /// Each is the set a guarded clone proved for its own body: the locals + /// its entry test admitted and whose every in-clone write is + /// Number-preserving. The function scope is the static + /// `number_by_construction_locals`; `type_analysis::local_is_number` is + /// the one query over both. A scope ends with `dematerialize_scope`, like + /// every other scoped payload here, so the slow clone and post-loop code + /// never see it. + number_locals: Vec<(u32, Vec)>, } impl ReceiverDescriptorTable { @@ -712,10 +721,27 @@ impl ReceiverDescriptorTable { }) } + /// Open the Number-local scope of one guarded clone: `locals` hold a + /// Number at every read inside it. Empty sets are not recorded. + pub(crate) fn materialize_number_locals(&mut self, scope_id: u32, locals: &[u32]) { + if !locals.is_empty() { + self.number_locals.push((scope_id, locals.to_vec())); + } + } + + /// Whether an active clone scope proved `local` a Number. + pub(crate) fn local_is_number_in_scope(&self, local: u32) -> bool { + self.number_locals + .iter() + .any(|(_, locals)| locals.contains(&local)) + } + /// End every descriptor fact owned by a lexical proof scope. Each Phase 4 /// migration adds its scoped payload here, replacing a separate - /// `retain(scope_id)` discipline at the lowering site. + /// `retain(scope_id)` discipline at the lowering site. Returns the number + /// of receiver entries removed; the scope's Number-local set ends too. pub(crate) fn dematerialize_scope(&mut self, scope_id: u32) -> usize { + self.number_locals.retain(|(scope, _)| *scope != scope_id); let before = self.entries.len(); self.entries.retain(|entry| { let active_scope = match &entry.data { diff --git a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs index ee1a5913ef..b45a6cfcda 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs @@ -318,7 +318,6 @@ fn representation_payloads_use_the_common_scope_boundary() { store_side_exit_label: "slow".into(), array_kind: crate::expr::PackedNumericLoopKind::F64, allow_holes: false, - numeric_accumulators: vec![NUM2], window_validated: true, affine_indices: false, }); @@ -327,7 +326,6 @@ fn representation_payloads_use_the_common_scope_boundary() { .next() .expect("packed descriptor must be queryable"); assert_eq!(fact.array_local_id, OBJ); - assert_eq!(fact.numeric_accumulators, vec![NUM2]); assert!(table.has_packed_f64_loop_facts()); table.materialize_masked_window_array(crate::expr::MaskedWindowArrayFact { array_local_id: OBJ + 1, @@ -336,7 +334,6 @@ fn representation_payloads_use_the_common_scope_boundary() { min_idx: 0, max_idx_exclusive: 16, values_i32: false, - numeric_accumulators: Vec::new(), elem: crate::expr::MaskedWindowElem::PlainF64, allows_stores: false, }); @@ -1218,3 +1215,20 @@ fn the_inventory_covers_every_claim_kind_and_every_boundary_mechanism() { "inventory size changed — see FnCtx declarations" ); } + +#[test] +fn a_number_local_scope_ends_with_its_clone() { + // 5L: a clone-scoped Number local is visible only while its clone lowers; + // the slow clone and post-loop code must not see it. + let mut table = ReceiverDescriptorTable::default(); + table.materialize_number_locals(5, &[NUM]); + table.materialize_number_locals(6, &[NUM2]); + table.materialize_number_locals(7, &[]); + assert!(table.local_is_number_in_scope(NUM)); + assert!(table.local_is_number_in_scope(NUM2)); + assert_eq!(table.dematerialize_scope(6), 0); + assert!(table.local_is_number_in_scope(NUM)); + assert!(!table.local_is_number_in_scope(NUM2)); + table.dematerialize_scope(5); + assert!(!table.local_is_number_in_scope(NUM)); +} diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 6e0066dd78..992ca93074 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -654,7 +654,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // arms below keep their calls. let needs_numeric_coerce = !ctx.integer_locals.contains(id) && !ctx.unsigned_i32_locals.contains(id) - && !ctx.number_by_construction_locals.contains(id); + && !crate::type_analysis::local_is_number(ctx, *id); let is_increment_arg = match op { UpdateOp::Increment => "1", UpdateOp::Decrement => "0", diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index dcaf78e9a9..2b62d3dbe9 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1804,15 +1804,6 @@ pub(crate) struct StablePackedReadCache { pub(crate) struct StablePackedLoopFact { pub counter_local_id: u32, pub array_local_id: u32, - /// Plain locals the fast preheader proved to hold a Number (one tag test - /// per admitted accumulator) and whose every write inside the loop body is - /// numeric-preserving with all leaves provable numeric in-loop, so the - /// value stays a Number by induction for the whole fast clone. - /// `is_numeric_expr` consults this for `LocalGet`, exactly like the - /// element-shape clone's `numeric_accumulator` — it is what lets - /// `s += arr[i]` lower to a native `fadd` instead of - /// `js_dynamic_string_or_number_add` on every iteration. - pub numeric_accumulators: Vec, pub side_exit_label: String, pub descriptor: String, /// Boxed bound passed to the runtime guard (`-1` requests live length). @@ -1989,15 +1980,6 @@ pub(crate) struct PackedF64LoopFact { /// RHS is numeric bits (side-exiting otherwise) and skip the per-iteration /// store guard — the range guard already proved bounds and mutability. pub allow_holes: bool, - /// Plain locals the packed fast preheader proved to hold a Number (one - /// tag test per admitted accumulator) whose every in-body write is - /// numeric-preserving — the packed twin of - /// `StablePackedLoopFact::numeric_accumulators`. `is_numeric_expr` - /// consults this for `LocalGet`, which is what lets `s += arr[i]` inside - /// the fast clone lower to a native `fadd` instead of - /// `js_dynamic_string_or_number_add` on every iteration. Scope-safe by - /// construction: the fact is pushed around the fast-clone lowering only. - pub numeric_accumulators: Vec, /// True when a *range* guard (hole-tolerant or dense) validated the whole /// constant-offset index window `[start + min_offset, bound + max_offset)` /// at loop entry — `arr[i ± c]` loads may use non-zero offsets even @@ -2081,12 +2063,6 @@ pub(crate) struct MaskedWindowArrayFact { /// element type is exactly i32 (Int32Array tier), so loads may /// materialize elements as native `i32`. pub values_i32: bool, - /// Accumulator locals admitted by the entry tag check for THIS clone: - /// every in-clone write is numeric-preserving (verified by the - /// accumulator walk), so `is_numeric_expr` may treat them as Numbers - /// while the fact is live. Mirrors `StringWindowArrayFact`'s - /// `numeric_accumulator` (#9160) and `PackedF64LoopFact`'s vec. - pub numeric_accumulators: Vec, /// Storage layout the guard proved — selects the inline load shape. pub elem: MaskedWindowElem, /// True only in a dense fast-loop scope whose matcher admitted masked @@ -2111,7 +2087,6 @@ pub(crate) struct StringWindowArrayFact { pub scope_id: u32, pub min_idx: i64, pub max_idx_exclusive: i64, - pub numeric_accumulator: u32, } /// #5093: one fact per (receiver, versioned loop). See @@ -2367,10 +2342,6 @@ pub(crate) struct ElementShapeLoopFact { /// binds `r` generically. `None` for the single-statement accumulator /// form. pub element_binding: Option, - /// Mutable accumulator whose current value the preheader proved is a - /// Number. The matcher admits only assignments that preserve this fact, - /// and the fact exists only while lowering the guarded fast clone. - pub numeric_accumulator: u32, } /// Find the innermost active element-shape loop fact covering a @@ -3837,7 +3808,7 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result + if crate::type_analysis::local_is_number(ctx, *id)) => { let value = lower_expr(ctx, expr)?; return Ok(Some(if is_known_i32_range(ctx, expr) { diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 9eb78a6aaa..6954e2471a 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -47,7 +47,7 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp // annotation and remains valid at every read, including loop // counters whose back-edge update makes an initializer-only // proof ineligible. - if ctx.integer_locals.contains(id) || ctx.number_by_construction_locals.contains(id) { + if ctx.integer_locals.contains(id) || crate::type_analysis::local_is_number(ctx, *id) { return true; } // A reserved shadow slot means the local is pointer-possible even @@ -469,15 +469,12 @@ pub(crate) fn emit_shadow_slot_update_for_expr( if ctx.masked_region_scalar_locals.contains(&local_id) { return; } - // The element-shape clone's preheader checked this accumulator's current - // Number tag, and the matcher admits only numeric-preserving writes in a - // call-free clone. Its old shadow value may remain conservatively rooted; - // the slow clone resumes ordinary mirroring after the scoped fact is gone. - if ctx - .element_shape_loop_facts - .iter() - .any(|fact| fact.numeric_accumulator == local_id) - { + // A clone-scoped Number local (5L): the clone's entry test checked its + // current value is a Number and every in-clone write is Number-preserving, + // so the shadow slot already holds a non-pointer and keeps doing so. The + // old value may remain conservatively rooted; the slow clone resumes + // ordinary mirroring after the scope ends. + if ctx.receiver_descriptors.local_is_number_in_scope(local_id) { return; } let Some(slot_idx) = ctx.shadow_slot_map.get(&local_id).copied() else { diff --git a/crates/perry-codegen/src/rooting/temp_root.rs b/crates/perry-codegen/src/rooting/temp_root.rs index d7bcf25ad3..ab878ba22f 100644 --- a/crates/perry-codegen/src/rooting/temp_root.rs +++ b/crates/perry-codegen/src/rooting/temp_root.rs @@ -448,7 +448,7 @@ pub(in crate::rooting) fn local_is_inert_primitive(ctx: &FnCtx<'_>, id: u32) -> !ctx.shadow_slot_map.contains_key(&id) && !ctx.module_globals.contains_key(&id) && (ctx.integer_locals.contains(&id) - || ctx.number_by_construction_locals.contains(&id) + || crate::type_analysis::local_is_number(ctx, id) || matches!( ctx.stable_local_type_proof(&id), Some( diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 6f657bb8a9..d125a9958c 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -1742,8 +1742,11 @@ pub(super) fn lower_element_shape_versioned_for( fields, synthesized_body: matched.fast_body.is_some(), element_binding: matched.element_binding, - numeric_accumulator: matched.accumulator_id, }); + // The preheader proved the accumulator's current value is a Number and the + // matcher admits only Number-preserving writes: the clone's 5L scope. + ctx.receiver_descriptors + .materialize_number_locals(scope_id, &[matched.accumulator_id]); let lowered = lower_for_after_init_with_i32_bound( ctx, init, @@ -1755,6 +1758,7 @@ pub(super) fn lower_element_shape_versioned_for( ); ctx.element_shape_loop_facts .retain(|fact| fact.scope_id != scope_id); + ctx.receiver_descriptors.dematerialize_scope(scope_id); native.finish(ctx, &merge_label); lowered?; if !ctx.block().is_terminated() { diff --git a/crates/perry-codegen/src/stmt/element_shape_native.rs b/crates/perry-codegen/src/stmt/element_shape_native.rs index a406800e64..e221a59793 100644 --- a/crates/perry-codegen/src/stmt/element_shape_native.rs +++ b/crates/perry-codegen/src/stmt/element_shape_native.rs @@ -55,8 +55,8 @@ //! clones (`emit_gc_loop_safepoint`), so no collection observes the stale //! root slot, and a stale slot holds a Number, which a scan treats as data. //! * **JS `+`.** The redirect admits no write the clone did not already lower -//! as a bare `fadd`: the accumulator is the fact's `numeric_accumulator`, -//! which `is_numeric_expr` already trusted as a raw double inside this clone. +//! as a bare `fadd`: the accumulator is in the clone's 5L Number scope +//! (`type_analysis::local_is_number`), which `is_numeric_expr` already trusted as a raw double inside this clone. //! Keeping that same double in a register instead of a stack slot is the //! same IEEE arithmetic on the same operands in the same order, so `-0`, NaN //! and overflow to Infinity are bit-identical. diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index b920d64f3e..7e02131c3c 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -822,8 +822,8 @@ fn lower_strided_tagged_fill_loop( /// and emit one Number tag test each in the current (fast preheader) block, /// branching to the slow preheader when any holds a non-Number — the /// induction base case, exactly like the stable-packed clone's admission. -/// The returned ids ride the scope's `PackedF64LoopFact`, where -/// `is_numeric_expr` consults them so `s += arr[i]` lowers to a native +/// The returned ids open the clone's 5L Number scope +/// (`materialize_number_locals`), which `local_is_number` answers from so `s += arr[i]` lowers to a native /// `fadd` instead of `js_dynamic_string_or_number_add` per iteration. /// Range-loop wrapper: accumulators are collected against the loop's single /// counter-accessed array (the `arr[counter]` leaf of the accumulator walk). @@ -864,7 +864,7 @@ fn emit_range_loop_accumulator_admission( } /// The live state of a packed clone's accumulator admission: the admitted -/// ids (they ride the scope's fact so `is_numeric_expr` sees them), the +/// ids (they open the clone's Number scope, see `local_is_number`), the /// unboxed subset (id, F64 alloca, real slot) whose reads/writes redirect /// through `ctx.numeric_accumulator_f64_slots`, and the side-exit trampoline /// that writes the live values back before entering the slow clone. @@ -1473,8 +1473,9 @@ fn lower_packed_f64_versioned_for( allow_holes: false, window_validated: false, affine_indices: false, - numeric_accumulators: acc_scope.accumulators.clone(), }); + ctx.receiver_descriptors + .materialize_number_locals(packed_scope_id, &acc_scope.accumulators); // The guard just proved a live, non-forwarded plain array, and the // matched body cannot change its length (in-bounds stores only, no // calls/closures/awaits) — so hoist the length ONCE as the fast clone's @@ -3259,6 +3260,10 @@ fn push_packed_f64_range_facts( numeric_accumulators: &[u32], affine_window_proven: &std::collections::BTreeSet, ) { + // The range guard tag-tested every admitted accumulator; the scope ends + // with the caller's `dematerialize_scope(scope_id)`. + ctx.receiver_descriptors + .materialize_number_locals(scope_id, numeric_accumulators); for access in &matched.arrays { if access.counter.is_some() { ctx.receiver_descriptors @@ -3275,7 +3280,6 @@ fn push_packed_f64_range_facts( allow_holes: !matched.dense, window_validated: true, affine_indices: false, - numeric_accumulators: numeric_accumulators.to_vec(), }); } // #9253: an affine access publishes a receiver-only fact. No window @@ -3296,7 +3300,6 @@ fn push_packed_f64_range_facts( // the range clamp and the per-read bounds check. window_validated: affine_window_proven.contains(&access.array_id), affine_indices: true, - numeric_accumulators: numeric_accumulators.to_vec(), }); } if let Some((lo, hi)) = access.stat { @@ -3310,7 +3313,6 @@ fn push_packed_f64_range_facts( values_i32, elem: crate::expr::MaskedWindowElem::PlainF64, allows_stores: allow_masked_stores, - numeric_accumulators: numeric_accumulators.to_vec(), }, ); } @@ -3414,7 +3416,6 @@ fn lower_masked_window_ta_tier( values_i32, elem, allows_stores: false, - numeric_accumulators: Vec::new(), }, ); } diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index 26fb3740c4..3f1b83e90f 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -888,7 +888,6 @@ pub(super) fn lower_masked_window_region( values_i32: true, allows_stores: false, elem: MaskedWindowElem::TaI32 { data_ptr: data_i64 }, - numeric_accumulators: Vec::new(), }); } let privatize = ctx.try_depth == 0; @@ -1005,7 +1004,6 @@ pub(super) fn lower_masked_window_region( values_i32: true, allows_stores: false, elem: MaskedWindowElem::TaI32 { data_ptr }, - numeric_accumulators: Vec::new(), }); } let privatize = ctx.try_depth == 0; @@ -1040,7 +1038,6 @@ pub(super) fn lower_masked_window_region( values_i32: false, allows_stores: false, elem: MaskedWindowElem::PlainF64, - numeric_accumulators: Vec::new(), }); } lower_region_copy( diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index dd87f04fb4..be5e95167d 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -1751,10 +1751,14 @@ pub(super) fn lower( .map(|installed| installed.common_length.clone()), u32_out_of_bounds_label: None, numeric_access, - numeric_accumulators, derived_locals: std::collections::HashSet::new(), u32_view_derived_locals: std::collections::HashMap::new(), }); + // The fast preheader tag-tested every admitted accumulator and each + // in-clone write is Number-preserving: the clone's 5L scope. + let number_scope_id = ctx.next_loop_proof_scope_id(); + ctx.receiver_descriptors + .materialize_number_locals(number_scope_id, &numeric_accumulators); super::loops::lower_for_after_init_with_i32_bound( ctx, init, @@ -1765,6 +1769,8 @@ pub(super) fn lower( Some((candidate.counter_id, bound_i32)), )?; ctx.stable_packed_loop_facts.pop(); + ctx.receiver_descriptors + .dematerialize_scope(number_scope_id); if let Some(installed) = installed_typed_array_views { super::stable_packed_typed_array::restore_views(ctx, installed); } diff --git a/crates/perry-codegen/src/stmt/string_length_loop.rs b/crates/perry-codegen/src/stmt/string_length_loop.rs index feba735de1..a0330e151e 100644 --- a/crates/perry-codegen/src/stmt/string_length_loop.rs +++ b/crates/perry-codegen/src/stmt/string_length_loop.rs @@ -218,8 +218,11 @@ pub(super) fn lower( scope_id, min_idx: matched.min_idx, max_idx_exclusive: matched.max_idx_exclusive, - numeric_accumulator: matched.accumulator_id, }); + // #9160: the entry tag check admitted the accumulator and its sole write + // adds a proven string length: the clone's 5L scope. + ctx.receiver_descriptors + .materialize_number_locals(scope_id, &[matched.accumulator_id]); let saved_stride = ctx.poll_stride_counter_slot.take(); ctx.poll_stride_counter_slot = ctx.i32_counter_slots.get(&matched.counter_id).cloned(); lower_for_after_init_with_i32_bound( @@ -234,6 +237,7 @@ pub(super) fn lower( ctx.poll_stride_counter_slot = saved_stride; ctx.string_window_array_facts .retain(|fact| fact.scope_id != scope_id); + ctx.receiver_descriptors.dematerialize_scope(scope_id); if !ctx.block().is_terminated() { ctx.block().br(&merge_label); } diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index c09bd9d69a..9c5d360d03 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -33,7 +33,7 @@ mod strings; pub(crate) use numeric::{ expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_declared_number_expr, - is_integer_valued_expr, is_numeric_expr, is_provably_not_bigint, + is_integer_valued_expr, is_numeric_expr, is_provably_not_bigint, local_is_number, }; pub(crate) use pod::{ add_operands_have_pod_materialization_hazard, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 358c7f7920..190ec6c40e 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -124,6 +124,27 @@ fn string_method_call_returns_number(ctx: &FnCtx<'_>, object: &Expr, property: & && crate::lower_string_method::is_known_string_method_name(property) } +/// 5L (step5 DESIGN §4.1): THE query for "this local holds a Number here". +/// +/// A local is Number in scope Σ iff every write to it that can reach a read in +/// Σ is Number-producing, judged by a greatest fixed point. The function scope +/// is `number_by_construction_locals` (#8105: literals, numeric operators, +/// `Math.*`, Number locals, proven numeric fields; parameters, boxed cells and +/// module globals excluded). A guarded loop clone adds its own scope: the +/// locals its entry test admitted and whose every in-clone write is +/// Number-preserving (`ReceiverDescriptorTable::materialize_number_locals`), +/// ended with the clone. +/// +/// A Number's representation IS its raw double and every leaf of the rule +/// yields a canonical one (arithmetic gives the default NaN; typed-array float +/// lanes are canonicalised at the read), so a member never holds a pointer or +/// a NaN-box tag at a read in its scope. Every consumer that asks "Number?", +/// "raw f64?" or "non-pointer?" about a `LocalGet` asks this. +pub(crate) fn local_is_number(ctx: &FnCtx<'_>, id: u32) -> bool { + ctx.number_by_construction_locals.contains(&id) + || ctx.receiver_descriptors.local_is_number_in_scope(id) +} + pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::Integer(_) @@ -146,20 +167,7 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { true } Expr::LocalGet(id) => { - ctx.element_shape_loop_facts - .iter() - .rev() - .any(|fact| fact.numeric_accumulator == *id) - // The stable-packed twin: the fast preheader tag-tested the - // accumulator and every in-clone write is numeric-preserving, - // so within the fast clone the local provably holds a Number. - // The fact is pushed around the fast-clone lowering only, so - // the slow clone and post-loop code never see it. - || ctx - .stable_packed_loop_facts - .iter() - .rev() - .any(|fact| fact.numeric_accumulators.contains(id)) + local_is_number(ctx, *id) || ctx.integer_locals.contains(id) || ctx.unsigned_i32_locals.contains(id) || ctx.int_valued_i64_locals.contains_key(id) @@ -167,40 +175,6 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { ctx.stable_local_type_proof(id), Some(HirType::Number) | Some(HirType::Int32) ) - // #8105: the reassignment-tolerant proof. Every arm above - // either needs the local to be write-once - // (`stable_local_type_proof` answers `None` the moment it is - // reassigned) or is an integer-range fact, so a plain - // fractional accumulator — `let x = 0.0; … x = x * x - y * y - // + cx` — had NO numeric proof and every `x * x` bailed to - // the BigInt-aware `js_dynamic_mul`. This set proves the - // value is a Number from the WRITES, so reassignment is fine. - || ctx.number_by_construction_locals.contains(id) - // The packed-f64 clone twin of the stable-packed arm above: - // tag-tested in the versioned/range fast preheader, and every - // in-clone write is numeric-preserving by the accumulator - // walk. - || ctx - .receiver_descriptors - .packed_f64_loop_facts() - .rev() - .any(|fact| fact.numeric_accumulators.contains(id)) - // #9160: the string-window clone admits the accumulator only - // after an entry tag check, and its sole write adds a proven - // string length. The fact exists only while lowering that - // clone, so the slow copy retains dynamic `+` semantics. - // The dense masked-window clone's twin: same entry tag - // check, same numeric-preserving write proof. - || ctx - .receiver_descriptors - .masked_window_array_facts() - .rev() - .any(|fact| fact.numeric_accumulators.contains(id)) - || ctx - .string_window_array_facts - .iter() - .rev() - .any(|fact| fact.numeric_accumulator == *id) } // NOTE: Expr::Compare is NOT numeric — it produces a NaN-boxed // TAG_TRUE/TAG_FALSE which `fcmp one cond, 0.0` would handle @@ -637,9 +611,14 @@ pub(crate) fn is_declared_number_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// * explicit `NumberCoerce`; /// * `Logical` selections whose BOTH operands are themselves canonical. /// -/// Deliberately NOT admitted: `LocalGet` (a Number-typed local can hold an -/// INT32-boxed value assigned from a boxed read fallback), reads -/// (`IndexGet`/`PropertyGet` — cold fallbacks return boxed bits), and calls. +/// * a `LocalGet` of an integer-provenance local or a [`local_is_number`] +/// member: every write reaching the read is Number-producing, so the slot +/// holds a canonical double. A declared `number` type is NOT evidence (a +/// Number-typed local can hold an INT32-boxed value assigned from a boxed +/// read fallback), which is why the proof is the write rule, not the type. +/// +/// Deliberately NOT admitted: reads (`IndexGet`/`PropertyGet` — cold +/// fallbacks return boxed bits) outside the arms below, and calls. pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::Integer(_) | Expr::Number(_) => true, @@ -656,7 +635,9 @@ pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool // boxed, captured, or a module global (those can be rebound by code // the dataflow walk cannot see). Expr::LocalGet(id) => { - (ctx.i32_counter_slots.contains_key(id) || ctx.integer_locals.contains(id)) + (ctx.i32_counter_slots.contains_key(id) + || ctx.integer_locals.contains(id) + || local_is_number(ctx, *id)) && (ctx.locals.contains_key(id) || ctx.local_slot_reps.contains_key(id)) && !ctx.boxed_vars.contains(id) && !ctx.closure_captures.contains_key(id) diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index f28599b299..18ed1745b3 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -291,6 +291,33 @@ fn reassigned_number_accumulator_multiply_is_an_inline_fmul() { ); } +#[test] +fn a_number_local_is_a_canonical_raw_double_at_its_reads() { + // 5L: `local_is_number` reaches `expr_produces_canonical_raw_f64`. The + // accumulators are fractional (not integer locals), so before 5L the + // raw-f64 predicate refused them and the array literal re-tested each + // element against the lowest NaN-box tag (`0x7FF9 << 48`). + let mut body = mandelbrot_shaped_body(Expr::Number(0.5), Expr::Number(0.25)); + *body.last_mut().expect("return") = Stmt::Return(Some(Expr::Array(vec![ + Expr::LocalGet(10), + Expr::LocalGet(11), + ]))); + let ir = emitted_ir(probe_module( + "number_local_raw_f64_unit.ts", + Vec::new(), + body, + )); + assert!( + ir.contains("fmul double"), + "the accumulators must still be proven Numbers:\n{ir}" + ); + assert!( + !ir.contains("9221401712017801216"), + "a Number local is canonical raw f64 at its reads; the array literal \ + must not re-test it for a NaN-box tag:\n{ir}" + ); +} + #[test] fn a_reassigned_local_seeded_from_a_parameter_keeps_the_dynamic_helper() { // The SABOTAGE arm. Identical body, but `x` is seeded from an `Any` diff --git a/crates/perry-codegen/src/type_analysis/pod.rs b/crates/perry-codegen/src/type_analysis/pod.rs index c225dc4e48..53a89130ba 100644 --- a/crates/perry-codegen/src/type_analysis/pod.rs +++ b/crates/perry-codegen/src/type_analysis/pod.rs @@ -614,7 +614,7 @@ pub(crate) fn numeric_proof_is_declared_only(ctx: &FnCtx<'_>, expr: &Expr) -> bo // `undefined` seed overwritten on every path before use). // Keep the historical marker for generic/unproven bodies, // but do not let it mask that runtime-derived proof. - && !ctx.number_by_construction_locals.contains(id) + && !crate::type_analysis::local_is_number(ctx, *id) } // `a + b` is numeric only when both sides are, so it is violable when // either side is; `a || b` / `a && b` / `a ?? b` pass one operand From eecffc2a615ce752939f6a78ef1043a8807f14e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 07:40:51 +0000 Subject: [PATCH 2/5] codegen: masked-window refinements join the copy's Number-local scope (charter step 5L, P5) The sixth Number-local set, FnCtx::masked_region_scalar_locals, is deleted. A masked-window fast copy now admits a flow-refined local to its own scope (ReceiverDescriptorTable::admit_number_local) after the statement that wrote a Number, and withdraws it at the first write it cannot prove Number; the copy's dematerialize_scope ends the rest. type_analysis::local_is_number is the one query: the shadow-mirror skip no longer has a second arm, and every consumer of the query (is_numeric_expr, raw-f64 LocalGet, bitwise leaf, Update coerce, temp-root inertness) now sees the refinement too. The refinement lands strictly after its statement and the region admits only top-level scalar LocalSet/Update/pure statements, so a member holds a Number at every read inside the scope. Tests: receiver_regions_tests a_flow_refined_number_local_joins_and_leaves_its_copy_scope; gap fixture test_gap_masked_region_number_scope.ts (un-refine to a numeric string, refined reads in and after the region, ta_i32 / plain_f64 / slow copies). Sabotage: withdraw_number_local as a no-op turns the unit test red. --- .../number-locals-masked-region-scope.md | 5 ++ crates/perry-codegen/src/codegen/closure.rs | 1 - crates/perry-codegen/src/codegen/entry.rs | 2 - crates/perry-codegen/src/codegen/function.rs | 1 - crates/perry-codegen/src/codegen/method.rs | 1 - .../src/codegen/method_static.rs | 1 - .../src/collectors/receiver_regions.rs | 36 ++++++++++++ .../src/collectors/receiver_regions_tests.rs | 21 +++++++ crates/perry-codegen/src/expr/mod.rs | 12 ++-- crates/perry-codegen/src/expr/shadow_slot.rs | 13 ++--- .../src/stmt/masked_window_region.rs | 35 +++++++---- .../test_gap_masked_region_number_scope.ts | 58 +++++++++++++++++++ 12 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 changelog.d/number-locals-masked-region-scope.md create mode 100644 test-files/test_gap_masked_region_number_scope.ts diff --git a/changelog.d/number-locals-masked-region-scope.md b/changelog.d/number-locals-masked-region-scope.md new file mode 100644 index 0000000000..f5dae32fba --- /dev/null +++ b/changelog.d/number-locals-masked-region-scope.md @@ -0,0 +1,5 @@ +The masked-window region's flow-refined Number locals (#6750) are no longer a +separate set: a fast copy admits a refined local to its own Number-local scope +and withdraws it at the first write it cannot prove Number, so +`type_analysis::local_is_number` is the one query for every Number local and +`FnCtx::masked_region_scalar_locals` is deleted (charter step 5L, P5). diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 47458e331b..fb1cd68d19 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1188,7 +1188,6 @@ pub(super) fn compile_closure( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index f9d7e66e02..cf4b8247d4 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -771,7 +771,6 @@ pub(super) fn compile_module_entry( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), @@ -1633,7 +1632,6 @@ pub(super) fn compile_module_entry( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 3ad454033e..22c9bb89a5 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1256,7 +1256,6 @@ pub(super) fn compile_function( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 3bdb5dfd2c..e9cac7f389 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -665,7 +665,6 @@ pub(super) fn compile_method( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index 4e52bb1ba7..278bebfdf2 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -329,7 +329,6 @@ pub(in crate::codegen) fn compile_static_method( class_header_images: HashMap::new(), array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), - masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/collectors/receiver_regions.rs b/crates/perry-codegen/src/collectors/receiver_regions.rs index 56b483b703..bc119d323e 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions.rs @@ -729,6 +729,42 @@ impl ReceiverDescriptorTable { } } + /// Admit `local` to the Number set of the open scope `scope_id` at a flow + /// point inside it: a masked-window refinement lands strictly after the + /// statement that wrote a Number, and holds until a write the region cannot + /// prove Number withdraws it. Returns whether the local was newly admitted. + pub(crate) fn admit_number_local(&mut self, scope_id: u32, local: u32) -> bool { + if let Some((_, locals)) = self + .number_locals + .iter_mut() + .find(|(scope, _)| *scope == scope_id) + { + if locals.contains(&local) { + return false; + } + locals.push(local); + return true; + } + self.number_locals.push((scope_id, vec![local])); + true + } + + /// Withdraw `local` from the Number set of scope `scope_id` (the write just + /// lowered is not provably a Number). Returns whether it was a member. + /// Another open scope that proved it by its own fixed point keeps it. + pub(crate) fn withdraw_number_local(&mut self, scope_id: u32, local: u32) -> bool { + let Some((_, locals)) = self + .number_locals + .iter_mut() + .find(|(scope, _)| *scope == scope_id) + else { + return false; + }; + let before = locals.len(); + locals.retain(|member| *member != local); + before != locals.len() + } + /// Whether an active clone scope proved `local` a Number. pub(crate) fn local_is_number_in_scope(&self, local: u32) -> bool { self.number_locals diff --git a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs index b45a6cfcda..5c449f99b6 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs @@ -1232,3 +1232,24 @@ fn a_number_local_scope_ends_with_its_clone() { table.dematerialize_scope(5); assert!(!table.local_is_number_in_scope(NUM)); } + +#[test] +fn a_flow_refined_number_local_joins_and_leaves_its_copy_scope() { + // 5L: a masked-window fast copy admits a local at its refinement point and + // withdraws it at the first write it cannot prove Number. Withdrawal from + // one scope leaves another scope's own proof standing. + let mut table = ReceiverDescriptorTable::default(); + assert!(table.admit_number_local(9, NUM)); + assert!(!table.admit_number_local(9, NUM)); + assert!(table.local_is_number_in_scope(NUM)); + assert!(table.withdraw_number_local(9, NUM)); + assert!(!table.local_is_number_in_scope(NUM)); + assert!(!table.withdraw_number_local(9, NUM)); + table.materialize_number_locals(5, &[NUM2]); + assert!(table.admit_number_local(9, NUM2)); + assert!(table.withdraw_number_local(9, NUM2)); + assert!(table.local_is_number_in_scope(NUM2)); + assert!(table.admit_number_local(9, NUM)); + table.dematerialize_scope(9); + assert!(!table.local_is_number_in_scope(NUM)); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2b62d3dbe9..272e3ef6ec 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1015,21 +1015,17 @@ pub(crate) struct FnCtx<'a> { /// in-bounds SSO-or-heap string, so reads may bypass ordinary array /// dispatch and string `.length` needs no dynamic miss arm. pub string_window_array_facts: Vec, - /// #6750 follow-up: locals currently flow-refined to Number inside a - /// masked-window region fast copy — their shadow slots were cleared at - /// the refinement point and per-statement shadow updates are suppressed - /// until the refinement is dropped (`expr::shadow_slot`). - pub masked_region_scalar_locals: std::collections::HashSet, /// #6794 follow-up (b): shadow slots that a masked-window region fast copy /// has already cleared to 0 for a currently-suppressed local. Because - /// `emit_shadow_slot_update_for_expr` skips every write to a local in - /// `masked_region_scalar_locals`, such a slot provably stays 0 for the rest + /// `emit_shadow_slot_update_for_expr` skips every write to a local in the + /// copy's Number-local scope (`type_analysis::local_is_number`), such a + /// slot provably stays 0 for the rest /// of the suppression window — so every later per-statement clear of it (the /// `_tlv_get_addr`-heavy `js_shadow_slot_set(slot, 0)` that dominated /// bcryptjs `_encipher` profiles) is a redundant no-op. `emit_shadow_slot_clear` /// skips slots in this set; entries are added right after the first clear and - /// removed the moment the local leaves `masked_region_scalar_locals`. + /// removed the moment the local leaves that scope. pub suppressed_cleared_shadow_slots: std::collections::HashSet, /// #5093: scoped loop-versioning facts for monomorphic class-field loops. diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 6954e2471a..19d07b8d35 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -461,19 +461,14 @@ pub(crate) fn emit_shadow_slot_update_for_expr( value_reg: &str, rhs: &Expr, ) { - // #6750 follow-up: inside a masked-window region fast copy, a local - // flow-refined to Number had its slot cleared at the refinement point - // and every subsequent region write stores a proven number — no - // per-statement shadow traffic needed until the refinement is dropped - // (see `stmt::masked_window_region`). - if ctx.masked_region_scalar_locals.contains(&local_id) { - return; - } // A clone-scoped Number local (5L): the clone's entry test checked its // current value is a Number and every in-clone write is Number-preserving, // so the shadow slot already holds a non-pointer and keeps doing so. The // old value may remain conservatively rooted; the slow clone resumes - // ordinary mirroring after the scope ends. + // ordinary mirroring after the scope ends. A masked-window fast copy + // (#6750) admits a flow-refined local at its refinement point after + // clearing the slot, and withdraws it at the first write it cannot prove + // Number (`stmt::masked_window_region`). if ctx.receiver_descriptors.local_is_number_in_scope(local_id) { return; } diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index 3f1b83e90f..f227ba8520 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -676,6 +676,7 @@ fn lower_region_copy( base_idx: usize, emit_shadow_clears: bool, refinements: &[RegionRefinement], + scope_id: u32, privatize: bool, enable_i32: bool, ) -> Result<()> { @@ -729,14 +730,17 @@ fn lower_region_copy( if set_number { ctx.local_types.insert(id, perry_hir::types::Type::Number); // The local now provably holds a number for the rest of the - // copy (or until an unset): clear its shadow slot once and - // suppress the per-statement shadow updates — numbers need - // no GC root, and the region admits no statement that could - // store a pointer while suppressed. When the statement's own - // shadow update already emitted a clear (its RHS was a known - // non-pointer shape), don't emit a second one. + // copy (or until an unset): it joins this copy's Number-local + // scope, the one set `type_analysis::local_is_number` answers + // from. Its shadow slot is cleared once and the per-statement + // shadow updates are suppressed — numbers need no GC root, and + // the region admits no statement that could store a pointer + // while suppressed. When the statement's own shadow update + // already emitted a clear (its RHS was a known non-pointer + // shape), don't emit a second one. + let admitted = ctx.receiver_descriptors.admit_number_local(scope_id, id); if let Some(slot_idx) = ctx.shadow_slot_map.get(&id).copied() { - if ctx.masked_region_scalar_locals.insert(id) { + if admitted { let already_cleared = matches!( stmt, Stmt::Expr(Expr::LocalSet(_, rhs)) @@ -795,7 +799,7 @@ fn lower_region_copy( // prove numeric while its shadow update was suppressed — // re-bind the slot from the local's current value so GC sees // it again. - if ctx.masked_region_scalar_locals.remove(&id) { + if ctx.receiver_descriptors.withdraw_number_local(scope_id, id) { if let Some(slot_idx) = ctx.shadow_slot_map.get(&id).copied() { // #6794 (b): suppression ended — later clears of this slot // are real again, so stop skipping them. @@ -832,10 +836,13 @@ fn lower_region_copy( for id in &bound_i32 { ctx.i32_counter_slots.remove(id); } - // Drop any still-active suppressions before leaving the copy — the slow - // copy and post-region code use the ordinary shadow protocol. + // Drop any still-active admissions before leaving the copy — the slow + // copy and post-region code use the ordinary shadow protocol. The caller's + // `dematerialize_scope` ends the scope too; this keeps the copy's own + // bookkeeping closed on the early-return paths. for (id, _) in &saved { - ctx.masked_region_scalar_locals.remove(id); + ctx.receiver_descriptors + .withdraw_number_local(scope_id, *id); } // #6794 (b): the redundant-clear skip set is scoped to this copy; drop it so // the next copy / post-region code emits real clears again. @@ -897,6 +904,7 @@ pub(super) fn lower_masked_window_region( base_idx, emit_shadow_clears, ®ion.refinements, + ta_scope_id, privatize, true, ); @@ -1013,6 +1021,7 @@ pub(super) fn lower_masked_window_region( base_idx, emit_shadow_clears, ®ion.refinements, + ta_scope_id, privatize, // ta_i32 copy: masked reads are native i32, so bind region-scoped i32 // shadow slots and keep the whole bit-mixing chain out of the ToInt32 @@ -1046,6 +1055,7 @@ pub(super) fn lower_masked_window_region( base_idx, emit_shadow_clears, ®ion.refinements, + plain_scope_id, privatize, // plain_f64 copy: masked reads are f64, so an i32 shadow slot would be // maintained by no write — keep the ordinary Number lowering here. @@ -1058,12 +1068,15 @@ pub(super) fn lower_masked_window_region( // Slow copy: the untouched per-access lowering, original static types. ctx.current_block = slow_pre_idx; + // The slow copy admits nothing (no refinements); its scope stays empty. + let slow_scope_id = ctx.next_loop_proof_scope_id(); lower_region_copy( ctx, region_stmts, base_idx, emit_shadow_clears, &[], + slow_scope_id, false, false, )?; diff --git a/test-files/test_gap_masked_region_number_scope.ts b/test-files/test_gap_masked_region_number_scope.ts new file mode 100644 index 0000000000..fcc406f9ba --- /dev/null +++ b/test-files/test_gap_masked_region_number_scope.ts @@ -0,0 +1,58 @@ +// 5L: a masked-window region fast copy admits a flow-refined local to its +// Number-local scope (the one set `local_is_number` answers from) after the +// statement that wrote a Number, and withdraws it at the first write it cannot +// prove Number (here: a numeric string, later ToInt32'd outside the region). +// Refined locals are read as Numbers inside and after the region. Output must +// match Node on the ta_i32, plain_f64 and slow (mixed-array) copies. + +function unrefineToString(S: any, s: any, seed: number): number { + let x = S[15]; + let y = S[14]; + x = (x ^ S[0] ^ seed) | 0; + x = (((S[1] + S[2]) | 0) ^ S[3]) | 0; + x = s; + y = (x ^ S[4]) | 0; + y = (((y + S[5]) | 0) ^ S[6]) | 0; + y = (y ^ S[7]) | 0; + return y; +} + +// Refined locals read as Numbers later in the region and after it. +function refinedReads(S: any, seed: number): string { + let x = S[15]; + let y = S[14]; + x = (x ^ S[0] ^ seed) | 0; + y = x * 0.5 + S[1]; + x = (((S[2] + S[3]) | 0) ^ S[4]) | 0; + y = y + x + S[5] + S[6] + S[7]; + return String(x + 0.25) + "|" + String(y); +} + +// The un-refining write reads the local's Number value in its own RHS. +function unrefineReadsOld(S: any, seed: number): string { + let x = S[15]; + x = (x ^ S[0] ^ seed) | 0; + x = (((S[1] + S[2]) | 0) ^ S[3]) | 0; + x = "v" + x; + x = x + S[4] + S[5]; + x = x + S[6] + S[7]; + return x; +} + +const S = new Int32Array(16); +for (let i = 0; i < 16; i++) S[i] = ((i * 2654435761) ^ (i << 28)) | 0; +const Plain: number[] = []; +for (let i = 0; i < 16; i++) Plain.push(i * 3 - 7); +const Mixed: any[] = [1, "a", 2, 3, "b", 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; + +let out: string[] = []; +for (let i = 0; i < 2000; i++) { + const a = unrefineToString(S, String(1234 + (i & 3)), i); + const b = refinedReads(S, i); + const c = unrefineReadsOld(S, i); + const d = unrefineToString(Plain, "77", i); + const e = refinedReads(Plain, i); + if (i % 400 === 0) out.push(String(a), b, c, String(d), e); +} +out.push(String(unrefineToString(Mixed, "5", 3)), refinedReads(Mixed, 5), unrefineReadsOld(Mixed, 7)); +console.log(out.join("\n")); From 8ae2244f6bf138aa6da1f3eb11cc36137158bcbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 07:59:06 +0000 Subject: [PATCH 3/5] codegen: the shape-field leaf of the Number-local rule is unconditional (charter step 5L) h = h + o.a with o = new C(...) never admitted h as a Number: the #10777 shape-field leaf (a read of a proven-numeric field on a shape-proven receiver) was computed in the right order but its inputs were gated behind PERRY_L14_NBC_ORDER, default off, so collect_number_by_construction_locals always saw empty shape members. The knob, its build-cache entry and its object-cache key are deleted; shape_numeric_inputs always hands the fixpoint the receivers and the intersection of their numeric fields. The fields come from prove_numeric_fields (every reachable store is Number-producing by construction, never the declared type), the same proof that already licenses a bare load double of o.a. fpxnum (5L fixture): 25 -> 10 instr/iter; the loop no longer calls js_dynamic_string_or_number_add and h takes no root barrier. Tests: gap fixture test_gap_number_local_shape_field_leaf.ts (conditional and missing constructor stores, delete, string stores through any, escaping sink, computed key, method store, Object.assign, getter, NaN, the accumulator). Sabotage: shape_numeric_inputs returning empty sets turns shape_inputs_intersect_numeric_fields_before_proving_property_locals red. --- changelog.d/number-local-shape-field-leaf.md | 7 +++++ .../perry-codegen/src/collectors/hir_facts.rs | 5 +--- .../src/collectors/number_by_construction.rs | 28 ++--------------- .../perry/src/commands/compile/build_cache.rs | 6 ---- .../src/commands/compile/object_cache.rs | 9 ------ .../test_gap_number_local_shape_field_leaf.ts | 30 +++++++++++++++++++ 6 files changed, 41 insertions(+), 44 deletions(-) create mode 100644 changelog.d/number-local-shape-field-leaf.md create mode 100644 test-files/test_gap_number_local_shape_field_leaf.ts diff --git a/changelog.d/number-local-shape-field-leaf.md b/changelog.d/number-local-shape-field-leaf.md new file mode 100644 index 0000000000..a4dcb1b92f --- /dev/null +++ b/changelog.d/number-local-shape-field-leaf.md @@ -0,0 +1,7 @@ +`h = h + o.a` on a shape-proven receiver `o = new C(...)` now lowers as a +Number accumulator: the #10777 shape-field leaf of the function-scope +Number-by-construction rule is unconditional, and the default-off +`PERRY_L14_NBC_ORDER` knob (and its build/object cache keys) is deleted. On the +5L `fpxnum` fixture the loop drops from 25 to 10 instructions per iteration, with +no `js_dynamic_string_or_number_add` call and no root barrier on `h` +(charter step 5L). diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 1cb8f54deb..75723fbeae 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -740,10 +740,7 @@ pub(crate) fn collect_type_facts( // `shape_proven_ptr_locals` yields empty inputs below and the fixpoint then // computes exactly what it computed before. let (nbc_shape_members, nbc_shape_numeric_fields) = - super::number_by_construction::shape_numeric_inputs( - &shape_proven_ptr_locals, - super::number_by_construction::nbc_order_enabled(), - ); + super::number_by_construction::shape_numeric_inputs(&shape_proven_ptr_locals); let number_by_construction_locals = super::collect_number_by_construction_locals( stmts, params, diff --git a/crates/perry-codegen/src/collectors/number_by_construction.rs b/crates/perry-codegen/src/collectors/number_by_construction.rs index cefcb4bf0f..37a6620c0e 100644 --- a/crates/perry-codegen/src/collectors/number_by_construction.rs +++ b/crates/perry-codegen/src/collectors/number_by_construction.rs @@ -755,22 +755,6 @@ mod tests { // ── #10777: shape inputs for the function-scope walk ────────────────────── -/// `PERRY_L14_NBC_ORDER` gate. **Default OFF.** When off this returns empty -/// sets, the fixpoint sees exactly what it saw before, and every emitted byte -/// is identical to the pre-fix build — the reorder in `hir_facts.rs` is pure, -/// so the knob gates the INPUTS, not the position. Keyed into the object cache -/// so a warm cache cannot serve the other arm's object. -pub(crate) fn nbc_order_enabled() -> bool { - use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - matches!( - std::env::var("PERRY_L14_NBC_ORDER").as_deref(), - Ok("1") | Ok("on") | Ok("true") - ) - }) -} - /// Turn the receiver proofs into the `(members, numeric_fields)` pair the /// function-scope fixpoint needs. /// @@ -790,13 +774,10 @@ pub(crate) fn nbc_order_enabled() -> bool { /// /// A union would be a WRONG ANSWER, not a weaker one: `a` numeric on `C` and /// not on `D` would license a bare `fadd` on `D.a`. -/// The gate is passed in so both modes can be tested without changing the -/// process environment shared by parallel unit tests. pub(crate) fn shape_numeric_inputs( shape_proven: &HashMap, - enabled: bool, ) -> (HashSet, HashSet) { - if !enabled || shape_proven.is_empty() { + if shape_proven.is_empty() { return (HashSet::new(), HashSet::new()); } let mut members: HashSet = HashSet::new(); @@ -855,7 +836,7 @@ mod shape_input_tests { } #[test] - fn nbc_order_intersects_numeric_fields_before_proving_property_locals() { + fn shape_inputs_intersect_numeric_fields_before_proving_property_locals() { let shape_proven = HashMap::from([ ( 10, @@ -882,7 +863,7 @@ mod shape_input_tests { property_local(24, 12, "shared"), ]; - let (members, fields) = shape_numeric_inputs(&shape_proven, true); + let (members, fields) = shape_numeric_inputs(&shape_proven); let numeric = numeric_locals(&stmts, &members, &fields); assert!(numeric.contains(&20), "shared field on First is numeric"); assert!(numeric.contains(&21), "shared field on Second is numeric"); @@ -898,8 +879,5 @@ mod shape_input_tests { !numeric.contains(&24), "an unproven receiver is not a numeric input" ); - - let (off_members, off_fields) = shape_numeric_inputs(&shape_proven, false); - assert!(numeric_locals(&stmts, &off_members, &off_fields).is_empty()); } } diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 2a5d1d2f71..62cbf135e7 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -66,12 +66,6 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // of one shape compare plus a slot load. Different emitted code, so an // object built with regions must not be served to a build without them. "PERRY_REGION_READS", - // #10777: gates computing numeric-by-construction provenance AFTER the - // `Ptr` receiver proofs it depends on. On, an accumulator written - // `h = h + o.a` is admitted and the `+` routes to INLINE_FADD; off, the - // shape inputs are empty and it stays GUARDED. Different emitted code, so - // an object built one way must not be served to a build of the other. - "PERRY_L14_NBC_ORDER", // #9071: gates resolving a loop-called immutable callee binding once at // body entry instead of per call — the two settings emit different call // sequences, so a cached object from one must not serve the other. diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index b515b1a32c..6a1104d077 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1432,15 +1432,6 @@ fn compute_object_cache_key_with_env( .unwrap_or(""), ); - // #10777 — numeric-provenance fact ordering. `=1` lets the function-scope - // `number_by_construction` fixpoint see the `Ptr` receiver proofs - // computed before it, which flips `both_numeric` and with it the `+` - // lowering. Different IR, different .o bytes. - h.field( - "env_l14_nbc_order", - env_var("PERRY_L14_NBC_ORDER").as_deref().unwrap_or(""), - ); - // #10884 step 4b — the region kill switch. Same reasoning as the build // cache above, and the same trap #10929 fell into: keying ONE of the two // caches leaves the other serving objects compiled the other way. diff --git a/test-files/test_gap_number_local_shape_field_leaf.ts b/test-files/test_gap_number_local_shape_field_leaf.ts new file mode 100644 index 0000000000..225c85ea55 --- /dev/null +++ b/test-files/test_gap_number_local_shape_field_leaf.ts @@ -0,0 +1,30 @@ +// #10777 / charter step 5L: `h = h + o.a` with `o = new C(...)` is a Number +// accumulator by construction when `o` is a shape-proven receiver and every +// reachable store into `a` is Number-producing. The leaf is unconditional (the +// `PERRY_L14_NBC_ORDER` knob is gone), so each case below must still match Node +// wherever the field proof has to refuse: a conditional or missing constructor +// store, a delete, string stores through `any`, an escaping sink, a computed +// key, a method, Object.assign, a getter, and a NaN field value. +class C { a: number; b: number; + constructor(a: number, b: number, f: boolean) { if (f) this.a = a; this.b = b; } } +function condStore(n: number) { const o = new C(1, 2, false); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +class D { a: number; constructor(a?: number) { this.a = a as number; } } +function missingArg(n: number) { const o = new D(); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +class E { a: number; constructor(a: number) { this.a = a; } } +function deleted(n: number) { const o = new E(3); delete (o as any).a; let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +function strWrite(n: number) { const o = new E(3); (o as any).a = "x"; let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +function sink(x: any) { x.a = "s"; } +function escaped(n: number) { const o = new E(3); sink(o); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +function computed(n: number, k: string) { const o = new E(3); (o as any)[k] = "c"; let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +class F { a: number; constructor(a: number) { this.a = a; } set(v: any) { this.a = v; } } +function viaMethod(n: number) { const o = new F(3); o.set("m"); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +class G { a: number = 1; b: number; constructor(b: number) { this.b = b + this.a; } } +function initField(n: number) { const o = new G(2); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a + o.b; } return h; } +function nanPay(n: number) { const o = new E(0 / 0); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return [h, Object.is(h, NaN), String(h)]; } +function objAssign(n: number) { const o = new E(3); Object.assign(o, { a: "oa" }); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +function defProp(n: number) { const o = new E(3); Object.defineProperty(o, "a", { get() { return "g"; } }); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } return h; } +const results: any[] = [condStore(3), missingArg(3), deleted(3), strWrite(3), escaped(3), computed(3, "a"), viaMethod(3), initField(3), nanPay(3), objAssign(3), defProp(3)]; +class P { a: number; b: number; constructor(a: number, b: number) { this.a = a; this.b = b; } } +function accumulate(n: number) { const o = new P(1.5, 2); let h = 0; for (let i = 0; i < n; i++) { h = h + o.a; } o.b = h; return o.b; } +results.push(accumulate(1000), accumulate(0)); +console.log(results.map((r) => String(r)).join(" ")); From 2d53fcf19e055030e19d670d7f015403e5b3dbc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 11:24:05 +0200 Subject: [PATCH 4/5] changelog: name the fragments after #11662 --- ...shape-field-leaf.md => 11662-number-local-shape-field-leaf.md} | 0 ...region-scope.md => 11662-number-locals-masked-region-scope.md} | 0 ...{number-locals-one-rule.md => 11662-number-locals-one-rule.md} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{number-local-shape-field-leaf.md => 11662-number-local-shape-field-leaf.md} (100%) rename changelog.d/{number-locals-masked-region-scope.md => 11662-number-locals-masked-region-scope.md} (100%) rename changelog.d/{number-locals-one-rule.md => 11662-number-locals-one-rule.md} (100%) diff --git a/changelog.d/number-local-shape-field-leaf.md b/changelog.d/11662-number-local-shape-field-leaf.md similarity index 100% rename from changelog.d/number-local-shape-field-leaf.md rename to changelog.d/11662-number-local-shape-field-leaf.md diff --git a/changelog.d/number-locals-masked-region-scope.md b/changelog.d/11662-number-locals-masked-region-scope.md similarity index 100% rename from changelog.d/number-locals-masked-region-scope.md rename to changelog.d/11662-number-locals-masked-region-scope.md diff --git a/changelog.d/number-locals-one-rule.md b/changelog.d/11662-number-locals-one-rule.md similarity index 100% rename from changelog.d/number-locals-one-rule.md rename to changelog.d/11662-number-locals-one-rule.md From d86af9d800c760bf5d04d10db66695616cb34166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 29 Sep 2026 09:37:38 +0000 Subject: [PATCH 5/5] fix warnings: drop unused import, gate debug-only relevant_box_roots --- crates/perry-codegen/src/stmt/element_shape_loop.rs | 2 -- crates/perry-runtime/src/box.rs | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 67b4451b42..321a669790 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -577,8 +577,6 @@ fn declared_array_element_type_hint<'a>( /// keeps the answer independent of `ctx.classes` iteration order, which is a /// `HashMap`'s. fn anon_shape_class_for_element_type(ctx: &FnCtx<'_>, array_id: u32) -> Option { - use perry_hir::types::Type as HirType; - // The annotation selects a candidate versioned clone. The clone's // preheader validates the receiver kind, array head, shape, and key token // before any representation-specific access, and falls back on failure. diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 8dce01b473..c48da63290 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -1037,6 +1037,7 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { /// Every live box whose current payload a minor can move, mark through, or /// sweep. This is the authoritative debug re-derivation of the remembered set. +#[cfg(any(debug_assertions, test))] fn relevant_box_roots() -> Vec { let mut relevant = BOX_REGISTRY.with(|registry| { registry