From 90d8424cfd4b79567df4d79e41135ed380e4a423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 10:13:04 +0100 Subject: [PATCH 01/19] codegen: an index clone's parameter is covered by an I32 signature slot of any sign; probes # typed_call and PERRY_SIG_DIAG --- .../src/codegen/signature_entry.rs | 74 ++++++- crates/perry-codegen/src/expr/mod.rs | 2 +- crates/perry-wpfacts/src/lib.rs | 3 +- crates/perry-wpfacts/src/signatures.rs | 188 +++++++++++++----- .../perry/src/commands/compile/compile_env.rs | 1 + 5 files changed, 211 insertions(+), 57 deletions(-) diff --git a/crates/perry-codegen/src/codegen/signature_entry.rs b/crates/perry-codegen/src/codegen/signature_entry.rs index 6842b6421a..c811548dde 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -293,6 +293,52 @@ pub(crate) struct TypedMethod { pub typed_trampoline: bool, } +/// An index clone's parameter is covered by an I32 signature slot of any +/// sign. The clone proves its index parameters non-negative int32s so that an +/// element key built from them needs no sign test. Since the unsigned bound +/// (N10, `index_key::unsigned_bound`) an int32 key is bounded by one unsigned +/// compare that declines every negative key, so the sign of an I32 slot buys +/// the clone nothing a typed body lacks; the typed body also takes the +/// parameter as a word at every site, where the clone needs each site to +/// prove the whole tuple non-negative, or its guarded entry. +/// `PERRY_SIG_SIGNED_INDEX=0` (`off`, `false`) keeps the clone unless the +/// parameter's interval is non-negative. +pub(crate) fn signed_index_params() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + crate::expr::index_key::unsigned_bound() + && !matches!( + std::env::var("PERRY_SIG_SIGNED_INDEX").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// The probe `PERRY_SIG_DIAG=1`: one line per method a `typed_calls` site +/// reaches and the facts give a signature, with the selector's verdict +/// (`typed=1`, or the tier or shape that refuses it). +fn probe(class: &str, method: &str, sig: &FactsSignature, refused: Option<&str>) { + if std::env::var("PERRY_SIG_DIAG").as_deref() != Ok("1") { + return; + } + let params: Vec = sig + .params + .iter() + .zip(&sig.ranges) + .map(|(r, iv)| match iv { + Some((lo, hi)) => format!("{r:?}[{lo},{hi}]"), + None => format!("{r:?}"), + }) + .collect(); + match refused { + None => eprintln!("[perry sig] {class}.{method} typed=1 params=({})", params.join(",")), + Some(why) => eprintln!( + "[perry sig] {class}.{method} typed=0 why={why} params=({})", + params.join(",") + ), + } +} + /// The typed methods of `hir` (T2), installed after every method selector /// ran as on main; their subsumed tiers (proven-`this`, the proven-argument clone, the /// index clones, typed_abi `typed_f64`/`typed_i32`) are dropped for them @@ -328,6 +374,7 @@ pub(crate) fn select_methods( .iter() .any(|p| p.is_rest || p.arguments_object.is_some()) { + probe(&class.name, &m.name, sig, Some("excluded-shape")); continue; } let key = (class.name.clone(), m.name.clone()); @@ -340,6 +387,7 @@ pub(crate) fn select_methods( || crate::collectors::ptr_array_cached_method(class, m).is_some() || super::arguments::method_supports_arguments_length_direct_abi(m) { + probe(&class.name, &m.name, sig, Some("excluded-tier")); continue; } let mut defaults = Vec::with_capacity(m.params.len()); @@ -357,9 +405,11 @@ pub(crate) fn select_methods( }); } if !ok { + probe(&class.name, &m.name, sig, Some("default")); continue; } let Some(body) = sig_function(m, &defaults) else { + probe(&class.name, &m.name, sig, Some("default")); continue; }; let demoted = crate::collectors::callee_demoted_params(&body); @@ -385,6 +435,7 @@ pub(crate) fn select_methods( // routes to the boxed entry. let tower = cm.pshape_tower_routable.contains(&key); if tower && proven_this.is_none() { + probe(&class.name, &m.name, sig, Some("tower")); continue; } // CWN 11-09 (T6 as the selector): every tier `M$sig` would @@ -394,21 +445,32 @@ pub(crate) fn select_methods( // proven-`this` layout, and the proven-argument clone's proven argument shapes // (which no representation carries). let all = |want: FactsRep| sig.params.iter().all(|r| at_least(*r, want)); + let signed_index = signed_index_params(); let index_ok = cm.nonnegative_index_methods.get(&key).is_none_or(|ids| { m.params .iter() .zip(sig.params.iter().zip(sig.ranges.iter())) .all(|(p, (rep, range))| { !ids.contains(&p.id) - || *rep == FactsRep::I32 && range.is_some_and(|(lo, _)| lo >= 0) + || *rep == FactsRep::I32 + && (signed_index || range.is_some_and(|(lo, _)| lo >= 0)) }) }); - if cm.typed_i32_methods.contains(&key) && !all(FactsRep::I32) + let less_precise = if cm.typed_i32_methods.contains(&key) && !all(FactsRep::I32) || cm.typed_f64_methods.contains(&key) && !all(FactsRep::F64) - || !index_ok - || cm.pshape_methods.contains_key(&key) && proven_this.is_none() - || cm.pshape_arg_methods.contains_key(&key) { + Some("typed-abi-clone") + } else if !index_ok { + Some("index-clone") + } else if cm.pshape_methods.contains_key(&key) && proven_this.is_none() { + Some("proven-this") + } else if cm.pshape_arg_methods.contains_key(&key) { + Some("proven-argument") + } else { + None + }; + if less_precise.is_some() { + probe(&class.name, &m.name, sig, less_precise); continue; } // A signature whose every slot is Boxed changes the body only @@ -418,8 +480,10 @@ pub(crate) fn select_methods( && sig.ret.encoding() == FactsEnc::Boxed && sig.params.iter().all(|r| r.encoding() == FactsEnc::Boxed) { + probe(&class.name, &m.name, sig, Some("all-boxed")); continue; } + probe(&class.name, &m.name, sig, None); typed.push(TypedMethod { published_pshape: cm.pshape_methods.contains_key(&key), index_admission: cm.nonnegative_index_methods.contains_key(&key), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 4b1292ead5..ca3a36ebbb 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3356,7 +3356,7 @@ pub(crate) mod index_get; pub(crate) use index_get::guarded_array::emit_spread_stack_admission; #[cfg(test)] mod index_get_claim_tests; -mod index_key; +pub(crate) mod index_key; #[cfg(test)] mod kind_entry_selection_tests; #[cfg(test)] diff --git a/crates/perry-wpfacts/src/lib.rs b/crates/perry-wpfacts/src/lib.rs index 3b4cc5f7af..1c08c6500d 100644 --- a/crates/perry-wpfacts/src/lib.rs +++ b/crates/perry-wpfacts/src/lib.rs @@ -1005,7 +1005,7 @@ fn compute_act<'a>( rest_fields::admit(&g, &mut templates, interp_guard.is_some()) }; rest_fields::export_reads(&g, &templates, &mut bindings); - let rep_probe = if cfg.diagnostics && !g.m.aborted { + let mut rep_probe = if cfg.diagnostics && !g.m.aborted { let mut p = bindings::rep_probe(&g); p.extend(rest_lanes); if g.m.zero_sign { @@ -1043,6 +1043,7 @@ fn compute_act<'a>( &mut bindings, interp_guard.is_some(), cfg.sabotage.as_deref(), + cfg.diagnostics.then_some(&mut rep_probe), ); } if g.m.aborted { diff --git a/crates/perry-wpfacts/src/signatures.rs b/crates/perry-wpfacts/src/signatures.rs index 506a3842e6..30f7a81ca7 100644 --- a/crates/perry-wpfacts/src/signatures.rs +++ b/crates/perry-wpfacts/src/signatures.rs @@ -127,6 +127,7 @@ pub(crate) fn collect( bindings: &mut [ModuleBindings], interpreted: bool, sabotage: Option<&str>, + mut probe: Option<&mut Vec>, ) { let m = &g.m; if m.aborted || interpreted { @@ -223,11 +224,57 @@ pub(crate) fn collect( } // Typed call sites (§2, E3). let mut typed: Vec<((u32, u32), (u32, u32))> = Vec::new(); - for (key, (t, recs)) in call_targets(g, sites) { - let Some(sig) = sigs.get(&t) else { continue }; - let lits = &defaults[&t]; - if recs.iter().all(|r| admits(g, &all, sig, lits, &r.args)) { - typed.push((key, t)); + for (key, (t, recs)) in resolve(g, sites) { + let why = match t { + Err(why) => Some(why.to_string()), + Ok(t) => match sigs.get(&t) { + None => None, + Some(sig) => { + let lits = &defaults[&t]; + let refused = recs + .iter() + .find_map(|r| refusal(g, &all, sig, lits, &r.args)); + if refused.is_none() { + typed.push((key, t)); + } + refused.map(|(i, why)| format!("argument-{i}-{why}")) + } + }, + }; + // The probe: each call that resolves to a body with a signature, and + // each call a method site names candidates for but that does not + // resolve to one body. + if let Some(out) = probe.as_deref_mut() { + let name = |(mi, id): (u32, u32)| { + hir_fn(g, mi, id).map_or_else(|| format!("{mi}:{id}"), |f| f.name.clone()) + }; + let (mi, offset) = key; + match (t, why) { + (Ok(t), None) if sigs.contains_key(&t) => out.push(format!( + "# typed_call module={mi} call={offset} callee={} typed=1", + name(t) + )), + (Ok(t), Some(why)) => out.push(format!( + "# typed_call module={mi} call={offset} callee={} typed=0 why={why}", + name(t) + )), + (Err(_), Some(why)) => { + let mut names: Vec = sites + .iter() + .filter(|s| s.is_call && (s.module, s.byte_offset) == key) + .flat_map(|s| s.target_fns.iter().map(|t| name(*t))) + .collect(); + names.sort(); + names.dedup(); + if !names.is_empty() { + out.push(format!( + "# typed_call module={mi} call={offset} callee={} typed=0 why={why}", + names.join("|") + )); + } + } + _ => {} + } } } if matches!(sabotage, Some("signature") | Some("signature-escape")) { @@ -278,6 +325,21 @@ pub(crate) fn call_targets<'g>( g: &'g Gen<'_>, sites: &[SiteFacts], ) -> std::collections::BTreeMap<(u32, u32), ((u32, u32), Vec<&'g CallSiteRec>)> { + resolve(g, sites) + .into_iter() + .filter_map(|(key, (t, recs))| Some((key, (t.ok()?, recs)))) + .collect() +} + +/// A source call's one body, or why it has none (`target_of`). +type Target = Result<(u32, u32), &'static str>; + +/// Every source call gen walked, keyed `(module, offset)`: its target +/// (`target_of`) and its copies. +fn resolve<'g>( + g: &'g Gen<'_>, + sites: &[SiteFacts], +) -> std::collections::BTreeMap<(u32, u32), (Target, Vec<&'g CallSiteRec>)> { let m = &g.m; let mut copies: std::collections::HashMap> = Default::default(); for inst in 0..m.sum.insts.len() as u32 { @@ -290,7 +352,6 @@ pub(crate) fn call_targets<'g>( .chain(copies.get(&n).into_iter().flatten().copied()) .collect() }; - let live = |n: NodeId| (n as usize) < m.nodes.len(); let mut by_key: std::collections::BTreeMap<(u32, u32), Vec<&CallSiteRec>> = Default::default(); for r in &g.call_sites_at { by_key.entry((r.module, r.offset)).or_default().push(r); @@ -308,47 +369,62 @@ pub(crate) fn call_targets<'g>( } out }; - let mut out = std::collections::BTreeMap::new(); - for (&(mi, offset), recs) in &by_key { - if offset == 0 || recs.iter().any(|r| r.spread) { - continue; - } - // E3: the key names one source call; its copies share its span. - let end = recs[0].end; - if recs.iter().any(|r| r.end != end) { - continue; - } - // Every copy is a call of the same kind. - let direct = recs[0].callee.is_some(); - if recs.iter().any(|r| r.callee.is_some() != direct) { - continue; - } - let target = if direct { - // Every copy (and every instance copy of its callee node) - // holds exactly one body, the same one; a missing node refuses. - let mut one: Option<(u32, u32)> = None; - let mut ok = true; - for r in recs { - for c in all(r.callee.expect("a direct call")) { - match live(c).then(|| single_body(g, c)).flatten() { - Some(b) if one.is_none_or(|o| o == b) => one = Some(b), - _ => ok = false, - } + by_key + .into_iter() + .map(|((mi, offset), recs)| { + let t = target_of(g, &all, &method_target, mi, offset, &recs); + ((mi, offset), (t, recs)) + }) + .collect() +} + +/// The one body the source call `(mi, offset)` (its copies `recs`) resolves +/// to (§2, E3), or why it has none (the `# typed_call` probe's `why`). +fn target_of( + g: &Gen<'_>, + all: &dyn Fn(NodeId) -> Vec, + method_target: &std::collections::HashMap<(u32, u32), Option<(u32, u32)>>, + mi: u32, + offset: u32, + recs: &[&CallSiteRec], +) -> Result<(u32, u32), &'static str> { + let live = |n: NodeId| (n as usize) < g.m.nodes.len(); + if offset == 0 { + return Err("offset-0"); + } + if recs.iter().any(|r| r.spread) { + return Err("spread"); + } + // E3: the key names one source call; its copies share its span. + let end = recs[0].end; + if recs.iter().any(|r| r.end != end) { + return Err("distinct-calls-share-the-key"); + } + // Every copy is a call of the same kind. + let direct = recs[0].callee.is_some(); + if recs.iter().any(|r| r.callee.is_some() != direct) { + return Err("mixed-call-kinds"); + } + if direct { + // Every copy (and every instance copy of its callee node) holds + // exactly one body, the same one; a missing node refuses. + let mut one: Option<(u32, u32)> = None; + for r in recs { + for c in all(r.callee.expect("a direct call")) { + match live(c).then(|| single_body(g, c)).flatten() { + Some(b) if one.is_none_or(|o| o == b) => one = Some(b), + _ => return Err("callee-not-one-body"), } } - if ok { - one - } else { - None - } - } else { - method_target.get(&(mi, offset)).copied().flatten() - }; - if let Some(t) = target { - out.insert((mi, offset), (t, recs.clone())); + } + one.ok_or("callee-not-one-body") + } else { + match method_target.get(&(mi, offset)) { + Some(Some(t)) => Ok(*t), + Some(None) => Err("method-not-one-target"), + None => Err("no-method-site"), } } - out } /// Whether `(module, func_id)` may have a typed signature at all (§1): not a @@ -391,6 +467,18 @@ fn admits( lits: &[Option], args: &[NodeId], ) -> bool { + refusal(g, all, sig, lits, args).is_none() +} + +/// The first parameter whose argument does not hold `sig` (`admits`), with +/// the reason (the `# typed_call` probe's `why`). +fn refusal( + g: &Gen<'_>, + all: &dyn Fn(NodeId) -> Vec, + sig: &Signature, + lits: &[Option], + args: &[NodeId], +) -> Option<(usize, &'static str)> { use crate::model::prim; // The int interval of the values joined so far: `None` before the // first value, `Some(None)` once one value is no int. @@ -404,21 +492,21 @@ fn admits( let lit_range = |v: f64| { (v.fract() == 0.0 && !(v == 0.0 && v.is_sign_negative())).then_some((v as i64, v as i64)) }; - sig.params.iter().enumerate().all(|(i, &rep)| { + sig.params.iter().enumerate().find_map(|(i, &rep)| { if rep.encoding() == Enc::Boxed { - return true; + return None; } let lit = lits.get(i).copied().flatten(); let mut acc: Option> = None; match args.get(i) { None => match lit { Some(v) => join_range(&mut acc, lit_range(v)), - None => return false, + None => return Some((i, "omitted")), }, Some(&a) => { for n in all(a) { if (n as usize) >= g.m.nodes.len() { - return false; + return Some((i, "no-node")); } let b = crate::bindings::facts_of_node(g, n); let mut prims = b.prims; @@ -427,7 +515,7 @@ fn admits( join_range(&mut acc, lit_range(v)); } if b.other || !b.class_ids.is_empty() || prims & !prim::NUM != 0 { - return false; + return Some((i, "not-a-number")); } if prims != 0 { join_range(&mut acc, b.int_range); @@ -438,9 +526,9 @@ fn admits( match (rep, acc) { (Rep::I32, Some(r)) => { let (lo, hi) = sig.ranges[i].unwrap_or((i64::from(i32::MIN), i64::from(i32::MAX))); - r.is_some_and(|(a, b)| a >= lo && b <= hi) + (!r.is_some_and(|(a, b)| a >= lo && b <= hi)).then_some((i, "outside-the-interval")) } - _ => true, + _ => None, } }) } diff --git a/crates/perry/src/commands/compile/compile_env.rs b/crates/perry/src/commands/compile/compile_env.rs index 5035dd2613..31244e72eb 100644 --- a/crates/perry/src/commands/compile/compile_env.rs +++ b/crates/perry/src/commands/compile/compile_env.rs @@ -72,6 +72,7 @@ pub(super) const COMPILE_ENV_VARS: &[&str] = &[ "PERRY_REST_FIELDS", "PERRY_REST_TIERS", "PERRY_REST_CAPACITY", + "PERRY_SIG_SIGNED_INDEX", "PERRY_FACTS_ZERO_SIGN", "PERRY_FACTS_FILTER_DEMAND", "PERRY_WP_REP_TEMPS", From 46b68289da8c95ee4543b76fdafa0f56f56df9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 10:25:23 +0100 Subject: [PATCH 02/19] typed signed index parameters: parity fixture, S0 arm, unit tests, changelog --- .../codegen-typed-signed-index-params.md | 1 + .../src/codegen/signature_entry.rs | 42 +++- crates/perry-wpfacts/src/tests/signatures.rs | 94 +++++++++ .../src/commands/compile/compile_env/tests.rs | 11 + scripts/test-verify-facts-s0.py | 1 + .../verify_facts_s0/signed-index-negative.ts | 27 +++ .../function/test_p2_signed_index_params.ts | 199 ++++++++++++++++++ 7 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 changelog.d/codegen-typed-signed-index-params.md create mode 100644 scripts/verify_facts_s0/signed-index-negative.ts create mode 100644 tests/parity/language/function/test_p2_signed_index_params.ts diff --git a/changelog.d/codegen-typed-signed-index-params.md b/changelog.d/codegen-typed-signed-index-params.md new file mode 100644 index 0000000000..fc849c72a8 --- /dev/null +++ b/changelog.d/codegen-typed-signed-index-params.md @@ -0,0 +1 @@ +Performance: a method whose parameters feed element keys gets its typed body (`M$sig`) when whole-program facts prove those parameters int32, whatever their sign. Before, the non-negative index clones (`$idx_u31`) of such a method refused the typed signature unless every index parameter's interval started at 0, so a helper like `set(square, piece)` with a possibly negative key or value kept its double-ABI generic body and its guarded clones. Since the unsigned bound an int32 key is bounded by one unsigned compare, which declines a negative key onto the property path. `PERRY_SIG_SIGNED_INDEX=0` restores the old rule. New probes: `# typed_call` lines in `PERRY_FACTS_DUMP` (each call that resolves to a body with a signature, typed or refused with the reason) and `PERRY_SIG_DIAG=1` (the selector's verdict per method). diff --git a/crates/perry-codegen/src/codegen/signature_entry.rs b/crates/perry-codegen/src/codegen/signature_entry.rs index c811548dde..9a0182e5e8 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -314,6 +314,13 @@ pub(crate) fn signed_index_params() -> bool { }) } +/// A signature slot (`rep`, with its interval `range`) is at least as +/// precise as an index clone's parameter: an I32 whose interval is +/// non-negative, or, with `signed` (`signed_index_params`), any I32. +fn index_param_covered(rep: FactsRep, range: Option<(i64, i64)>, signed: bool) -> bool { + rep == FactsRep::I32 && (signed || range.is_some_and(|(lo, _)| lo >= 0)) +} + /// The probe `PERRY_SIG_DIAG=1`: one line per method a `typed_calls` site /// reaches and the facts give a signature, with the selector's verdict /// (`typed=1`, or the tier or shape that refuses it). @@ -451,9 +458,7 @@ pub(crate) fn select_methods( .iter() .zip(sig.params.iter().zip(sig.ranges.iter())) .all(|(p, (rep, range))| { - !ids.contains(&p.id) - || *rep == FactsRep::I32 - && (signed_index || range.is_some_and(|(lo, _)| lo >= 0)) + !ids.contains(&p.id) || index_param_covered(*rep, *range, signed_index) }) }); let less_precise = if cm.typed_i32_methods.contains(&key) && !all(FactsRep::I32) @@ -1059,3 +1064,34 @@ pub(crate) fn nonnegative_params(f: &Function, plan: &SpecFnPlan) -> HashSet Vec { + let pf = facts_with( + src, + crate::FactsConfig { + diagnostics: true, + ..Default::default() + }, + ); + pf.rep_probe + .iter() + .filter(|l| l.starts_with("# typed_call ")) + .cloned() + .collect() +} + +/// A board helper whose key and value are int32s of either sign: both +/// parameters are I32 with intervals that start below 0, and every call +/// site is typed. (Whether codegen keeps the method's index clones over +/// this signature is its selector's `signed_index_params` rule.) +const SIGNED_SET: &str = r#" +class Board { + cells: number[] = new Array(64).fill(0); + keys: number[] = []; + h = 0; + set(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h ^= this.keys[(old + 6) * 64 + square] ?? 0; + this.cells[square] = piece; + if (piece !== 0) this.h ^= this.keys[(piece + 6) * 64 + square] ?? 0; + } +} +const b = new Board(); +for (let i = 0; i < 64; i++) b.set(i, (i % 13) - 6); +b.set(-8, -3); +console.log(b.h); +"#; + +#[test] +fn a_signed_key_and_value_are_i32_parameters_with_their_intervals() { + let s = sig(SIGNED_SET, "set").expect("a signature"); + assert_eq!(s.params, vec![I32, I32], "{s:?}"); + assert!(s.ranges[0].is_some_and(|(lo, hi)| lo <= -8 && hi >= 63), "{s:?}"); + assert!(s.ranges[1].is_some_and(|(lo, hi)| lo <= -6 && hi >= 6), "{s:?}"); + let id = id_of(SIGNED_SET, "set"); + assert_eq!(typed_targets(SIGNED_SET), vec![id, id]); +} + +#[test] +fn the_probe_names_each_typed_call_and_each_refusal() { + let lines = typed_call_lines(SIGNED_SET); + let typed = lines + .iter() + .filter(|l| l.contains(" callee=set typed=1")) + .count(); + assert_eq!(typed, 2, "{lines:#?}"); + // A fraction makes `piece` a double: the calls are typed still (F64 + // slot). A string argument is no Number: that call is refused, by + // argument and reason. + let src = format!("{SIGNED_SET}\nfunction pick(i: number): number {{ return (i * 2) | 0; }}\nlet t = 0;\nfor (let i = 0; i < 9; i++) t += pick(i);\nconsole.log(t);\n"); + let lines = typed_call_lines(&src); + assert!( + lines.iter().any(|l| l.contains(" callee=pick typed=1")), + "{lines:#?}" + ); + // An override: two bodies, no typed call, the probe names both. + let src = r#" +class A { f(x: number): number { return x | 0; } } +class B extends A { f(x: number): number { return (x * 2) | 0; } } +const xs: A[] = [new A(), new B()]; +let s = 0; +for (let i = 0; i < 4; i++) s += xs[i & 1].f(i); +console.log(s); +"#; + let lines = typed_call_lines(src); + assert!( + lines + .iter() + .any(|l| l.contains(" callee=f typed=0 why=method-not-one-target")), + "{lines:#?}" + ); + assert!(!lines.iter().any(|l| l.contains("typed=1")), "{lines:#?}"); +} + +#[test] +fn without_diagnostics_there_is_no_probe_line() { + let pf = facts(SIGNED_SET); + assert!( + !pf.rep_probe.iter().any(|l| l.starts_with("# typed_call ")), + "{:#?}", + pf.rep_probe + ); +} diff --git a/crates/perry/src/commands/compile/compile_env/tests.rs b/crates/perry/src/commands/compile/compile_env/tests.rs index e4928fb0b1..75ec461dcb 100644 --- a/crates/perry/src/commands/compile/compile_env/tests.rs +++ b/crates/perry/src/commands/compile/compile_env/tests.rs @@ -374,6 +374,17 @@ fn unsigned_bound_switch_is_a_cache_input() { ); } +/// Typed `set`: the signed-index switch decides which methods get a typed +/// body in place of their index clones, so `=0` must never reuse an `=1` +/// object. +#[test] +fn signed_index_switch_is_a_cache_input() { + assert!( + COMPILE_ENV_VARS.contains(&"PERRY_SIG_SIGNED_INDEX"), + "PERRY_SIG_SIGNED_INDEX must key the compile caches" + ); +} + /// N4: the cold-path switch changes helper declarations, call-site /// attributes and branch weights, so `=0` must never reuse an `=1` object. #[test] diff --git a/scripts/test-verify-facts-s0.py b/scripts/test-verify-facts-s0.py index 8df18294f5..9273ed54d7 100644 --- a/scripts/test-verify-facts-s0.py +++ b/scripts/test-verify-facts-s0.py @@ -134,6 +134,7 @@ def oracle(source, *argv): ("signature-negative", "signature", "signature"), ("signature-escape-negative", "signature-escape", "signature"), ("signature-range-negative", "signature-range", "signature"), + ("signed-index-negative", "signature-range", "signature"), ("rest-field-negative", "restfield", "field rep"), ("rest-field-zero-negative", "restfield", "field rep"), ("rest-elem-negative", "restelem", "array rep"), diff --git a/scripts/verify_facts_s0/signed-index-negative.ts b/scripts/verify_facts_s0/signed-index-negative.ts new file mode 100644 index 0000000000..5d4288ef04 --- /dev/null +++ b/scripts/verify_facts_s0/signed-index-negative.ts @@ -0,0 +1,27 @@ +// P2, an index clone's parameter covered by a signed I32 signature slot: +// `put`'s parameters feed element keys, and its callers pass negative values +// for both, so their I32 intervals start below 0. The method is typed all +// the same (an int32 key is bounded by one unsigned compare). Under +// PERRY_FACTS_SABOTAGE=signature-range the facts claim [0, 127] for both, +// which would let the typed body drop that sign: the signature's interval +// witness must report the negative argument. +// A loop bound the compiler cannot fold keeps each call site one HIR call. +const RUN = Math.random() < 2 ? 1 : 0; +class Grid { + cells: number[] = new Array(16).fill(0); + keys: number[] = []; + h = 0; + constructor() { + for (let i = 0; i < 16 * 16; i++) this.keys.push((i * 2654435761) | 0); + } + put(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h ^= this.keys[(old + 6) * 16 + square] ?? 0; + this.cells[square] = piece; + if (piece !== 0) this.h ^= this.keys[(piece + 6) * 16 + square] ?? 0; + } +} +const g = new Grid(); +for (let i = 0; i < 16 * RUN; i++) g.put(i, (i % 9) - 4); +g.put(-2, -3); +console.log(g.h, g.cells.join(","), (g.cells as any)[-2]); diff --git a/tests/parity/language/function/test_p2_signed_index_params.ts b/tests/parity/language/function/test_p2_signed_index_params.ts new file mode 100644 index 0000000000..e974772736 --- /dev/null +++ b/tests/parity/language/function/test_p2_signed_index_params.ts @@ -0,0 +1,199 @@ +// P2 typed signatures: a method whose parameters feed element keys is typed +// when the facts give them I32, whatever their sign (an int32 key is bounded +// by one unsigned compare). Exact against node with the rule on, off +// (PERRY_SIG_SIGNED_INDEX=0) and in check mode. +// A loop bound the compiler cannot fold keeps each loop as written. +const RUN = Math.random() < 2 ? 1 : 0; + +// The shape: a board helper that reads the old element, stores the new one +// and updates two hashes through keys built from both parameters. +class Board { + cells: number[] = new Array(64).fill(0); + za: number[] = []; + zb: number[] = []; + ha = 0; + hb = 0; + constructor() { + let seed = 12345; + for (let i = 0; i < 16 * 64; i++) { + seed = (seed * 1103515245 + 12345) | 0; + this.za.push(seed); + seed = (seed * 1103515245 + 12345) | 0; + this.zb.push(seed); + } + } + at(square: number): number { + return this.cells[square] ?? 0; + } + set(square: number, piece: number): void { + const old = this.at(square); + if (old !== 0) { + this.ha ^= this.za[(old + 6) * 64 + square] ?? 0; + this.hb ^= this.zb[(old + 6) * 64 + square] ?? 0; + } + this.cells[square] = piece; + if (piece !== 0) { + this.ha ^= this.za[(piece + 6) * 64 + square] ?? 0; + this.hb ^= this.zb[(piece + 6) * 64 + square] ?? 0; + } + } +} + +// 1. In-range keys and pieces of both signs: the typed body, every site. +const b1 = new Board(); +for (let i = 0; i < 64 * RUN; i++) b1.set(i, (i % 13) - 6); +for (let i = 0; i < 64 * RUN; i += 3) b1.set(i, 0); +for (let i = 0; i < 64 * RUN; i += 5) b1.set(i, i & 1 ? -3 : 4); +let sum1 = 0; +for (let i = 0; i < 64 * RUN; i++) sum1 += b1.at(i) * (i + 1); +console.log("in-range", sum1, b1.ha, b1.hb, b1.cells.length); + +// 2. A negative key beside in-range ones: it names a property, no element, +// and the old value read back through `at` is that property's. +const b2 = new Board(); +for (let i = 0; i < 32 * RUN; i++) b2.set(i - 8, (i % 5) - 2); +console.log( + "negative key", + b2.cells.length, + b2.cells[0], + b2.cells[23], + (b2.cells as any)[-8], + (b2.cells as any)[-1], + Object.keys(b2.cells).length, + b2.ha, + b2.hb, +); +b2.set(-8, 5); +b2.set(-8, -5); +console.log("negative key again", (b2.cells as any)[-8], b2.at(-8), b2.ha, b2.hb); + +// 3. A key past the length (inside the reserved hull, then far past it). +const b3 = new Board(); +for (let i = 0; i < 70 * RUN; i++) b3.set(i, 1 + (i & 3)); +console.log("past the length", b3.cells.length, b3.cells[69], b3.at(69), b3.at(70), b3.ha, b3.hb); +const b3b = new Board(); +for (let i = 0; i < 4 * RUN; i++) b3b.set(i * 100, 2); +console.log("far past", b3b.cells.length, b3b.cells[300], b3b.cells[299], b3b.at(301), b3b.ha, b3b.hb); + +// 4. Negative controls on the value: a fraction, -0, NaN, undefined. The +// parameter is no I32, so the method keeps its index clones (or a double +// slot), and the output is still exact. +class Loose { + cells: number[] = new Array(16).fill(0); + h = 0; + set(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h = (this.h + old * 3 + square) | 0; + this.cells[square] = piece; + if (piece !== 0) this.h = (this.h ^ (((piece + 6) * 16 + square) | 0)) | 0; + } +} +const l1 = new Loose(); +for (let i = 0; i < 16 * RUN; i++) l1.set(i, i - 4); +l1.set(3, 1.5); +l1.set(4, -0); +l1.set(5, NaN); +l1.set(6, undefined as any); +console.log( + "loose values", + l1.cells[3], + Object.is(l1.cells[4], -0), + Number.isNaN(l1.cells[5]), + l1.cells[6], + l1.h, + l1.cells.join(","), +); + +// 5. Negative controls on the key: a fraction, a numeric string, NaN, +// undefined, -0. Each names a property (or element 0 for -0). +class LooseKey { + cells: number[] = new Array(16).fill(0); + n = 0; + put(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.n++; + this.cells[square] = piece; + } +} +const k1 = new LooseKey(); +for (let i = 0; i < 16 * RUN; i++) k1.put(i, i + 1); +k1.put(1.5, 7); +k1.put("3" as any, 8); +k1.put(NaN, 9); +k1.put(undefined as any, 10); +k1.put(-0, 11); +k1.put(-1, 12); +console.log( + "loose keys", + k1.cells.length, + k1.cells[0], + k1.cells[3], + (k1.cells as any)[1.5], + (k1.cells as any)["NaN"], + (k1.cells as any)["undefined"], + (k1.cells as any)[-1], + k1.n, + Object.keys(k1.cells).length, +); + +// 6. The method retained and called indirectly: extracted, `call`, `apply`, +// `bind`, and through an array of functions. These enter the boxed entry +// (or keep the method untyped); in-range direct sites stand beside them. +class Kept { + cells: number[] = new Array(32).fill(0); + h = 0; + set(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h = (this.h - old) | 0; + this.cells[square] = piece; + if (piece !== 0) this.h = (this.h + (piece + 6) * 32 + square) | 0; + } +} +const kept = new Kept(); +for (let i = 0; i < 32 * RUN; i++) kept.set(i, (i % 7) - 3); +const loose = kept.set; +loose.call(kept, 2, -6); +loose.apply(kept, [3, 6]); +const bound = kept.set.bind(kept); +bound(4, -1); +const fns = [bound, (s: number, p: number) => kept.set(s, p)]; +for (let i = 0; i < 8 * RUN; i++) fns[i & 1](5 + i, i - 4); +loose.call(kept, -2, 9); +loose.call(kept, 2.5, 9); +loose.call(kept, 6, 0.25); +const other = { cells: [1, 2, 3], h: 100 }; +loose.call(other as any, 1, 5); +console.log( + "kept", + kept.h, + kept.cells.join(","), + (kept.cells as any)[-2], + (kept.cells as any)[2.5], + other.cells.join(","), + other.h, +); + +// 7. A subclass that overrides the helper: the site has two bodies. +class Sub extends Kept { + set(square: number, piece: number): void { + this.cells[square] = piece * 2; + } +} +const mixed: Kept[] = [new Kept(), new Sub()]; +for (let i = 0; i < 16 * RUN; i++) mixed[i & 1].set(i, i - 8); +console.log("override", mixed[0].cells.join(","), mixed[1].cells.join(","), mixed[0].h, mixed[1].h); + +// 8. A parameter the body writes, and one a closure captures. +class Written { + cells: number[] = new Array(16).fill(0); + set(square: number, piece: number): number { + if (square < 0) square = -square; + const read = () => piece; + this.cells[square] = read(); + return this.cells[square] ?? -1; + } +} +const w = new Written(); +let ws = 0; +for (let i = 0; i < 24 * RUN; i++) ws += w.set(i - 8, i); +console.log("written", ws, w.cells.join(",")); From f7af377221dd230ad636c2707df6f31f5f480e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 10:41:03 +0100 Subject: [PATCH 03/19] WIP: codegen range proofs read the facts interval of proven int32 lets and typed I32 parameters --- crates/perry-codegen/src/codegen/function.rs | 2 ++ crates/perry-codegen/src/codegen/method.rs | 5 ++- .../src/codegen/program_facts_view.rs | 5 +++ .../src/codegen/signature_entry.rs | 32 +++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 1 + crates/perry-codegen/src/expr/range_facts.rs | 35 +++++++++++++++++++ .../perry/src/commands/compile/compile_env.rs | 1 + .../src/commands/compile/program_facts.rs | 3 ++ 8 files changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 8b6b6a2d0b..3cbf208bfe 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1710,6 +1710,8 @@ pub(super) fn compile_function( ctx.sig_return = sig_return; ctx.nonnegative_integer_locals .extend(super::signature_entry::nonnegative_params(f, plan)); + ctx.int_range_facts + .extend(super::signature_entry::param_range_facts(f, plan)); } let wrapper_name = format!("__perry_wrap_{}", public_llvm_name); super::arguments::materialize_arguments_object( diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 2d9a3ecda9..4f69597fab 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -997,7 +997,10 @@ pub(super) fn compile_method( buffer_hazard_reasons: HashMap::new(), native_i32_aliases: HashMap::new(), int_range_aliases: HashMap::new(), - int_range_facts: Vec::new(), + int_range_facts: match sig { + Some(tm) => super::signature_entry::param_range_facts(method, &tm.plan), + None => Vec::new(), + }, next_loop_proof_scope_id: 0, nonnegative_integer_locals: nonnegative_param_ids, elided_arguments: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/program_facts_view.rs b/crates/perry-codegen/src/codegen/program_facts_view.rs index fd4b5ef8eb..2112555279 100644 --- a/crates/perry-codegen/src/codegen/program_facts_view.rs +++ b/crates/perry-codegen/src/codegen/program_facts_view.rs @@ -335,6 +335,11 @@ pub struct ElisionFacts { pub local_reps: std::collections::BTreeMap, /// The `int32_inputs` whose interval is non-negative. pub nonneg_int32_inputs: std::collections::BTreeSet, + /// Each of the `int32_inputs` with its interval (wpfacts + /// `BindingFacts::int_range`): every value the binding ever holds lies + /// in it. Codegen's range proofs read it for a proven int32 `let` + /// (`expr::range_facts::facts_local_range`). + pub int32_ranges: std::collections::BTreeMap, /// Local id → it holds only strings and ordinary Arrays: `x.length` is a /// Number. pub length_number_inputs: std::collections::BTreeSet, diff --git a/crates/perry-codegen/src/codegen/signature_entry.rs b/crates/perry-codegen/src/codegen/signature_entry.rs index 9a0182e5e8..3a725f1272 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -1047,6 +1047,38 @@ pub(crate) fn slot_i32_params(f: &Function, plan: &SpecFnPlan) -> HashSet { .collect() } +/// `F$sig`'s parameters with the interval the signature proves (T1): an I32 +/// parameter in its i32 slot (never written: plan selection demoted written +/// parameters) holds an integer of its interval for the whole body, so the +/// body's range proofs start from it (`FnCtx::int_range_facts`, under a +/// scope no loop ends). The interval is witnessed at every typed site and at +/// the boxed entry (`witness_value`). Empty with +/// `PERRY_FACTS_LOCAL_RANGES=0`. +pub(crate) fn param_range_facts( + f: &Function, + plan: &SpecFnPlan, +) -> Vec { + let Some(sig) = plan.signature.as_ref() else { + return Vec::new(); + }; + if !crate::expr::facts_local_ranges() { + return Vec::new(); + } + f.params + .iter() + .zip(plan.reps.iter()) + .zip(sig.ranges.iter()) + .filter_map(|((p, rep), range)| { + let (min, max) = (*range)?; + matches!(rep, SpecParamRep::I32).then_some(crate::expr::IntRangeFact { + local_id: p.id, + scope_id: u32::MAX, + range: crate::expr::IntRange { min, max }, + }) + }) + .collect() +} + /// `F$sig`'s parameters that seed `nonnegative_integer_locals` (T1): an I32 /// parameter in its i32 slot whose interval has `lo >= 0` (the slot is /// never written: plan selection demoted written parameters). diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index ca3a36ebbb..50364ba8fc 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -220,6 +220,7 @@ pub(crate) use range_facts::{ record_int_facts_for_update, record_local_value_alias_for_write, while_condition_range_fact, IntRange, IntRangeFact, }; +pub(crate) use range_facts::{facts_local_range, facts_local_ranges}; pub(crate) use strings::emit_string_literal_global; pub(crate) use typed_feedback::{ emit_typed_feedback_record_call, emit_typed_feedback_register_site, native_region_slug, diff --git a/crates/perry-codegen/src/expr/range_facts.rs b/crates/perry-codegen/src/expr/range_facts.rs index 6496f40d13..f5e17de6e5 100644 --- a/crates/perry-codegen/src/expr/range_facts.rs +++ b/crates/perry-codegen/src/expr/range_facts.rs @@ -323,10 +323,45 @@ fn int_range_for_local( // parameter. Without it one unbounded parameter leaf poisons the whole // index expression and demotes EVERY array access in the function. let result = result.or_else(|| ctx.param_int_ranges.get(&id).copied()); + // The whole-program interval of a proven int32 `let`: the weakest proof, + // read only when nothing local bounds the local. + let result = result.or_else(|| facts_local_range(ctx, id)); seen.remove(&id); result } +/// `PERRY_FACTS_LOCAL_RANGES=0` (`off`, `false`): codegen's range proofs do +/// not read the whole-program interval of a proven int32 `let` or of a typed +/// body's I32 parameter (main before this rule). +pub(crate) fn facts_local_ranges() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + !matches!( + std::env::var("PERRY_FACTS_LOCAL_RANGES").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// The whole-program interval (wpfacts `BindingFacts::int_range`) of local +/// `id` when this body holds it as a proven int32 `let` +/// (`FnCtx::facts_int32_locals`): every value the local ever holds is an +/// integer in it, at every read, so it is a range proof that needs no +/// dominating guard. Check mode witnesses the interval at every store into +/// such a local (`codegen::facts_int32_locals::witness_exact`). The negative +/// control `PERRY_FACTS_SABOTAGE=localrange` claims the interval's lower +/// bound alone. +pub(crate) fn facts_local_range(ctx: &FnCtx<'_>, id: u32) -> Option { + if !facts_local_ranges() || ctx.wpfacts_off || !ctx.facts_int32_locals.contains(&id) { + return None; + } + let &(min, max) = ctx.wpfacts.int32_ranges.get(&id)?; + if std::env::var("PERRY_FACTS_SABOTAGE").as_deref() == Ok("localrange") { + return Some(IntRange { min, max: min }); + } + Some(IntRange { min, max }) +} + fn int_range_expr_inner( ctx: &FnCtx<'_>, expr: &Expr, diff --git a/crates/perry/src/commands/compile/compile_env.rs b/crates/perry/src/commands/compile/compile_env.rs index 31244e72eb..90eb7aec84 100644 --- a/crates/perry/src/commands/compile/compile_env.rs +++ b/crates/perry/src/commands/compile/compile_env.rs @@ -67,6 +67,7 @@ pub(super) const COMPILE_ENV_VARS: &[&str] = &[ "PERRY_FACTS_INT_RANGES", "PERRY_FACTS_INT_DEMAND", "PERRY_FACTS_COMPARE_DEMAND", + "PERRY_FACTS_LOCAL_RANGES", "PERRY_FACTS_AT_REST", "PERRY_REST_ELEMS", "PERRY_REST_FIELDS", diff --git a/crates/perry/src/commands/compile/program_facts.rs b/crates/perry/src/commands/compile/program_facts.rs index 2fb428e1a5..b2fba4bdae 100644 --- a/crates/perry/src/commands/compile/program_facts.rs +++ b/crates/perry/src/commands/compile/program_facts.rs @@ -662,6 +662,9 @@ fn elision_for(pf: &perry_wpfacts::ProgramFacts, path: &str) -> perry_codegen::E .is_some_and(|(lo, hi)| lo >= i64::from(i32::MIN) && hi <= i64::from(i32::MAX)) { out.int32_inputs.insert(*id); + if let Some(range) = b.int_range { + out.int32_ranges.insert(*id, range); + } if b.zero_tol { out.zero_tolerant_locals.insert(*id); } From 89d88a9a9e6a99f4c430fe1b36700af9df406d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 11:02:12 +0100 Subject: [PATCH 04/19] WIP: interval witness, S0 arm localrange, fixtures and IR tests for the local ranges --- .../src/codegen/facts_int32_locals.rs | 22 +++- .../src/codegen/facts_local_ranges_tests.rs | 104 +++++++++++++++ .../src/commands/compile/compile_env/tests.rs | 11 ++ scripts/test-verify-facts-s0.py | 1 + .../verify_facts_s0/local-range-negative.ts | 16 +++ .../number/test_facts_local_ranges.ts | 120 ++++++++++++++++++ 6 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs create mode 100644 scripts/verify_facts_s0/local-range-negative.ts create mode 100644 tests/parity/language/number/test_facts_local_ranges.ts diff --git a/crates/perry-codegen/src/codegen/facts_int32_locals.rs b/crates/perry-codegen/src/codegen/facts_int32_locals.rs index 63eb95c03d..706ed4fc1b 100644 --- a/crates/perry-codegen/src/codegen/facts_int32_locals.rs +++ b/crates/perry-codegen/src/codegen/facts_int32_locals.rs @@ -8,8 +8,14 @@ //! (`repsel_benefit`). A double entering that slot is an exact int32, so it //! converts with `fptosi` instead of ToInt32 (`store_from_double`). //! +//! The proof's interval is a range proof of the local at every read +//! (`expr::range_facts::facts_local_range`, `PERRY_FACTS_LOCAL_RANGES=0` +//! turns that off): an index or an integer chain built from it needs no +//! dominating guard. +//! //! Check mode (`PERRY_VERIFY_FACTS=1`) witnesses every store: the stored value -//! must pass the exact-i32 test (`emit_typed_i32_plain_guard_and_raw`). Stores the +//! must pass the exact-i32 test (`emit_typed_i32_plain_guard_and_raw`) and lie +//! in the interval the range proofs read. Stores the //! ordinary lowering would compute natively in i32 take the double path //! there, so no store escapes the witness (`checked`). //! @@ -96,6 +102,16 @@ pub(crate) fn witness_exact(ctx: &mut crate::expr::FnCtx<'_>, value: &str, id: u let nz = ctx.block().icmp_eq(I64, &bits, "-9223372036854775808"); exact = ctx.block().or(I1, &exact, &nz); } + // The interval the body's range proofs read for this local + // (`range_facts::facts_local_range`): the stored value lies in it. A + // tolerated `-0` compares as 0. + if let Some(range) = crate::expr::facts_local_range(ctx, id) { + let blk = ctx.block(); + let ge = blk.fcmp("oge", value, &crate::nanbox::double_literal(range.min as f64)); + let le = blk.fcmp("ole", value, &crate::nanbox::double_literal(range.max as f64)); + let inside = blk.and(I1, &ge, &le); + exact = blk.and(I1, &exact, &inside); + } crate::verify_facts::tick(ctx); let miss = ctx.new_block("int32.input.verify_miss"); let next = ctx.new_block("int32.input.admitted"); @@ -141,3 +157,7 @@ pub(crate) fn store_from_double( #[cfg(test)] #[path = "facts_int32_locals_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "facts_local_ranges_tests.rs"] +mod range_tests; diff --git a/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs new file mode 100644 index 0000000000..10e339afd5 --- /dev/null +++ b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs @@ -0,0 +1,104 @@ +//! The whole-program interval of a proven int32 `let` is a range proof +//! (`expr::range_facts::facts_local_range`): a key built from the local is +//! an int32 by the interval, so it is computed in i32 and handed to the +//! element read as it is. Without the interval the key is a double chain +//! and goes through the index normalizer. + +use std::sync::Arc; + +use crate::codegen::{ElisionFacts, FactsRep, ProgramFactsView}; +use crate::{compile_module, CompileOptions}; +use perry_hir::Module; + +const SRC: &str = "export function f(t: number[], i: number): number {\n\ + const k = (i & 15) - 3;\n\ + return t[k * 2 + 1] ?? -1;\n\ + }\n"; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = perry_diagnostics::SourceCache::new(); + let parsed = perry_parser::parse_typescript_with_cache(&src, "lr.ts", &mut cache) + .expect("parses"); + perry_hir::lower_module(&parsed.module, "lr", "lr.ts").expect("lowers") + }) + .expect("spawn") + .join() + .expect("lowering thread") +} + +/// The HIR id of the `let` named `name`. +fn let_id(m: &Module, name: &str) -> u32 { + let text = format!("{m:?}"); + let mut rest = text.as_str(); + while let Some(i) = rest.find("Let { id: ") { + rest = &rest[i + 10..]; + let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + let j = rest.find("name: \"").expect("named let"); + if rest[j + 7..].starts_with(&format!("{name}\"")) { + return num.parse().expect("id"); + } + } + panic!("no let {name}"); +} + +/// The IR of `f` with `k` proven int32, and its interval `range` when given. +fn ir_of_f(range: Option<(i64, i64)>) -> String { + let m = lower(SRC); + let mut elision = ElisionFacts::default(); + let k = let_id(&m, "k"); + elision.int32_inputs.insert(k); + elision.local_reps.insert(k, FactsRep::I32); + if let Some(range) = range { + elision.int32_ranges.insert(k, range); + } + let opts = CompileOptions { + emit_ir_only: true, + program_facts: ProgramFactsView { + elision: Arc::new(elision), + ..Default::default() + }, + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&m, opts).expect("compiles")).expect("UTF-8"); + let start = ir + .lines() + .position(|l| l.starts_with("define") && l.contains("perry_fn_lr__f")) + .expect("f defined"); + ir.lines() + .skip(start) + .take_while(|l| *l != "}") + .collect::>() + .join("\n") +} + +fn normalizer_calls(ir: &str) -> usize { + ir.lines() + .filter(|l| l.contains("call") && l.contains("perry_array_index_normalize_i32")) + .count() +} + +#[test] +fn a_key_built_from_a_ranged_int32_let_is_an_i32_key() { + // `k` in [-3, 12]: `k * 2 + 1` is in [-5, 25], an int32 of either sign. + let f = ir_of_f(Some((-3, 12))); + assert_eq!(normalizer_calls(&f), 0, "{f}"); + assert!(!f.contains("fmul double"), "{f}"); +} + +#[test] +fn without_the_interval_the_key_is_normalized() { + let f = ir_of_f(None); + assert_eq!(normalizer_calls(&f), 1, "{f}"); +} + +#[test] +fn an_interval_whose_chain_leaves_int32_is_no_i32_key() { + // `k` up to 2^31 - 1: `k * 2 + 1` may pass int32, so the key keeps its + // double (a wrapped i32 would name another element). + let f = ir_of_f(Some((0, i64::from(i32::MAX)))); + assert_eq!(normalizer_calls(&f), 1, "{f}"); +} diff --git a/crates/perry/src/commands/compile/compile_env/tests.rs b/crates/perry/src/commands/compile/compile_env/tests.rs index 75ec461dcb..bff042fdce 100644 --- a/crates/perry/src/commands/compile/compile_env/tests.rs +++ b/crates/perry/src/commands/compile/compile_env/tests.rs @@ -385,6 +385,17 @@ fn signed_index_switch_is_a_cache_input() { ); } +/// The local-range switch decides whether codegen's range proofs read the +/// facts' interval of an int32 local, so `=0` must never reuse an `=1` +/// object. +#[test] +fn local_ranges_switch_is_a_cache_input() { + assert!( + COMPILE_ENV_VARS.contains(&"PERRY_FACTS_LOCAL_RANGES"), + "PERRY_FACTS_LOCAL_RANGES must key the compile caches" + ); +} + /// N4: the cold-path switch changes helper declarations, call-site /// attributes and branch weights, so `=0` must never reuse an `=1` object. #[test] diff --git a/scripts/test-verify-facts-s0.py b/scripts/test-verify-facts-s0.py index 9273ed54d7..8981380682 100644 --- a/scripts/test-verify-facts-s0.py +++ b/scripts/test-verify-facts-s0.py @@ -135,6 +135,7 @@ def oracle(source, *argv): ("signature-escape-negative", "signature-escape", "signature"), ("signature-range-negative", "signature-range", "signature"), ("signed-index-negative", "signature-range", "signature"), + ("local-range-negative", "localrange", "int32 input"), ("rest-field-negative", "restfield", "field rep"), ("rest-field-zero-negative", "restfield", "field rep"), ("rest-elem-negative", "restelem", "array rep"), diff --git a/scripts/verify_facts_s0/local-range-negative.ts b/scripts/verify_facts_s0/local-range-negative.ts new file mode 100644 index 0000000000..f640ff2f01 --- /dev/null +++ b/scripts/verify_facts_s0/local-range-negative.ts @@ -0,0 +1,16 @@ +// The facts' interval of a proven int32 `let` is a range proof in codegen +// (`range_facts::facts_local_range`): `k` is in [3, 18], so `k * 2 + 1` is an +// int32 element key with no guard. Under PERRY_FACTS_SABOTAGE=localrange the +// interval claimed is its lower bound alone ([3, 3]): the store witness of +// the local must report the first other value. +// A loop bound the compiler cannot fold keeps the loop as written. +const RUN = Math.random() < 2 ? 1 : 0; +const table: number[] = []; +for (let i = 0; i < 64; i++) table.push(i * 7); +function pick(i: number): number { + const k = (i & 15) + 3; + return table[k * 2 + 1] ?? -1; +} +let s = 0; +for (let i = 0; i < 40 * RUN; i++) s += pick(i); +console.log(s); diff --git a/tests/parity/language/number/test_facts_local_ranges.ts b/tests/parity/language/number/test_facts_local_ranges.ts new file mode 100644 index 0000000000..ac3a18e027 --- /dev/null +++ b/tests/parity/language/number/test_facts_local_ranges.ts @@ -0,0 +1,120 @@ +// The whole-program interval of a proven int32 local is a range proof in +// codegen (PERRY_FACTS_LOCAL_RANGES): a key or an integer chain built from +// such `let`s and from a typed body's I32 parameters is computed in i32. +// Exact against node with the rule on, off and in check mode. +// A loop bound the compiler cannot fold keeps each loop as written. +const RUN = Math.random() < 2 ? 1 : 0; + +const table: number[] = []; +for (let i = 0; i < 4096; i++) table.push((i * 2654435761) | 0); + +// 1. A `let` read from an int Array and a parameter, both of either sign, +// feed a key: `(old + 6) * 64 + square`. +class Grid { + cells: number[] = new Array(64).fill(0); + h = 0; + put(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h ^= table[(old + 6) * 64 + square] ?? 0; + this.cells[square] = piece; + if (piece !== 0) this.h ^= table[(piece + 6) * 64 + square] ?? 0; + } +} +const g = new Grid(); +for (let i = 0; i < 64 * RUN; i++) g.put(i, (i % 13) - 6); +for (let i = 0; i < 64 * RUN; i += 3) g.put(i, 0); +for (let i = 0; i < 64 * RUN; i += 7) g.put(i, i & 1 ? -6 : 6); +console.log("grid", g.h, g.cells.join(",")); + +// 2. A key that can be negative: it names a property, and the chain's +// negative value must not wrap into an element. +const sparse: number[] = [10, 20, 30, 40]; +function probe(i: number): number { + const k = (i & 7) - 4; + return sparse[k * 2 + 1] ?? -1; +} +let s2 = 0; +for (let i = 0; i < 16 * RUN; i++) s2 += probe(i); +(sparse as any)[-3] = 7; +for (let i = 0; i < 16 * RUN; i++) s2 += probe(i); +console.log("negative key", s2); + +// 3. A product past int32: the interval is no int32, so the key stays a +// double and names the property "4294967296" (never element 0). +const wide: number[] = [1, 2, 3]; +function far(i: number): number { + const a = (i & 1) * 65536; + const b = a * 65536; + (wide as any)[b] = i + 100; + return ((wide as any)[b] as number) + wide[0]; +} +let s3 = 0; +for (let i = 0; i < 4 * RUN; i++) s3 += far(i); +console.log("past int32", s3, wide.length, wide[0], (wide as any)[4294967296]); + +// 4. Lets that are no int32: a fraction, -0, NaN, undefined. No interval. +function loose(i: number): string { + const half = i / 2; + const negz = i === 0 ? -0 : i; + const nan = i === 1 ? NaN : i; + const und = i === 2 ? undefined : i; + const o: any = { 0: "zero", 1: "one", 2: "two", 0.5: "half", NaN: "nan", undefined: "undef" }; + return [o[half], o[negz], o[nan], o[und as any], Object.is(negz, -0)].join("/"); +} +const out4: string[] = []; +for (let i = 0; i < 4 * RUN; i++) out4.push(loose(i)); +console.log("loose", out4.join(" ")); + +// 5. A let a closure captures and writes (boxed), and a let written past +// its first interval: the proof covers every value or it is not used. +function boxed(n: number): number { + let k = n & 3; + const bump = () => { + k = k + 100; + }; + let r = table[k + 1] ?? -1; + bump(); + r = (r + (table[k + 1] ?? -1)) | 0; + return r; +} +function widened(n: number): number { + let k = n & 3; + let r = table[k] ?? -1; + if (n > 5) k = k - 9; + r = (r + (table[k] ?? -5)) | 0; + return r; +} +let s5 = 0; +for (let i = 0; i < 12 * RUN; i++) s5 = (s5 + boxed(i) + widened(i)) | 0; +console.log("boxed and widened", s5); + +// 6. A typed parameter with a signed interval in a chain, beside an +// indirect call that passes values outside every direct site's range. +class Lookup { + t: number[] = []; + constructor() { + for (let i = 0; i < 64; i++) this.t.push(i * i); + } + get(i: number, j: number): number { + return this.t[i * 8 + j] ?? -1; + } +} +const lk = new Lookup(); +let s6 = 0; +for (let i = 0; i < 9 * RUN; i++) for (let j = -2; j < 9 * RUN; j++) s6 += lk.get(i - 1, j); +const viaCall = lk.get; +console.log("typed chain", s6, viaCall.call(lk, 1000, 5), viaCall.call(lk, 0.5, 1), viaCall.call(lk, -1, 9)); + +// 7. Loop counters keep their own (narrower) facts beside a facts interval. +function sweep(): number { + let acc = 0; + for (let r = 0; r < 8 * RUN; r++) { + const base = r * 8; + for (let f = 0; f < 8; f++) { + const sq = base + f; + acc = (acc + (table[sq * 3 + 1] ?? 0)) | 0; + } + } + return acc; +} +console.log("sweep", sweep()); From 6128dbf71ebf68937951b7abb628036b3a0a779a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 11:02:13 +0100 Subject: [PATCH 05/19] typed signed index parameters: the fixture's retained helper stays single-bodied --- .../function/test_p2_signed_index_params.ts | 70 +++++++++++++------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/tests/parity/language/function/test_p2_signed_index_params.ts b/tests/parity/language/function/test_p2_signed_index_params.ts index e974772736..2846112765 100644 --- a/tests/parity/language/function/test_p2_signed_index_params.ts +++ b/tests/parity/language/function/test_p2_signed_index_params.ts @@ -136,9 +136,9 @@ console.log( Object.keys(k1.cells).length, ); -// 6. The method retained and called indirectly: extracted, `call`, `apply`, -// `bind`, and through an array of functions. These enter the boxed entry -// (or keep the method untyped); in-range direct sites stand beside them. +// 6. The method retained and called indirectly with int32 keys and values +// of either sign: extracted, `call`, `apply`, `bind`, and through an array of +// functions. These enter the boxed entry; direct sites stand beside them. class Kept { cells: number[] = new Array(32).fill(0); h = 0; @@ -151,35 +151,63 @@ class Kept { } const kept = new Kept(); for (let i = 0; i < 32 * RUN; i++) kept.set(i, (i % 7) - 3); -const loose = kept.set; -loose.call(kept, 2, -6); -loose.apply(kept, [3, 6]); -const bound = kept.set.bind(kept); -bound(4, -1); -const fns = [bound, (s: number, p: number) => kept.set(s, p)]; -for (let i = 0; i < 8 * RUN; i++) fns[i & 1](5 + i, i - 4); -loose.call(kept, -2, 9); -loose.call(kept, 2.5, 9); -loose.call(kept, 6, 0.25); +const keptSet = kept.set; +keptSet.call(kept, 2, -6); +keptSet.apply(kept, [3, 6]); +const keptBound = kept.set.bind(kept); +keptBound(4, -1); +const keptFns = [keptBound, (s: number, p: number) => kept.set(s, p)]; +for (let i = 0; i < 8 * RUN; i++) keptFns[i & 1](5 + i, i - 4); +keptSet.call(kept, -2, 5); +console.log("kept", kept.h, kept.cells.join(","), (kept.cells as any)[-2]); + +// 6b. The same shape, but the indirect calls pass a fraction, a fractional +// key and another receiver: the parameters are no int32. +class KeptLoose { + cells: number[] = new Array(32).fill(0); + h = 0; + set(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h = (this.h - old) | 0; + this.cells[square] = piece; + if (piece !== 0) this.h = (this.h + (piece + 6) * 32 + square) | 0; + } +} +const keptLoose = new KeptLoose(); +for (let i = 0; i < 32 * RUN; i++) keptLoose.set(i, (i % 7) - 3); +const looseSet = keptLoose.set; +looseSet.call(keptLoose, -2, 9); +looseSet.call(keptLoose, 2.5, 9); +looseSet.call(keptLoose, 6, 0.25); const other = { cells: [1, 2, 3], h: 100 }; -loose.call(other as any, 1, 5); +looseSet.call(other as any, 1, 5); console.log( - "kept", - kept.h, - kept.cells.join(","), - (kept.cells as any)[-2], - (kept.cells as any)[2.5], + "kept loose", + keptLoose.h, + keptLoose.cells.join(","), + (keptLoose.cells as any)[-2], + (keptLoose.cells as any)[2.5], other.cells.join(","), other.h, ); // 7. A subclass that overrides the helper: the site has two bodies. -class Sub extends Kept { +class Base7 { + cells: number[] = new Array(32).fill(0); + h = 0; + set(square: number, piece: number): void { + const old = this.cells[square] ?? 0; + if (old !== 0) this.h = (this.h - old) | 0; + this.cells[square] = piece; + if (piece !== 0) this.h = (this.h + (piece + 6) * 32 + square) | 0; + } +} +class Sub7 extends Base7 { set(square: number, piece: number): void { this.cells[square] = piece * 2; } } -const mixed: Kept[] = [new Kept(), new Sub()]; +const mixed: Base7[] = [new Base7(), new Sub7()]; for (let i = 0; i < 16 * RUN; i++) mixed[i & 1].set(i, i - 8); console.log("override", mixed[0].cells.join(","), mixed[1].cells.join(","), mixed[0].h, mixed[1].h); From 9f7a3d4f8bcb0e008167e84913dfb15fb412807a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 11:55:30 +0100 Subject: [PATCH 06/19] changelog: facts local ranges --- changelog.d/codegen-facts-local-ranges.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/codegen-facts-local-ranges.md diff --git a/changelog.d/codegen-facts-local-ranges.md b/changelog.d/codegen-facts-local-ranges.md new file mode 100644 index 0000000000..9261809518 --- /dev/null +++ b/changelog.d/codegen-facts-local-ranges.md @@ -0,0 +1 @@ +Performance: the whole-program interval of an int32 local reaches codegen's range proofs. wpfacts already proved such intervals (a `let` read from an int Array is in the Array's element interval; a typed body's I32 parameter is in its signature's interval) but exported only "this local is an int32", so an element key built from such locals (`table[(old + 6) * 128 + square]`) was computed in doubles and normalized by a runtime call, and an at-rest element site keyed by a signed int32 local converted the key to a double and back. Now the key is an i32 chain bounded by one unsigned compare. `PERRY_FACTS_LOCAL_RANGES=0` restores the old lowering. Check mode witnesses the interval at every store into such a `let` (signature parameters are witnessed at their call sites). From 7c02239582e32932413b25347968600373db7b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:03:48 +0100 Subject: [PATCH 07/19] WIP: an int32-proven element key is lowered as its i32 (int32_key) --- crates/perry-codegen/src/expr/index_get.rs | 26 +++++++++++++++ crates/perry-codegen/src/expr/index_set.rs | 8 ++++- crates/perry-codegen/src/expr/int32_key.rs | 38 ++++++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 1 + 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 crates/perry-codegen/src/expr/int32_key.rs diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 756f6d0081..68fecf31e6 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1475,6 +1475,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let repair_slot = receiver_repair_slot(ctx, object); let confined = confined_receiver(ctx, object); let proven = proven_array::fact(ctx, object, *element_offset); + // A key the range proofs bound to int32 (of either sign) + // is lowered as that i32: the split takes it as a native + // key, with no normalizer call (`int32_key`). + if super::int32_key::proven(ctx, index) { + return rooting::with_operands_rooted_across( + ctx, + &[object], + &[index], + |ctx| super::int32_key::lower(ctx, index), + |ctx, vals, key| { + with_confined_read(ctx, confined, |ctx| { + lower_array_index_get_via_canonical_i32_split( + ctx, + &vals[0], + &key, + index, + false, + false, + false, + repair_slot.as_deref(), + proven, + ) + }) + }, + ); + } return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| { with_confined_read(ctx, confined, |ctx| { lower_array_index_get_via_canonical_i32_split( diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 6ef441554c..0159f10d9a 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -320,7 +320,13 @@ fn lower_array_index_set_via_runtime_key( &[object], &[index, value], |ctx| { - let idx_double = lower_expr(ctx, index)?; + // A key the range proofs bound to int32 is lowered as that i32 + // (`int32_key`): the guarded store takes it as a native key. + let idx_double = if super::int32_key::proven(ctx, index) { + super::int32_key::lower(ctx, index)? + } else { + lower_expr(ctx, index)? + }; let value_needs_barrier = array_store_needs_write_barrier(ctx, value); let (val_double, val_bits) = lower_value_for_dynamic_index_set( ctx, diff --git a/crates/perry-codegen/src/expr/int32_key.rs b/crates/perry-codegen/src/expr/int32_key.rs new file mode 100644 index 0000000000..37a694b4d3 --- /dev/null +++ b/crates/perry-codegen/src/expr/int32_key.rs @@ -0,0 +1,38 @@ +//! An element key the body's range proofs bound to the int32 range, of +//! either sign. +//! +//! The canonical key split (`index_get`'s runtime-key arm, `index_set`'s +//! runtime-key store) admits a key that is only statically numeric through +//! the shared normalizer (`index_key`, a call) unless the key's lowered +//! double has a native i32 source. A key whose range is proven inside int32 +//! ([`proven`]: `i32_fast_path::is_known_i32_range`, so a loop fact, a typed +//! body's parameter interval, or the whole-program interval of an int32 +//! `let`, `range_facts::facts_local_range`) is an exact int32 at every +//! evaluation, so it is lowered as one ([`lower`]: its i32 chain, else one +//! conversion of its double) and handed to the split as that source. The +//! split's unsigned bound (N10) then declines a negative key onto the arm +//! that reads the original value, as it does for any native key. +//! +//! `PERRY_FACTS_LOCAL_RANGES=0` keeps the double key and the normalizer. + +use anyhow::Result; +use perry_hir::Expr; + +use super::FnCtx; +use crate::types::{DOUBLE, I32}; + +/// `index` is an int32 at every evaluation by the body's range proofs, and +/// the unsigned bound is on (the split then needs no sign test for it). +pub(crate) fn proven(ctx: &FnCtx<'_>, index: &Expr) -> bool { + super::facts_local_ranges() + && super::index_key::unsigned_bound() + && !ctx.wpfacts_off + && super::i32_fast_path::is_known_i32_range(ctx, index) +} + +/// The key `index` ([`proven`]) as the double whose source is its i32 +/// (`sitofp`): `index_key::normalize_unsigned` hands that i32 over as it is. +pub(crate) fn lower(ctx: &mut FnCtx<'_>, index: &Expr) -> Result { + let idx = super::lower_expr_as_i32(ctx, index)?; + Ok(ctx.block().sitofp(I32, &idx, DOUBLE)) +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index d30835ff54..c29e49d9be 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3359,6 +3359,7 @@ pub(crate) use index_get::guarded_array::emit_spread_stack_admission; #[cfg(test)] mod index_get_claim_tests; pub(crate) mod index_key; +pub(crate) mod int32_key; #[cfg(test)] mod kind_entry_selection_tests; #[cfg(test)] From 9147d6cc17299366f2675f75caba8eefe395bb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:08:24 +0100 Subject: [PATCH 08/19] local ranges fixture: a typed chain with signed parameters beside the loose one --- .../number/test_facts_local_ranges.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/parity/language/number/test_facts_local_ranges.ts b/tests/parity/language/number/test_facts_local_ranges.ts index ac3a18e027..831a9da254 100644 --- a/tests/parity/language/number/test_facts_local_ranges.ts +++ b/tests/parity/language/number/test_facts_local_ranges.ts @@ -88,8 +88,8 @@ let s5 = 0; for (let i = 0; i < 12 * RUN; i++) s5 = (s5 + boxed(i) + widened(i)) | 0; console.log("boxed and widened", s5); -// 6. A typed parameter with a signed interval in a chain, beside an -// indirect call that passes values outside every direct site's range. +// 6. Typed parameters with signed intervals in a chain: every call is +// direct, so both are I32 (`i` in [-1, 7], `j` in [-2, 8]). class Lookup { t: number[] = []; constructor() { @@ -102,8 +102,26 @@ class Lookup { const lk = new Lookup(); let s6 = 0; for (let i = 0; i < 9 * RUN; i++) for (let j = -2; j < 9 * RUN; j++) s6 += lk.get(i - 1, j); -const viaCall = lk.get; -console.log("typed chain", s6, viaCall.call(lk, 1000, 5), viaCall.call(lk, 0.5, 1), viaCall.call(lk, -1, 9)); +(lk.t as any)[-3] = 1000; +for (let j = -2; j < 3 * RUN; j++) s6 += lk.get(-1, j + 3); +console.log("typed chain", s6); + +// 6b. The same helper also called indirectly with a key past every direct +// site's range, a fraction and a negative chain: its parameters are no I32. +class LookupLoose { + t: number[] = []; + constructor() { + for (let i = 0; i < 64; i++) this.t.push(i * i); + } + get(i: number, j: number): number { + return this.t[i * 8 + j] ?? -1; + } +} +const ll = new LookupLoose(); +let s6b = 0; +for (let i = 0; i < 9 * RUN; i++) for (let j = -2; j < 9 * RUN; j++) s6b += ll.get(i - 1, j); +const viaCall = ll.get; +console.log("loose chain", s6b, viaCall.call(ll, 1000, 5), viaCall.call(ll, 0.5, 1), viaCall.call(ll, -1, 9)); // 7. Loop counters keep their own (narrower) facts beside a facts interval. function sweep(): number { From 5f91420f295ea9a003320d77d6a77e95b06dcc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:08:36 +0100 Subject: [PATCH 09/19] typed signed index parameters: fixture comment --- tests/parity/language/function/test_p2_signed_index_params.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/parity/language/function/test_p2_signed_index_params.ts b/tests/parity/language/function/test_p2_signed_index_params.ts index 2846112765..c7785edb6d 100644 --- a/tests/parity/language/function/test_p2_signed_index_params.ts +++ b/tests/parity/language/function/test_p2_signed_index_params.ts @@ -138,7 +138,8 @@ console.log( // 6. The method retained and called indirectly with int32 keys and values // of either sign: extracted, `call`, `apply`, `bind`, and through an array of -// functions. These enter the boxed entry; direct sites stand beside them. +// functions. The extracted method escapes, so its parameters are Boxed and +// it keeps its index clones; direct sites stand beside the indirect ones. class Kept { cells: number[] = new Array(32).fill(0); h = 0; From de203f48de02d6286847dca1f5824fc221ae25a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:37:18 +0100 Subject: [PATCH 10/19] rustfmt --- crates/perry-codegen/src/codegen/signature_entry.rs | 11 +++++++++-- crates/perry-wpfacts/src/tests/signatures.rs | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/codegen/signature_entry.rs b/crates/perry-codegen/src/codegen/signature_entry.rs index 9a0182e5e8..a8032ee6dd 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -338,7 +338,10 @@ fn probe(class: &str, method: &str, sig: &FactsSignature, refused: Option<&str>) }) .collect(); match refused { - None => eprintln!("[perry sig] {class}.{method} typed=1 params=({})", params.join(",")), + None => eprintln!( + "[perry sig] {class}.{method} typed=1 params=({})", + params.join(",") + ), Some(why) => eprintln!( "[perry sig] {class}.{method} typed=0 why={why} params=({})", params.join(",") @@ -1090,7 +1093,11 @@ mod tests { /// the clone stays unless the interval is non-negative: main's rule. #[test] fn without_the_rule_a_negative_interval_keeps_the_index_clone() { - assert!(!index_param_covered(FactsRep::I32, Some((-128, 255)), false)); + assert!(!index_param_covered( + FactsRep::I32, + Some((-128, 255)), + false + )); assert!(!index_param_covered(FactsRep::I32, Some((-1, 7)), false)); assert!(!index_param_covered(FactsRep::I32, None, false)); } diff --git a/crates/perry-wpfacts/src/tests/signatures.rs b/crates/perry-wpfacts/src/tests/signatures.rs index 4e490439b4..083d3f8ebf 100644 --- a/crates/perry-wpfacts/src/tests/signatures.rs +++ b/crates/perry-wpfacts/src/tests/signatures.rs @@ -447,8 +447,14 @@ console.log(b.h); fn a_signed_key_and_value_are_i32_parameters_with_their_intervals() { let s = sig(SIGNED_SET, "set").expect("a signature"); assert_eq!(s.params, vec![I32, I32], "{s:?}"); - assert!(s.ranges[0].is_some_and(|(lo, hi)| lo <= -8 && hi >= 63), "{s:?}"); - assert!(s.ranges[1].is_some_and(|(lo, hi)| lo <= -6 && hi >= 6), "{s:?}"); + assert!( + s.ranges[0].is_some_and(|(lo, hi)| lo <= -8 && hi >= 63), + "{s:?}" + ); + assert!( + s.ranges[1].is_some_and(|(lo, hi)| lo <= -6 && hi >= 6), + "{s:?}" + ); let id = id_of(SIGNED_SET, "set"); assert_eq!(typed_targets(SIGNED_SET), vec![id, id]); } From 918b02bf0d7dfd340b815fb17c28bdba0af4ef8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:37:32 +0100 Subject: [PATCH 11/19] rustfmt --- .../perry-codegen/src/codegen/facts_int32_locals.rs | 12 ++++++++++-- crates/perry-codegen/src/codegen/signature_entry.rs | 5 +---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/codegen/facts_int32_locals.rs b/crates/perry-codegen/src/codegen/facts_int32_locals.rs index 706ed4fc1b..25815cf8e9 100644 --- a/crates/perry-codegen/src/codegen/facts_int32_locals.rs +++ b/crates/perry-codegen/src/codegen/facts_int32_locals.rs @@ -107,8 +107,16 @@ pub(crate) fn witness_exact(ctx: &mut crate::expr::FnCtx<'_>, value: &str, id: u // tolerated `-0` compares as 0. if let Some(range) = crate::expr::facts_local_range(ctx, id) { let blk = ctx.block(); - let ge = blk.fcmp("oge", value, &crate::nanbox::double_literal(range.min as f64)); - let le = blk.fcmp("ole", value, &crate::nanbox::double_literal(range.max as f64)); + let ge = blk.fcmp( + "oge", + value, + &crate::nanbox::double_literal(range.min as f64), + ); + let le = blk.fcmp( + "ole", + value, + &crate::nanbox::double_literal(range.max as f64), + ); let inside = blk.and(I1, &ge, &le); exact = blk.and(I1, &exact, &inside); } diff --git a/crates/perry-codegen/src/codegen/signature_entry.rs b/crates/perry-codegen/src/codegen/signature_entry.rs index 48c2fc8088..4481a79a83 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -1057,10 +1057,7 @@ pub(crate) fn slot_i32_params(f: &Function, plan: &SpecFnPlan) -> HashSet { /// scope no loop ends). The interval is witnessed at every typed site and at /// the boxed entry (`witness_value`). Empty with /// `PERRY_FACTS_LOCAL_RANGES=0`. -pub(crate) fn param_range_facts( - f: &Function, - plan: &SpecFnPlan, -) -> Vec { +pub(crate) fn param_range_facts(f: &Function, plan: &SpecFnPlan) -> Vec { let Some(sig) = plan.signature.as_ref() else { return Vec::new(); }; From 044d84c8ed806dcdc45f72ed62eb8d36c5606b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 12:52:33 +0100 Subject: [PATCH 12/19] typed signed index parameters: the fixture's at-rest board --- .../function/test_p2_signed_index_params.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/parity/language/function/test_p2_signed_index_params.ts b/tests/parity/language/function/test_p2_signed_index_params.ts index c7785edb6d..79b03e661c 100644 --- a/tests/parity/language/function/test_p2_signed_index_params.ts +++ b/tests/parity/language/function/test_p2_signed_index_params.ts @@ -226,3 +226,36 @@ const w = new Written(); let ws = 0; for (let i = 0; i < 24 * RUN; i++) ws += w.set(i - 8, i); console.log("written", ws, w.cells.join(",")); + +// 9. The same helper over an Array stored at rest (I32 words; only element +// reads and stores touch it): the typed body's board read and store, with +// keys below 0 (a property), inside the birth length, and past it (the +// reserved capacity). +class RestBoard { + cells: number[] = new Array(64).fill(0); + za: number[] = []; + ha = 0; + constructor() { + let seed = 99; + for (let i = 0; i < 16 * 128; i++) { + seed = (seed * 1103515245 + 12345) | 0; + this.za.push(seed); + } + } + at(square: number): number { + return this.cells[square] ?? 0; + } + set(square: number, piece: number): void { + const old = this.at(square); + if (old !== 0) this.ha ^= this.za[(old + 6) * 128 + square] ?? 0; + this.cells[square] = piece; + if (piece !== 0) this.ha ^= this.za[(piece + 6) * 128 + square] ?? 0; + } +} +const rb = new RestBoard(); +for (let i = 0; i < 64 * RUN; i++) rb.set(i, (i % 13) - 6); +for (let i = 0; i < 80 * RUN; i += 3) rb.set(i - 8, i & 1 ? -5 : 5); +for (let i = 0; i < 64 * RUN; i += 7) rb.set(i, 0); +let sum9 = 0; +for (let i = -8; i < 80 * RUN; i++) sum9 = (sum9 + rb.at(i) * (i + 9)) | 0; +console.log("at rest", sum9, rb.ha, rb.at(-8), rb.at(-5), rb.at(70), rb.at(71), rb.at(0)); From d3ee70ef4699223b537cda275bc19637cb54df81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 13:10:57 +0100 Subject: [PATCH 13/19] WIP: an I32 lane read and a present at-rest element read are known int32 --- crates/perry-codegen/src/expr/i32_fast_path.rs | 13 +++++++++++++ crates/perry-codegen/src/expr/rest_fields.rs | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index beac19b25c..bc2d7a7004 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -42,6 +42,19 @@ pub(crate) fn is_known_i32_range(ctx: &FnCtx<'_>, e: &Expr) -> bool { super::range_facts::int_range_expr(ctx, e) .is_some_and(|range| range.min >= i64::from(i32::MIN) && range.max <= i64::from(i32::MAX)) || is_int32_bitwise_result(ctx, e) + || is_at_rest_int32(ctx, e) +} + +/// A value the facts store at rest as an I32 word is an int32 Number where +/// it is read: an I32 field lane every receiver has (`rest_fields:: +/// is_int32_read`), and an at-rest element the facts prove present +/// (`rest_elems::number_read`). `this.hash ^= x` then takes the lane's word +/// with one conversion in place of the guarded ToInt32. +/// `PERRY_FACTS_LOCAL_RANGES=0` turns it off. +fn is_at_rest_int32(ctx: &FnCtx<'_>, e: &Expr) -> bool { + super::facts_local_ranges() + && !ctx.wpfacts_off + && (super::rest_fields::is_int32_read(ctx, e) || super::rest_elems::number_read(ctx, e)) } /// `&`, `|`, `^`, `<<` and `>>` compute a BigInt only when BOTH operands are diff --git a/crates/perry-codegen/src/expr/rest_fields.rs b/crates/perry-codegen/src/expr/rest_fields.rs index cee9641963..45ed4deeca 100644 --- a/crates/perry-codegen/src/expr/rest_fields.rs +++ b/crates/perry-codegen/src/expr/rest_fields.rs @@ -518,6 +518,17 @@ pub(crate) fn try_lower_number(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result, expr: &Expr) -> bool { + matches!(expr, Expr::PropertyGet { object, property, byte_offset, .. } + if gated_read(ctx, property, *byte_offset) + && !scalar(ctx, object) + && matches!(plan(ctx, object, property, Access::Get), Plan::Proven(p) if !p.nullable)) +} + /// Is `expr` a gated read, which a raw slot tier must leave alone? pub(crate) fn is_gated_read(ctx: &FnCtx<'_>, expr: &Expr) -> bool { matches!(expr, Expr::PropertyGet { object, property, byte_offset, .. } From 89372b2deaed974838d054e6a60d43ec5929b67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 13:21:32 +0100 Subject: [PATCH 14/19] local ranges IR test: a let with no local range proof --- .../perry-codegen/src/codegen/facts_local_ranges_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs index 10e339afd5..f75f2310b0 100644 --- a/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs +++ b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs @@ -10,8 +10,10 @@ use crate::codegen::{ElisionFacts, FactsRep, ProgramFactsView}; use crate::{compile_module, CompileOptions}; use perry_hir::Module; -const SRC: &str = "export function f(t: number[], i: number): number {\n\ - const k = (i & 15) - 3;\n\ +/// `k` has no local range proof (an element of an ordinary Array): only +/// the facts' interval bounds it. +const SRC: &str = "export function f(t: number[], u: number[], i: number): number {\n\ + const k = u[i];\n\ return t[k * 2 + 1] ?? -1;\n\ }\n"; From fd3c88681de7d35473ccfabd8fed3444b05a3df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 13:31:16 +0100 Subject: [PATCH 15/19] compile_env: PERRY_SIG_DIAG is a diagnostic read (exclusion with its reason) --- crates/perry/src/commands/compile/compile_env.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry/src/commands/compile/compile_env.rs b/crates/perry/src/commands/compile/compile_env.rs index fa3221b5f3..1afaac2795 100644 --- a/crates/perry/src/commands/compile/compile_env.rs +++ b/crates/perry/src/commands/compile/compile_env.rs @@ -697,6 +697,10 @@ const COMPILE_ENV_EXCLUSIONS: &[(&str, &str)] = &[ "PERRY_SANDBOX_BUILDRS", "Restricts external build-script permissions; successful script output is unchanged.", ), + ( + "PERRY_SIG_DIAG", + "Prints the typed-signature selector's verdict per method on stderr without changing emitted objects.", + ), ( "PERRY_SAVE_LL", "Writes IR/report files or diagnostic output without changing emitted objects.", From 176a957a36903e4a3687aaa98f792be07d9e3b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 13:34:27 +0100 Subject: [PATCH 16/19] wpfacts signatures: refusal replaces admits (no dead function) --- crates/perry-wpfacts/src/signatures.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/perry-wpfacts/src/signatures.rs b/crates/perry-wpfacts/src/signatures.rs index 30f7a81ca7..b6a4b86cdb 100644 --- a/crates/perry-wpfacts/src/signatures.rs +++ b/crates/perry-wpfacts/src/signatures.rs @@ -23,7 +23,7 @@ //! twice (a for…of's counted and IteratorRecord arms), or a callee the //! inliner copied into several callers. The site is joined over them: every //! copy must resolve to F, and every copy's arguments must hold F's -//! signature (`admits`). Calls of different spans under one key are distinct +//! signature (`refusal`). Calls of different spans under one key are distinct //! calls and refuse. use crate::bindings::{Enc, ModuleBindings, Rep}; @@ -460,18 +460,8 @@ fn literal_defaults(f: &perry_hir::Function) -> Vec> { /// interval, joined over the argument node's instance copies; an omitted or /// `undefined` argument of a literal-defaulted parameter is that literal. /// An omitted argument of any other non-Boxed parameter refuses. -fn admits( - g: &Gen<'_>, - all: &dyn Fn(NodeId) -> Vec, - sig: &Signature, - lits: &[Option], - args: &[NodeId], -) -> bool { - refusal(g, all, sig, lits, args).is_none() -} - -/// The first parameter whose argument does not hold `sig` (`admits`), with -/// the reason (the `# typed_call` probe's `why`). +/// The result is the first parameter whose argument does not hold `sig`, with +/// the reason (the `# typed_call` probe's `why`); `None` when every one does. fn refusal( g: &Gen<'_>, all: &dyn Fn(NodeId) -> Vec, From c7bb05a257c6df87df755de058a73c2495a27065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 14:04:29 +0100 Subject: [PATCH 17/19] check_file_size: expr/mod.rs code-line count (2844) --- scripts/check_file_size.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index cd4d1f2ed2..fdfcb6a249 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -28,7 +28,7 @@ crates/perry-codegen/src/codegen/mod.rs 3662 # Representation-aware HIR fact collection; further decomposition is tracked in #1435. crates/perry-codegen/src/collectors/hir_facts.rs 2760 # Representation-aware expression dispatch; further decomposition is tracked in #1435. -crates/perry-codegen/src/expr/mod.rs 2838 +crates/perry-codegen/src/expr/mod.rs 2844 # Loop versioning and representation evidence share lowering state; decomposition is tracked in #1435. crates/perry-codegen/src/stmt/loops.rs 8354 # Representation-aware map core; further decomposition is tracked in #1435. From be8a059ac9c88908141d154ace5e71ec650d3916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 14:07:57 +0100 Subject: [PATCH 18/19] Revert the at-rest int32 range tweak: no measured effect on chess (x3 = x2) --- crates/perry-codegen/src/expr/i32_fast_path.rs | 13 ------------- crates/perry-codegen/src/expr/rest_fields.rs | 11 ----------- 2 files changed, 24 deletions(-) diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index bc2d7a7004..beac19b25c 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -42,19 +42,6 @@ pub(crate) fn is_known_i32_range(ctx: &FnCtx<'_>, e: &Expr) -> bool { super::range_facts::int_range_expr(ctx, e) .is_some_and(|range| range.min >= i64::from(i32::MIN) && range.max <= i64::from(i32::MAX)) || is_int32_bitwise_result(ctx, e) - || is_at_rest_int32(ctx, e) -} - -/// A value the facts store at rest as an I32 word is an int32 Number where -/// it is read: an I32 field lane every receiver has (`rest_fields:: -/// is_int32_read`), and an at-rest element the facts prove present -/// (`rest_elems::number_read`). `this.hash ^= x` then takes the lane's word -/// with one conversion in place of the guarded ToInt32. -/// `PERRY_FACTS_LOCAL_RANGES=0` turns it off. -fn is_at_rest_int32(ctx: &FnCtx<'_>, e: &Expr) -> bool { - super::facts_local_ranges() - && !ctx.wpfacts_off - && (super::rest_fields::is_int32_read(ctx, e) || super::rest_elems::number_read(ctx, e)) } /// `&`, `|`, `^`, `<<` and `>>` compute a BigInt only when BOTH operands are diff --git a/crates/perry-codegen/src/expr/rest_fields.rs b/crates/perry-codegen/src/expr/rest_fields.rs index 45ed4deeca..cee9641963 100644 --- a/crates/perry-codegen/src/expr/rest_fields.rs +++ b/crates/perry-codegen/src/expr/rest_fields.rs @@ -518,17 +518,6 @@ pub(crate) fn try_lower_number(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result, expr: &Expr) -> bool { - matches!(expr, Expr::PropertyGet { object, property, byte_offset, .. } - if gated_read(ctx, property, *byte_offset) - && !scalar(ctx, object) - && matches!(plan(ctx, object, property, Access::Get), Plan::Proven(p) if !p.nullable)) -} - /// Is `expr` a gated read, which a raw slot tier must leave alone? pub(crate) fn is_gated_read(ctx: &FnCtx<'_>, expr: &Expr) -> bool { matches!(expr, Expr::PropertyGet { object, property, byte_offset, .. } From c96e5d4b4db5fc5cb46c89b5dbad568215c043fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 11 Nov 2026 14:18:22 +0100 Subject: [PATCH 19/19] local ranges IR tests: compare against the baseline without an interval --- .../src/codegen/facts_local_ranges_tests.rs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs index f75f2310b0..0cf12af323 100644 --- a/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs +++ b/crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs @@ -83,18 +83,24 @@ fn normalizer_calls(ir: &str) -> usize { .count() } -#[test] -fn a_key_built_from_a_ranged_int32_let_is_an_i32_key() { - // `k` in [-3, 12]: `k * 2 + 1` is in [-5, 25], an int32 of either sign. - let f = ir_of_f(Some((-3, 12))); - assert_eq!(normalizer_calls(&f), 0, "{f}"); - assert!(!f.contains("fmul double"), "{f}"); +/// The normalizer calls of `f` when `k` has no interval: one for `t[k * 2 + +/// 1]` (and one for `u[i]`, whose key is a double parameter in every arm). +fn baseline() -> usize { + let n = normalizer_calls(&ir_of_f(None)); + assert!( + n >= 1, + "the key of `t[k * 2 + 1]` is normalized without an interval" + ); + n } #[test] -fn without_the_interval_the_key_is_normalized() { - let f = ir_of_f(None); - assert_eq!(normalizer_calls(&f), 1, "{f}"); +fn a_key_built_from_a_ranged_int32_let_is_an_i32_key() { + // `k` in [-3, 12]: `k * 2 + 1` is in [-5, 25], an int32 of either sign: + // its normalizer call is gone, and the key is an i32 chain. + let f = ir_of_f(Some((-3, 12))); + assert_eq!(normalizer_calls(&f) + 1, baseline(), "{f}"); + assert!(f.contains("mul i32") || f.contains("shl i32"), "{f}"); } #[test] @@ -102,5 +108,5 @@ fn an_interval_whose_chain_leaves_int32_is_no_i32_key() { // `k` up to 2^31 - 1: `k * 2 + 1` may pass int32, so the key keeps its // double (a wrapped i32 would name another element). let f = ir_of_f(Some((0, i64::from(i32::MAX)))); - assert_eq!(normalizer_calls(&f), 1, "{f}"); + assert_eq!(normalizer_calls(&f), baseline(), "{f}"); }