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 6842b6421a..a8032ee6dd 100644 --- a/crates/perry-codegen/src/codegen/signature_entry.rs +++ b/crates/perry-codegen/src/codegen/signature_entry.rs @@ -293,6 +293,62 @@ 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") + ) + }) +} + +/// 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). +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 +384,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 +397,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 +415,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 +445,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 +455,30 @@ 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) + !ids.contains(&p.id) || index_param_covered(*rep, *range, signed_index) }) }); - 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 +488,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), @@ -995,3 +1067,38 @@ pub(crate) fn nonnegative_params(f: &Function, plan: &SpecFnPlan) -> HashSet( 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..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}; @@ -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 @@ -384,13 +460,15 @@ 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( +/// 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, sig: &Signature, lits: &[Option], args: &[NodeId], -) -> bool { +) -> 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 +482,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 +505,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 +516,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-wpfacts/src/tests/signatures.rs b/crates/perry-wpfacts/src/tests/signatures.rs index 6187144abb..083d3f8ebf 100644 --- a/crates/perry-wpfacts/src/tests/signatures.rs +++ b/crates/perry-wpfacts/src/tests/signatures.rs @@ -404,3 +404,103 @@ console.log(s); let id = id_of(src, "inc"); assert!(!typed_targets(src).contains(&id), "{:?}", sig(src, "inc")); } + +/// The `# typed_call` probe lines of `src` (the dump's, diagnostics on). +fn typed_call_lines(src: &str) -> 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.rs b/crates/perry/src/commands/compile/compile_env.rs index 51bf5bf232..1afaac2795 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", @@ -696,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.", diff --git a/crates/perry/src/commands/compile/compile_env/tests.rs b/crates/perry/src/commands/compile/compile_env/tests.rs index 08f0db64a3..dbd5756bd1 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..79b03e661c --- /dev/null +++ b/tests/parity/language/function/test_p2_signed_index_params.ts @@ -0,0 +1,261 @@ +// 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 with int32 keys and values +// of either sign: extracted, `call`, `apply`, `bind`, and through an array of +// 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; + 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 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 }; +looseSet.call(other as any, 1, 5); +console.log( + "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 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: 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); + +// 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(",")); + +// 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));