diff --git a/changelog.d/wpfacts-key-sign.md b/changelog.d/wpfacts-key-sign.md new file mode 100644 index 0000000000..fc2e31e007 --- /dev/null +++ b/changelog.d/wpfacts-key-sign.md @@ -0,0 +1 @@ +Performance: an element read or store whose key the whole-program facts prove an integer Number inside the int32 range no longer normalizes the key at run time, and one whose key's interval is non-negative no longer tests its sign. wpfacts exports the key interval of every element site (reads, stores and called elements, joined over every copy of the site) as `key_signs`; codegen converts such a key with one exact `fptosi` (or takes the native integer it was widened from) instead of calling the shared normalizer, and a proven array index enters its element tier directly: no sign test, no `-1` select and no property-key arm. A key that may be negative keeps its sign test (the unsigned bound's miss arm, or the split's test), so it still reads the named property exactly; a key that may be a fraction, NaN, a string or a symbol has no interval and keeps the normalizer. `Array.prototype.at`, typed arrays and the index method clones are unchanged. Check mode (`PERRY_VERIFY_FACTS=1`) tests the claim at each consuming site and reports a false proof as "key sign" (exit 86); `PERRY_FACTS_SABOTAGE=keysign` is its negative control. `PERRY_FACTS_KEY_SIGN=0` turns the facts and their consumers off; the dump (`PERRY_FACTS_DUMP`) prints one `# key_sign` line per element site with an integer key. diff --git a/crates/perry-codegen/src/codegen/program_facts_view.rs b/crates/perry-codegen/src/codegen/program_facts_view.rs index fd4b5ef8eb..0aabc00de1 100644 --- a/crates/perry-codegen/src/codegen/program_facts_view.rs +++ b/crates/perry-codegen/src/codegen/program_facts_view.rs @@ -399,6 +399,11 @@ pub struct ElisionFacts { /// every copy, with its interval (wpfacts `ModuleBindings::element_keys`, /// PR-C2; `expr::index_get::proven_array`). pub element_keys: std::collections::BTreeMap, + /// Element sites (reads and stores, by `element_offset`) whose key is an + /// integer Number inside the int32 range at every copy, with its + /// interval (wpfacts `ModuleBindings::key_signs`; + /// `expr::index_key::key_fact`). + pub key_signs: std::collections::BTreeMap, /// I32 at rest, PR B (wpfacts `ModuleBindings::rest_reads` / /// `rest_writes` / `rest_births`; `expr::rest_elems`): element reads and /// stores (by `element_offset`) whose every receiver is an at-rest diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 756f6d0081..41bbab3a25 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -581,6 +581,7 @@ pub(crate) fn lower_numeric_index_get_for_number_context( false, repair_slot.as_deref(), proven, + super::index_key::key_fact(ctx, *element_offset), ) .map(Some) }); @@ -1461,6 +1462,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { true, repair_slot.as_deref(), proven, + super::index_key::key_fact(ctx, *element_offset), ) }) }); @@ -1487,6 +1489,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { false, repair_slot.as_deref(), proven, + super::index_key::key_fact(ctx, *element_offset), ) }) }); diff --git a/crates/perry-codegen/src/expr/index_get/runtime_key.rs b/crates/perry-codegen/src/expr/index_get/runtime_key.rs index 20b2301ee3..b9bcbf392d 100644 --- a/crates/perry-codegen/src/expr/index_get/runtime_key.rs +++ b/crates/perry-codegen/src/expr/index_get/runtime_key.rs @@ -62,7 +62,35 @@ pub(super) fn lower_array_index_get_via_canonical_i32_split( preserve_claimed_receiver_fallback: bool, receiver_slot: Option<&str>, proven: Option, + key: Option, ) -> Result { + // Key signs: a key the facts prove a non-negative int32 is an array + // index at every evaluation. It takes the element tier with no + // normalizer and no sign test, the tier's own cold read serves every + // miss, and the property-key arm does not exist. + if let Some(fact) = key.filter(|k| k.nonneg) { + let idx_i32 = crate::expr::index_key::exact_i32(ctx, idx_double, fact); + return match proven { + Some(proven) => Ok(super::proven_array::lower_bounded( + ctx, + arr_box, + &idx_i32, + proven, + coerce_numeric_fallback, + None, + )), + None => super::guarded_array::lower_guarded_array_index_get_bounded( + ctx, + arr_box, + &idx_i32, + "aidx.dynamic", + require_numeric_layout, + coerce_numeric_fallback, + receiver_slot, + None, + ), + }; + } let element_idx = ctx.new_block("aidx.canonical"); let runtime_idx = ctx.new_block("aidx.runtime_key"); let merge_idx = ctx.new_block("aidx.dynamic_merge"); @@ -76,10 +104,12 @@ pub(super) fn lower_array_index_get_via_canonical_i32_split( // to the runtime arm below before its own cold read. A non-negative miss // keeps the tier's cold read, so every key takes the arm it takes today. let fold = super::guarded_array::unsigned_bound_covers(proven); - let idx_i32 = if fold { - crate::expr::index_key::normalize_unsigned(ctx, idx_double, Some(index)) - } else { - crate::expr::index_key::normalize(ctx, idx_double, Some(index)) + // Key signs: an int32 key that may be negative is its `i32` with no + // normalizer; its sign is tested as any other key's. + let idx_i32 = match key { + Some(fact) => crate::expr::index_key::exact_i32(ctx, idx_double, fact), + None if fold => crate::expr::index_key::normalize_unsigned(ctx, idx_double, Some(index)), + None => crate::expr::index_key::normalize(ctx, idx_double, Some(index)), }; let negative_exit = fold.then(|| runtime_label.clone()); if fold { diff --git a/crates/perry-codegen/src/expr/index_key.rs b/crates/perry-codegen/src/expr/index_key.rs index 5b28324ce8..fac1e941e4 100644 --- a/crates/perry-codegen/src/expr/index_key.rs +++ b/crates/perry-codegen/src/expr/index_key.rs @@ -93,6 +93,116 @@ pub(crate) fn unsigned_bound() -> bool { }) } +/// What wpfacts proves of the key of an element site (`ElisionFacts:: +/// key_signs`, wpfacts `element_keys::collect_signs`): at every copy of the +/// site it is an integer Number inside the int32 range, so its `i32` is the +/// key exactly and the site needs no normalizer ([`exact_i32`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct KeyFact { + /// Its interval has `lo >= 0`: the key is an array index. The site + /// needs no sign test and no property-key arm. + pub(crate) nonneg: bool, +} + +/// `PERRY_FACTS_KEY_SIGN=0` (`off`, `false`): no element site consumes its +/// key's interval; every site keeps its normalizer and its sign test. +pub(crate) fn key_sign_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + !matches!( + std::env::var("PERRY_FACTS_KEY_SIGN").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// THE query for the key of the element site at `element_offset` (a read or +/// a store of this module). A b1 call's synthesized read carries the body +/// module's offset (`FnCtx::rest_b1`): this module's facts say nothing of +/// its key. +pub(crate) fn key_fact(ctx: &FnCtx<'_>, element_offset: u32) -> Option { + if element_offset == 0 || ctx.wpfacts_off || !key_sign_enabled() { + return None; + } + if matches!(ctx.rest_b1, Some((o, _)) if o == element_offset) { + return None; + } + let &(lo, hi) = ctx.wpfacts.key_signs.get(&element_offset)?; + (lo >= i64::from(i32::MIN) && hi <= i64::from(i32::MAX)).then_some(KeyFact { nonneg: lo >= 0 }) +} + +/// [`key_fact`] for the element store `e` (`a[k] = v` as an `IndexSet`). +pub(crate) fn store_key_fact(ctx: &FnCtx<'_>, e: &Expr) -> Option { + match e { + Expr::IndexSet { element_offset, .. } => key_fact(ctx, *element_offset), + _ => None, + } +} + +/// The `i32` of the key `key` (a double) under `fact`: the native integer it +/// was widened from, else one `fptosi` (exact: the key is an integer inside +/// the int32 range; `-0` is index 0, as its property key `"0"` is). Check +/// mode (`PERRY_VERIFY_FACTS=1`) tests the claim first ([`witness_sign`]). +pub(crate) fn exact_i32(ctx: &mut FnCtx<'_>, key: &str, fact: KeyFact) -> String { + crate::verify_facts::consumed( + crate::verify_facts::Fact::KeySign, + || serde_json::json!({ "nonneg": fact.nonneg }), + ); + crate::facts_consumed::used(); + if crate::verify_facts::enabled() { + witness_sign(ctx, key, fact); + } + let source = ctx + .func + .blocks() + .iter() + .rev() + .find_map(|b| b.widened_i32_source(key)); + source.unwrap_or_else(|| ctx.block().fptosi(DOUBLE, key, I32)) +} + +/// Check mode: the key `key` is an integer Number inside the int32 range +/// and, under `fact.nonneg`, not negative. A miss reports the false proof +/// ("key sign") and exits 86. +fn witness_sign(ctx: &mut FnCtx<'_>, key: &str, fact: KeyFact) { + crate::verify_facts::covered( + crate::verify_facts::Fact::KeySign, + || serde_json::json!({ "nonneg": fact.nonneg }), + ); + let blk = ctx.block(); + let low = if fact.nonneg { "0.0" } else { "-2147483648.0" }; + let ge = blk.fcmp("oge", key, low); + let le = blk.fcmp("ole", key, "2147483647.0"); + let inside = blk.and(I1, &ge, &le); + // Sanitize before `fptosi`: a rejected conversion must not poison. + let safe = blk.select(I1, &inside, DOUBLE, key, "0.5"); + let int = blk.fptosi(DOUBLE, &safe, I32); + let back = blk.sitofp(I32, &int, DOUBLE); + let exact = blk.fcmp("oeq", &back, key); + let ok = blk.and(I1, &inside, &exact); + crate::verify_facts::tick(ctx); + let miss = ctx.new_block("keysign.verify_miss"); + let next = ctx.new_block("keysign.admitted"); + let (miss_l, next_l) = (ctx.block_label(miss), ctx.block_label(next)); + ctx.block().cond_br(&ok, &next_l, &miss_l); + ctx.current_block = miss; + let what = if fact.nonneg { + "an element key that is no non-negative int32" + } else { + "an element key that is no int32" + }; + let site = crate::verify_facts::site_arg(ctx, what, crate::verify_facts::Fact::KeySign, &[]); + let _ = crate::verify_facts::check( + ctx, + I32, + "perry_wpfacts_check_miss", + &[(DOUBLE, key)], + &site, + ); + ctx.block().unreachable(); + ctx.current_block = next; +} + fn normalize_keyed(ctx: &mut FnCtx<'_>, key: &str, expr: Option<&Expr>, wrap: bool) -> String { let source = ctx .func @@ -164,6 +274,10 @@ fn publish(ctx: &mut FnCtx<'_>) { ctx.pending_helpers.push(f); } +#[cfg(test)] +#[path = "index_key_sign_tests.rs"] +mod sign_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-codegen/src/expr/index_key_sign_tests.rs b/crates/perry-codegen/src/expr/index_key_sign_tests.rs new file mode 100644 index 0000000000..fcd7605f48 --- /dev/null +++ b/crates/perry-codegen/src/expr/index_key_sign_tests.rs @@ -0,0 +1,91 @@ +//! Key signs (`index_key::key_fact`, wpfacts `key_signs`): an element site +//! whose key the facts prove an int32 takes no normalizer, and one whose key +//! is proven non-negative takes no sign test and has no property-key arm. +//! Without the fact (or with an interval past int32) the site keeps both, so +//! each test can fail. + +use std::sync::Arc; + +use crate::codegen::{ElisionFacts, ProgramFactsView}; +use crate::{compile_module, CompileOptions}; + +const SRC: &str = "export function at(a: number[], i: number): number { return a[i]; }\n\ + export function any(a: number[], i: number): unknown { return a[i]; }\n\ + export function put(a: number[], i: number, v: number): void { a[i] = v; }\n"; + +/// IR of `SRC` with the key interval `key` on every `a[i]` site. +fn ir(key: Option<(i64, i64)>) -> String { + let mut elision = ElisionFacts::default(); + if let Some(k) = key { + // SWC offsets are 1-based; a site's offset is its object's start. + for (i, _) in SRC.match_indices("a[i]") { + elision.key_signs.insert(i as u32 + 1, k); + } + } + let opts = CompileOptions { + emit_ir_only: true, + program_facts: ProgramFactsView { + elision: Arc::new(elision), + ..Default::default() + }, + ..Default::default() + }; + let src = SRC.to_string(); + let m = 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, "ks.ts", &mut cache) + .expect("parses"); + perry_hir::lower_module(&parsed.module, "ks", "ks.ts").expect("lowers") + }) + .expect("spawn") + .join() + .expect("lowering thread"); + String::from_utf8(compile_module(&m, opts).expect("fixture compiles")).expect("UTF-8 IR") +} + +const NORMALIZE: &str = "call i32 @perry_array_index_normalize_i32"; + +#[test] +fn an_unproven_key_keeps_its_normalizer_and_the_property_key_arm() { + let without = ir(None); + assert!(without.contains(NORMALIZE), "{without}"); + assert!(without.contains("aidx.runtime_key"), "{without}"); + // An integer interval past the int32 range is no key fact. + let wide = ir(Some((0, 4_294_967_295))); + assert!(wide.contains(NORMALIZE), "{wide}"); + assert!(wide.contains("aidx.runtime_key"), "{wide}"); +} + +#[test] +fn a_proven_index_takes_no_normalizer_and_no_sign_test() { + let with = ir(Some((0, 100))); + assert!(!with.contains(NORMALIZE), "no normalizer\n{with}"); + assert!( + !with.contains("aidx.runtime_key"), + "no property-key arm\n{with}" + ); + assert!( + !with.contains("ubound.cold"), + "no negative exit on the miss arm\n{with}" + ); + assert!( + with.contains("fptosi double"), + "one exact conversion\n{with}" + ); + // The bound stays: the key is not proven inside the Array. + assert!(with.contains("icmp ult i32"), "{with}"); +} + +#[test] +fn an_int32_key_that_may_be_negative_keeps_its_sign_arm() { + let with = ir(Some((-5, 100))); + assert!(!with.contains(NORMALIZE), "no normalizer\n{with}"); + assert!( + with.contains("aidx.runtime_key"), + "a negative key reads the named property\n{with}" + ); + // The miss arm still sends a negative key to the property-key read. + assert!(with.contains("ubound.cold"), "{with}"); +} diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 6ef441554c..225931351c 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -303,6 +303,9 @@ fn lower_array_index_set_via_runtime_key( // #9394: the assignment's own `Throw` flag, carried to the runtime helper // so a rejected element write is a TypeError only in strict code. assignment_strict: bool, + // Key signs: what the facts prove of this store's key (`index_key:: + // store_key_fact`). + key: Option, ) -> Result { let layout_note_needed = array_store_needs_layout_note(ctx, object, value); let value_is_numeric = is_numeric_expr(ctx, value); @@ -342,7 +345,12 @@ fn lower_array_index_set_via_runtime_key( // N10: every arm of that store is behind `idx super::index_key::exact_i32(ctx, &idx_double, fact), + None => super::index_key::normalize_unsigned(ctx, &idx_double, Some(index)), + }; super::index_set_guarded::emit_guarded_inbounds_array_store( ctx, &arr_box, @@ -1053,6 +1061,7 @@ pub(crate) fn lower( value.as_ref(), "array[dynamic_numeric_index]", assignment_strict, + super::index_key::store_key_fact(ctx, expr), ); } // Same dispatch tree as IndexGet: known array → fast inline, @@ -1175,6 +1184,7 @@ pub(crate) fn lower( value.as_ref(), "array[dynamic_numeric_index]", assignment_strict, + super::index_key::store_key_fact(ctx, expr), ); }; let layout_note_needed = array_store_needs_layout_note(ctx, object, value); diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 19dd1de895..0af3693bbe 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -937,6 +937,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, receiver, strict, + element_offset, .. } => { // Value records' check mode (DESIGN-p4l4 §10.1): a store into a copy. @@ -1027,7 +1028,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object: target.clone(), index: key.clone(), value: value.clone(), - element_offset: 0, + // The store's own site: its key facts apply + // (`index_key::store_key_fact`). An at-rest store + // was taken above (`rest_elems::write_floor`). + element_offset: *element_offset, byte_offset: 0, byte_end: 0, }, diff --git a/crates/perry-codegen/src/expr/rest_elems.rs b/crates/perry-codegen/src/expr/rest_elems.rs index 761b726643..3a23d46f27 100644 --- a/crates/perry-codegen/src/expr/rest_elems.rs +++ b/crates/perry-codegen/src/expr/rest_elems.rs @@ -346,7 +346,14 @@ pub(crate) fn lower_read(ctx: &mut FnCtx<'_>, e: &Expr, floor: u32, want: Want) unreachable!("an at-rest read is an IndexGet") }; let (object, index) = (object.as_ref(), index.as_ref()); - if tiers_enabled() && !ctx.wpfacts_off && int32_key(ctx, index) { + // Key signs: a key only the facts prove an int32 (`index_key::key_fact`) + // is lowered as a double and converted exactly. + let fact = if int32_key(ctx, index) { + None + } else { + super::index_key::key_fact(ctx, *element_offset) + }; + if tiers_enabled() && !ctx.wpfacts_off && (fact.is_some() || int32_key(ctx, index)) { // A b1 call's synthesized read carries the body module's offset: the // key facts here are this module's, so only the call's own verdicts // (`lower_call::field_read_call`) bound its key. @@ -369,7 +376,13 @@ pub(crate) fn lower_read(ctx: &mut FnCtx<'_>, e: &Expr, floor: u32, want: Want) ctx, &[object], &[index], - |ctx| super::lower_expr_as_i32(ctx, index), + |ctx| match fact { + Some(fact) => { + let key = super::lower_expr(ctx, index)?; + Ok(super::index_key::exact_i32(ctx, &key, fact)) + } + None => super::lower_expr_as_i32(ctx, index), + }, |ctx, v, idx| { Ok(match rep { Some(read) => { diff --git a/crates/perry-codegen/src/verify_facts/coverage.rs b/crates/perry-codegen/src/verify_facts/coverage.rs index 0c730540a1..8c38b9c799 100644 --- a/crates/perry-codegen/src/verify_facts/coverage.rs +++ b/crates/perry-codegen/src/verify_facts/coverage.rs @@ -49,6 +49,7 @@ pub(crate) enum Fact { FieldRep, UnsignedBound, ZeroSign, + KeySign, } impl Fact { pub(super) fn name(self) -> &'static str { @@ -97,6 +98,7 @@ impl Fact { Self::FieldRep => "field rep", Self::UnsignedBound => "unsigned bound", Self::ZeroSign => "zero sign", + Self::KeySign => "key sign", } } } diff --git a/crates/perry-wpfacts/src/bindings.rs b/crates/perry-wpfacts/src/bindings.rs index 2f7e0709e0..1db231c655 100644 --- a/crates/perry-wpfacts/src/bindings.rs +++ b/crates/perry-wpfacts/src/bindings.rs @@ -517,6 +517,11 @@ pub struct ModuleBindings { /// Number at every copy, with the hull of its interval; sorted /// (`element_keys`). pub element_keys: Vec<(u32, (i64, i64))>, + /// Element sites (reads, stores and called elements) by + /// `element_offset` whose key is an integer Number inside the int32 + /// range at every copy, with the hull of its interval; sorted + /// (`element_keys::collect_signs`). + pub key_signs: Vec<(u32, (i64, i64))>, /// I32 at rest, PR B (`rest_elems`): element reads and element stores /// by `element_offset` whose every receiver holds only Arrays of at-rest /// templates, with the least floor of those Arrays; the `fill` call @@ -916,6 +921,7 @@ pub(crate) fn rep_probe(g: &Gen) -> Vec { } out.extend(rest_probe(m)); out.extend(crate::rest_elems::probe(m)); + out.extend(crate::element_keys::probe(g)); out } diff --git a/crates/perry-wpfacts/src/element_keys.rs b/crates/perry-wpfacts/src/element_keys.rs index d5633f0df2..bc9068ad7f 100644 --- a/crates/perry-wpfacts/src/element_keys.rs +++ b/crates/perry-wpfacts/src/element_keys.rs @@ -14,6 +14,21 @@ //! //! Exported with `Model::array_reps` on only (`PERRY_FACTS_ARRAY_REPS=0` //! exports nothing). +//! +//! **Key signs** (`collect_signs`, `ModuleBindings::key_signs`). An element +//! site of any operation (a read, a store, a called element) whose key is an +//! integer Number inside the int32 range at every copy, and at every site +//! that shares its offset, with the hull of its interval. Only the key +//! node's own interval counts: a key the solve resolves to the name `"3"` +//! may be that string at run time. Such a key is its `i32` exactly: the site needs no key normalization (a fraction, NaN, a +//! string or a symbol never reaches it). With `lo >= 0` it is an array +//! index, so the site needs no sign test and no property-key arm either. +//! The hull is over reads, stores and calls alike, so a store that shares +//! its offset with a read (`a[i] += 1`) has one verdict. +//! `PERRY_FACTS_KEY_SIGN=0` (`off`, `false`) exports none. The negative +//! control `PERRY_FACTS_SABOTAGE=keysign` claims every int32 key +//! non-negative; the check-mode witness at the site (`perry-codegen` +//! `expr::index_key::witness_sign`) must catch it. use crate::bindings::{number_range, ModuleBindings}; use crate::gen::Gen; @@ -32,11 +47,92 @@ fn copy_key(g: &Gen<'_>, key: Key, idx: NodeId) -> Option<(i64, i64)> { } } +/// `PERRY_FACTS_KEY_SIGN=0` turns the key signs off. +pub(crate) fn sign_enabled() -> bool { + !matches!( + std::env::var("PERRY_FACTS_KEY_SIGN").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +/// `(module, offset)` -> the hull of the key over every copy of every site +/// there that `admit` takes, or `None` once a copy has no interval. +type Hulls = std::collections::BTreeMap<(u32, u32), Option<(i64, i64)>>; + /// Fill `ModuleBindings::element_keys` (module docs). pub(crate) fn collect(g: &Gen<'_>, bindings: &mut [ModuleBindings]) { if g.m.aborted || !g.m.array_reps { return; } + let read = |op: crate::derive::ElementOp| op == crate::derive::ElementOp::Read; + for ((mi, offset), k) in hulls(g, read, true) { + if let (Some(k), Some(mb)) = (k, bindings.get_mut(mi as usize)) { + mb.element_keys.push((offset, k)); + } + } +} + +/// The key `k` is inside the int32 range. +fn int32(k: (i64, i64)) -> bool { + k.0 >= i64::from(i32::MIN) && k.1 <= i64::from(i32::MAX) +} + +/// Fill `ModuleBindings::key_signs` (module docs). +pub(crate) fn collect_signs(g: &Gen<'_>, bindings: &mut [ModuleBindings]) { + if g.m.aborted || !g.m.key_sign { + return; + } + for ((mi, offset), k) in hulls(g, |_| true, false) { + let Some(k) = k.filter(|&k| int32(k)) else { + continue; + }; + let k = if g.m.key_sign_sabotage { + (0, k.1.max(0)) + } else { + k + }; + if let Some(mb) = bindings.get_mut(mi as usize) { + mb.key_signs.push((offset, k)); + } + } +} + +/// The probe (`PERRY_FACTS_DUMP`): one `# key_sign` line per element site +/// whose key is an integer Number at every copy, with its interval and the +/// verdicts (`int32`: no normalization; `nonneg`: no sign test). +pub(crate) fn probe(g: &Gen<'_>) -> Vec { + if !g.m.key_sign { + return Vec::new(); + } + let mut ops: std::collections::BTreeMap<(u32, u32), (bool, bool)> = Default::default(); + for site in &g.element_sites { + let e = ops.entry((site.module, site.byte_offset)).or_default(); + match site.op { + crate::derive::ElementOp::Write => e.1 = true, + _ => e.0 = true, + } + } + hulls(g, |_| true, false) + .into_iter() + .filter_map(|((mi, offset), k)| { + let (lo, hi) = k?; + let (r, w) = ops.get(&(mi, offset)).copied().unwrap_or_default(); + Some(format!( + "# key_sign module={mi} site={offset} op={}{} key=[{lo},{hi}] int32={} nonneg={}", + if r { "r" } else { "" }, + if w { "w" } else { "" }, + int32((lo, hi)) as u8, + (int32((lo, hi)) && lo >= 0) as u8 + )) + }) + .collect() +} + +/// The key hulls of the sites whose operation `admit` takes. `named`: a +/// canonical index literal counts as its index (`copy_key`); otherwise only +/// the key node's own interval does, so a key that is the string `"3"` at +/// run time has none. +fn hulls(g: &Gen<'_>, admit: impl Fn(crate::derive::ElementOp) -> bool, named: bool) -> Hulls { // A summarized body's key node has one copy per instance (`Inst::fresh`). let mut copies: std::collections::HashMap> = Default::default(); for inst in 0..g.m.sum.insts.len() as u32 { @@ -45,17 +141,21 @@ pub(crate) fn collect(g: &Gen<'_>, bindings: &mut [ModuleBindings]) { }); } // (module, offset) -> Some(hull), or None once a copy has no interval. - let mut sites: std::collections::BTreeMap<(u32, u32), Option<(i64, i64)>> = Default::default(); + let mut sites: Hulls = Default::default(); let hull = |a: Option<(i64, i64)>, b: Option<(i64, i64)>| match (a, b) { (Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))), _ => None, }; for site in &g.element_sites { - if site.op != crate::derive::ElementOp::Read || site.byte_offset == 0 { + if !admit(site.op) || site.byte_offset == 0 { continue; } - let mut k = copy_key(g, site.key, site.idx); - if matches!(site.key, Key::Any(_)) { + let mut k = if named { + copy_key(g, site.key, site.idx) + } else { + number_range(&g.m, site.idx) + }; + if !named || matches!(site.key, Key::Any(_)) { for &c in copies.get(&site.idx).into_iter().flatten() { k = hull(k, number_range(&g.m, c)); } @@ -65,9 +165,5 @@ pub(crate) fn collect(g: &Gen<'_>, bindings: &mut [ModuleBindings]) { .and_modify(|e| *e = hull(*e, k)) .or_insert(k); } - for ((mi, offset), k) in sites { - if let (Some(k), Some(mb)) = (k, bindings.get_mut(mi as usize)) { - mb.element_keys.push((offset, k)); - } - } + sites } diff --git a/crates/perry-wpfacts/src/lib.rs b/crates/perry-wpfacts/src/lib.rs index 1c08c6500d..079ac88596 100644 --- a/crates/perry-wpfacts/src/lib.rs +++ b/crates/perry-wpfacts/src/lib.rs @@ -530,6 +530,8 @@ fn generate<'a>( g.m.rest_capacity = rest_elems::capacity_enabled(); g.m.rest_elem_sabotage = cfg.sabotage.as_deref() == Some("restelem"); g.m.rest_fields = rest_fields::enabled(); + g.m.key_sign = element_keys::sign_enabled(); + g.m.key_sign_sabotage = cfg.sabotage.as_deref() == Some("keysign"); g.m.rest_lane_sabotage = cfg.sabotage.as_deref() == Some("restlane"); g.m.exact_rules = !cfg.no_exact_rules; g.m.exact_rule_sabotage = cfg.sabotage.as_deref() == Some("exactrule"); @@ -1036,6 +1038,7 @@ fn compute_act<'a>( } const_arrays::collect(&g, &mut bindings); element_keys::collect(&g, &mut bindings); + element_keys::collect_signs(&g, &mut bindings); if !g.m.aborted && signatures::enabled() { signatures::collect( &g, diff --git a/crates/perry-wpfacts/src/model.rs b/crates/perry-wpfacts/src/model.rs index a7b4a8f409..aede998e35 100644 --- a/crates/perry-wpfacts/src/model.rs +++ b/crates/perry-wpfacts/src/model.rs @@ -1405,6 +1405,13 @@ pub struct Model { pub rest_capacity: bool, /// I32 at rest, PR C (`rest_fields`; `PERRY_REST_FIELDS=0` turns it off). pub rest_fields: bool, + /// The key signs (`element_keys::collect_signs`; + /// `PERRY_FACTS_KEY_SIGN=0` turns them off). + pub key_sign: bool, + /// Negative control (`PERRY_FACTS_SABOTAGE=keysign`): every int32 key + /// is claimed non-negative, a wrong claim the witness at the element + /// site must catch. + pub key_sign_sabotage: bool, /// Negative control (`PERRY_FACTS_SABOTAGE=restlane`): a field whose /// rep is F64 is admitted as an I32 lane too (`rest_fields`), a wrong /// claim the proven store's witness must catch. diff --git a/crates/perry-wpfacts/src/tests.rs b/crates/perry-wpfacts/src/tests.rs index 059fae0d45..e5cd2f5bbd 100644 --- a/crates/perry-wpfacts/src/tests.rs +++ b/crates/perry-wpfacts/src/tests.rs @@ -153,6 +153,7 @@ mod proto_lock_keys; mod proto_value; mod protocol_calls; mod protocol_keys; +mod key_signs; mod range_tightening; mod reach_accessor; mod receiver_slots; diff --git a/crates/perry-wpfacts/src/tests/key_signs.rs b/crates/perry-wpfacts/src/tests/key_signs.rs new file mode 100644 index 0000000000..98d0d2a988 --- /dev/null +++ b/crates/perry-wpfacts/src/tests/key_signs.rs @@ -0,0 +1,112 @@ +//! Key signs (`element_keys::collect_signs`): the int32 key interval of an +//! element site of any operation, joined over every copy and every site at +//! its offset. + +use super::*; +use crate::FactsConfig; + +/// The key interval of the element site whose object starts at `at` +/// (`ModuleBindings::key_signs`; SWC offsets are 1-based). +fn sign_at(src: &str, at: &str, cfg: FactsConfig) -> Option<(i64, i64)> { + let offset = src.find(at).expect("site in source") as u32 + 1; + let pf = facts_with(src, cfg); + let mb = pf.bindings_for("t.ts").expect("module bindings"); + mb.key_signs + .iter() + .find(|(o, _)| *o == offset) + .map(|(_, k)| *k) +} + +const TABLE: &str = "const K = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];\n"; + +#[test] +fn a_read_and_a_store_export_their_int32_key_interval() { + let src = format!( + "{TABLE}export function f(i: number): number {{ return K[i & 7] ?? 0; }}\n\ + console.log(f(3) + f(12));" + ); + assert_eq!( + sign_at(&src, "K[i & 7]", FactsConfig::default()), + Some((0, 7)) + ); + // A store has a key sign too (`element_keys` covers reads only). + let src = "const xs: number[] = [0, 0, 0, 0];\n\ + export function put(i: number, v: number): void { xs[i & 3] = v; }\n\ + put(1, 2); put(6, 3); console.log(xs[1]);"; + assert_eq!( + sign_at(src, "xs[i & 3]", FactsConfig::default()), + Some((0, 3)) + ); +} + +#[test] +fn a_key_that_may_be_negative_keeps_its_lower_bound() { + // The parameter joins every caller's argument: an int32, not an index. + let src = format!( + "{TABLE}function at(i: number): number {{ return K[i] ?? 0; }}\n\ + console.log(at(2) + at(-1) + at(11));" + ); + assert_eq!( + sign_at(&src, "K[i]", FactsConfig::default()), + Some((-1, 11)) + ); +} + +#[test] +fn a_key_that_is_no_int32_has_no_sign() { + // A fraction. + let src = format!( + "{TABLE}export function f(i: number): number {{ return K[i / 2] ?? 0; }}\n\ + console.log(f(3));" + ); + assert_eq!(sign_at(&src, "K[i / 2]", FactsConfig::default()), None); + // A string. + let src = format!( + "{TABLE}export function f(k: string): unknown {{ return (K as any)[k]; }}\n\ + console.log(f(\"length\"));" + ); + assert_eq!(sign_at(&src, "(K as any)[k]", FactsConfig::default()), None); + // A string that names an index: the key is no Number at run time. + let src = format!( + "{TABLE}const k: any = \"3\";\n\ + export function f(): unknown {{ return (K as any)[k]; }}\n\ + console.log(f());" + ); + assert_eq!(sign_at(&src, "(K as any)[k]", FactsConfig::default()), None); + // An integer past the int32 range. + let src = format!( + "{TABLE}export function f(i: number): number {{ return K[i >>> 0] ?? 0; }}\n\ + console.log(f(3));" + ); + assert_eq!(sign_at(&src, "K[i >>> 0]", FactsConfig::default()), None); + // A Number with no interval inside int32. + let src = format!( + "{TABLE}export function f(i: number): number {{ return K[i] ?? 0; }}\n\ + console.log(f(Number(process.argv[2])));" + ); + assert_eq!(sign_at(&src, "K[i]", FactsConfig::default()), None); +} + +#[test] +fn a_compound_store_has_one_hull_with_its_read() { + let src = "const xs: number[] = [0, 0, 0, 0];\n\ + export function bump(i: number): void { xs[i & 3] += 1; }\n\ + bump(1); bump(6); console.log(xs[1]);"; + assert_eq!( + sign_at(src, "xs[i & 3]", FactsConfig::default()), + Some((0, 3)) + ); +} + +#[test] +fn the_sabotage_claims_every_int32_key_non_negative() { + let src = format!( + "{TABLE}function at(i: number): number {{ return K[i] ?? 0; }}\n\ + console.log(at(2) + at(-1) + at(11));" + ); + let cfg = FactsConfig { + sabotage: Some("keysign".to_string()), + ..Default::default() + }; + assert_eq!(sign_at(&src, "K[i]", cfg), Some((0, 11))); +} diff --git a/crates/perry/src/commands/compile/compile_env.rs b/crates/perry/src/commands/compile/compile_env.rs index 1afaac2795..919206de99 100644 --- a/crates/perry/src/commands/compile/compile_env.rs +++ b/crates/perry/src/commands/compile/compile_env.rs @@ -66,6 +66,7 @@ pub(super) const COMPILE_ENV_VARS: &[&str] = &[ "PERRY_FACTS_INT32_LOCALS", "PERRY_FACTS_INT_RANGES", "PERRY_FACTS_INT_DEMAND", + "PERRY_FACTS_KEY_SIGN", "PERRY_FACTS_COMPARE_DEMAND", "PERRY_FACTS_AT_REST", "PERRY_REST_ELEMS", diff --git a/crates/perry/src/commands/compile/compile_env/tests.rs b/crates/perry/src/commands/compile/compile_env/tests.rs index dbd5756bd1..c9e801cbd6 100644 --- a/crates/perry/src/commands/compile/compile_env/tests.rs +++ b/crates/perry/src/commands/compile/compile_env/tests.rs @@ -374,6 +374,16 @@ fn unsigned_bound_switch_is_a_cache_input() { ); } +/// Key signs: the switch changes the exported facts and every element site +/// that consumes its key's interval, so `=0` must never reuse an `=1` object. +#[test] +fn key_sign_switch_is_a_cache_input() { + assert!( + COMPILE_ENV_VARS.contains(&"PERRY_FACTS_KEY_SIGN"), + "PERRY_FACTS_KEY_SIGN must key the compile caches" + ); +} + /// 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. diff --git a/crates/perry/src/commands/compile/program_facts.rs b/crates/perry/src/commands/compile/program_facts.rs index 2fb428e1a5..645be4d19a 100644 --- a/crates/perry/src/commands/compile/program_facts.rs +++ b/crates/perry/src/commands/compile/program_facts.rs @@ -584,6 +584,7 @@ fn elision_for(pf: &perry_wpfacts::ProgramFacts, path: &str) -> perry_codegen::E out.element_floors.extend(mb.element_floors.iter().copied()); out.const_numbers.extend(mb.const_numbers.iter().copied()); out.element_keys.extend(mb.element_keys.iter().copied()); + out.key_signs.extend(mb.key_signs.iter().copied()); out.rest_reads.extend(mb.rest_reads.iter().copied()); out.rest_writes.extend(mb.rest_writes.iter().copied()); out.rest_births.extend(mb.rest_births.iter().copied()); diff --git a/scripts/test-verify-facts-s0.py b/scripts/test-verify-facts-s0.py index 9273ed54d7..58a003f866 100644 --- a/scripts/test-verify-facts-s0.py +++ b/scripts/test-verify-facts-s0.py @@ -127,6 +127,7 @@ def oracle(source, *argv): ("array-base-admit-negative", "arrbaseadm", "array rep"), ("fn-base-admit-negative", "fnbaseadm", "array rep"), ("unsigned-bound-negative", "unsignedbound", "unsigned bound"), + ("key-sign-negative", "keysign", "key sign"), ("number-call-negative", "numbercall", "numeric result"), ("field-read-call-negative", "fieldreadcall", "field read call"), ("array-undef-negative", "arrundef", "element floor"), diff --git a/scripts/verify_facts_s0/key-sign-negative.ts b/scripts/verify_facts_s0/key-sign-negative.ts new file mode 100644 index 0000000000..b5828eda79 --- /dev/null +++ b/scripts/verify_facts_s0/key-sign-negative.ts @@ -0,0 +1,17 @@ +// Key signs: `xs[this.k]` reads with a key the facts bound to [-2, 1]: an +// int32, so the read takes no normalizer, but it may be negative, so it +// keeps its sign test and -2 reads the named property (undefined here). +// Under PERRY_FACTS_SABOTAGE=keysign every int32 key is claimed +// non-negative: the read would enter the element tier with -2. The +// check-mode witness at the site must report it ("key sign"). +class Holder { + k: number = process.argv.length > 100 ? 1 : -2; + xs: number[] = [4, 5, 6]; + get(): number { + return this.xs[this.k] ?? 7; + } +} +const h = new Holder(); +let s = 0; +for (let i = 0; i < 3; i++) s += h.get(); +console.log(s); diff --git a/tests/parity/language/array/test_key_sign.ts b/tests/parity/language/array/test_key_sign.ts new file mode 100644 index 0000000000..899db92415 --- /dev/null +++ b/tests/parity/language/array/test_key_sign.ts @@ -0,0 +1,197 @@ +// Key signs: an element read or store whose key the whole-program facts +// prove an integer inside the int32 range takes no key normalizer, and one +// whose key is proven non-negative takes no sign test and has no +// property-key arm. Every key below must name what it names in JavaScript. +const many = process.argv.length > 100; + +// (a) Keys the facts bound below at 0: a parameter joined over its callers, +// a class field, an element of an int Array, a sum of those. +function pick(xs: number[], k: number): number { + return xs[k] ?? -1; +} +const base: number[] = [10, 20, 30, 40, 50, 60, 70, 80]; +let sa = 0; +for (let i = 0; i < 8; i++) sa += pick(base, i); +sa += pick(base, many ? 3 : 5) + pick(base, 9) + pick(base, 200); +console.log("param", sa); + +class Cursor { + at: number = many ? 6 : 2; + step: number = many ? 4 : 1; + cells: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + read(): number { + return this.cells[this.at] ?? -1; + } + ahead(): number { + return this.cells[this.at + this.step * 3] ?? -1; + } + far(): any { + return this.cells[this.at + 40]; + } + put(v: number): void { + this.cells[this.at + this.step] = v; + } + grow(v: number): number { + this.cells[this.at + 9] = v; + return this.cells.length; + } +} +const c = new Cursor(); +c.put(33); +console.log("field", c.read(), c.ahead(), c.far(), c.cells[3], c.grow(77), c.cells[11], c.cells[10]); + +const order: number[] = [3, 0, 2, 1, 3, 3, 0]; +const values: any[] = ["a", "b", "c", "d"]; +let chain = ""; +for (let i = 0; i < order.length; i++) chain += values[order[i] ?? 0]; +console.log("element key", chain); + +// (b) Negative controls: a key that can be -1 names a property. +const named: any = [1, 2, 3]; +named[-1] = "minus one"; +named[-2147483648] = "min"; +function probe(a: any, k: number): any { + return a[k]; +} +let pk = ""; +for (let k = -1; k < 4; k++) pk += String(probe(named, k)) + ","; +console.log("can be -1", pk, probe(named, -2147483648), probe(base, -1), probe(base, -5)); + +class Signed { + k: number = many ? 2 : -1; + xs: number[] = [7, 8, 9]; + get(): any { + return this.xs[this.k]; + } + set(v: number): number { + this.xs[this.k] = v; + return this.xs.length; + } +} +const sg = new Signed(); +console.log("signed field", sg.get(), sg.set(5), sg.get(), Object.keys(sg.xs).join(","), sg.xs.join(",")); + +// (c) A key that is non-negative only behind a guard a `continue` leaves. +const deltas: number[] = [-3, 1, -1, 2, 0, -9, 4]; +let guarded = 0; +let skipped = 0; +for (let i = 0; i < deltas.length; i++) { + const k = deltas[i] ?? 0; + if (k < 0) { + skipped++; + continue; + } + guarded += base[k] ?? -1; +} +let unguarded = ""; +for (let i = 0; i < deltas.length; i++) { + const k = deltas[i] ?? 0; + unguarded += String(base[k]) + ","; +} +console.log("guard", guarded, skipped, unguarded); + +// (d) A write in the guard's branch: the key may be negative after it. +let w = 0; +for (let i = 0; i < 6; i++) { + let k = i - 2; + if (k < 0) { + k = k * 2; + } + w += base[k] ?? 100; +} +console.log("write in branch", w); + +// (e) -0 is element 0; a fraction and NaN are named properties; 2^31 is an +// index past the end. +const zero = (many ? 3 : -4) * 0; +const half = (many ? 3 : 1) / 2; +const big = many ? 7 : 2147483648; +const notNumber = Number(many ? "1" : "x"); +named["0.5"] = "half"; +named["NaN"] = "nan"; +console.log("edge", named[zero], Object.is(zero, -0), named[half], named[notNumber], named[big], base[zero]); +named[zero] = "first"; +named[half] = "h2"; +console.log("edge store", named[0], named["0.5"], named[big], named.length); + +// (e2) A string that names an index is a key, not a Number: it reads and +// writes the element, and a string that names none reads the property. +const two: any = "2"; +const sk: any = many ? "length" : "1"; +const strs: any[] = ["s0", "s1", "s2"]; +function byKey(xs: any, k: any): any { + return xs[k]; +} +function putKey(xs: any, k: any, v: any): void { + xs[k] = v; +} +putKey(strs, two, "S2"); +putKey(strs, "1", "S1"); +console.log("string key", byKey(strs, two), byKey(strs, sk), byKey(strs, "0"), byKey(strs, "length"), strs[two], strs[sk]); + +// (f) `at` counts a negative index from the end; untouched. +function atOf(xs: number[], k: number): any { + return xs.at(k); +} +console.log("at", atOf(base, -1), atOf(base, 0), atOf(base, 7), atOf(base, -8), atOf(base, -9), atOf(base, 8)); + +// (g) Typed arrays: an integer key outside the view is undefined, a store +// there does nothing; untouched. +const ta = new Int32Array([5, 6, 7, 8]); +function taGet(t: Int32Array, k: number): any { + return t[k]; +} +function taSet(t: Int32Array, k: number, v: number): void { + t[k] = v; +} +taSet(ta, -1, 99); +taSet(ta, 2, 70); +taSet(ta, 4, 99); +console.log("typed", taGet(ta, -1), taGet(ta, 0), taGet(ta, 2), taGet(ta, 3), taGet(ta, 4), ta.length); + +// (h) Stores: a proven index past the length grows the Array with holes; a +// key that can be negative makes a property and leaves the length. +function put(xs: any[], k: number, v: any): number { + xs[k] = v; + return xs.length; +} +const grown: any[] = [1, 2]; +console.log("store", put(grown, 1, "b"), put(grown, 5, "f"), grown[3], grown[5], 3 in grown); +const props: any[] = [1, 2]; +function putAny(xs: any[], k: number, v: any): number { + xs[k] = v; + return xs.length; +} +console.log("store neg", putAny(props, -1, "n"), putAny(props, 0, "z"), props[-1], props[0], Object.keys(props).join(",")); + +// (i) An at-rest int Array (its elements have int consumers only) read by +// a key only the facts bound: in the floor, across it and past it. +class Ring { + slots: number[] = new Array(16).fill(0); + head: number = many ? 9 : 4; + load(): void { + for (let i = 0; i < 16; i++) this.slots[i] = (i * 3) | 0; + } + peek(): number { + return (this.slots[this.head] ?? -1) | 0; + } + next(): number { + return (this.slots[this.head + 11] ?? -1) | 0; + } + past(): number { + return (this.slots[this.head + 12] ?? -1) | 0; + } +} +const ring = new Ring(); +ring.load(); +console.log("at rest", ring.peek(), ring.next(), ring.past()); + +// (j) A loop of reads through each form, for the GC schedule arm. +let total = 0; +for (let round = 0; round < 2000; round++) { + const fresh: number[] = [round, round + 1, round + 2, round + 3, round + 4, round + 5, round + 6, round + 7]; + total += pick(fresh, round & 7) + c.read() + ring.peek() + (probe(fresh, (round % 9) - 1) ?? 1000); + const boxed: any[] = [{ v: round }, { v: 1 }]; + total += boxed[order[round % 7] & 1].v; +} +console.log("loop", total);