From 151a7adc98fb1c7ab97c9438f1ce317472e3b631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 05:01:03 +0100 Subject: [PATCH 1/8] runtime/codegen/ffi: one exact int32-Number predicate; a non-Number 0x7FFE word (C.prototype ref) is never read as an int32 --- changelog.d/runtime-exact-int32-predicate.md | 1 + crates/perry-codegen/src/codegen/mod.rs | 8 ++-- crates/perry-codegen/src/codegen/typed_abi.rs | 16 ++------ .../perry-codegen/src/codegen/typed_entry.rs | 9 ++++- crates/perry-codegen/src/expr/compare.rs | 23 +++-------- .../perry-codegen/src/expr/compare_chain.rs | 5 +-- .../perry-codegen/src/expr/compare_tests.rs | 13 +++++- crates/perry-codegen/src/expr/index_key.rs | 3 +- .../property_get/builtin_kind_guard.rs | 2 +- crates/perry-codegen/src/stmt/switch_stmt.rs | 11 +---- crates/perry-ffi/src/jsvalue.rs | 11 ++++- crates/perry-runtime/src/array/generic.rs | 2 +- .../perry-runtime/src/array/indexing_keyed.rs | 6 +-- .../src/array/indexing_read_cold.rs | 2 +- crates/perry-runtime/src/buffer/cmp.rs | 4 +- .../src/byte_cell_support/mod.rs | 2 +- .../src/closure/dispatch/value_call.rs | 3 +- crates/perry-runtime/src/contracts.rs | 16 +++++++- crates/perry-runtime/src/date.rs | 4 +- crates/perry-runtime/src/date/tests.rs | 16 ++++++++ crates/perry-runtime/src/gc/barrier_store.rs | 2 +- crates/perry-runtime/src/gc/census.rs | 14 ++++++- .../perry-runtime/src/gc/census_field_repr.rs | 9 ++++- .../perry-runtime/src/json/stringify_flat.rs | 2 +- .../src/json/stringify_scalars.rs | 2 +- .../src/node_submodules/diagnostics_tail.rs | 2 +- .../src/object/buffer_dispatch.rs | 4 +- .../object/field_get_set/runtime_read_site.rs | 2 +- .../src/object/native_module_dispatch.rs | 2 +- .../src/object/object_ops/define_property.rs | 5 ++- .../src/object/polymorphic_index.rs | 14 ++++--- .../polymorphic_index_int32_exact_tests.rs | 16 ++++++++ crates/perry-runtime/src/pty/mod.rs | 2 +- crates/perry-runtime/src/thread.rs | 10 ++++- .../src/thread_int32_exact_tests.rs | 10 +++++ crates/perry-runtime/src/typed_feedback.rs | 8 ++-- .../perry-runtime/src/typed_feedback/tests.rs | 20 ++++++++++ .../perry-runtime/src/typedarray/construct.rs | 2 +- crates/perry-runtime/src/value/dyn_index.rs | 4 +- .../src/value/dyn_index_uint8array_tests.rs | 24 +++++++++++ .../src/value/int32_exact_tests.rs | 40 +++++++++++++++++++ crates/perry-runtime/src/value/jsvalue.rs | 2 +- crates/perry-runtime/src/value/mod.rs | 5 ++- crates/perry-runtime/src/value/tags.rs | 10 +++++ crates/perry-runtime/src/value/truthy.rs | 6 ++- crates/perry-runtime/src/wpfacts_check.rs | 16 +++++++- 46 files changed, 293 insertions(+), 97 deletions(-) create mode 100644 changelog.d/runtime-exact-int32-predicate.md create mode 100644 crates/perry-runtime/src/object/polymorphic_index_int32_exact_tests.rs create mode 100644 crates/perry-runtime/src/thread_int32_exact_tests.rs create mode 100644 crates/perry-runtime/src/value/int32_exact_tests.rs diff --git a/changelog.d/runtime-exact-int32-predicate.md b/changelog.d/runtime-exact-int32-predicate.md new file mode 100644 index 00000000000..14c25b15ede --- /dev/null +++ b/changelog.d/runtime-exact-int32-predicate.md @@ -0,0 +1 @@ +Fix: every "is this an int32 Number?" test in the runtime, the generated code and perry-ffi now uses one exact predicate (tag `0x7FFE` with bits 32..47 clear: `value::is_int32_bits`, emitted by `codegen::emit_int32_bits_test`). About 30 sites tested only the top 16 bits, so they read a `C.prototype` reference word (and any other `0x7FFE` word with high payload bits) as an int32 Number whose value is its class id. The affected paths were dynamic and keyed array indexing, typed-feedback and polymorphic index reads, Buffer and typed-array indexing, truthiness (a prototype reference of class id 0 read as falsy), JSON stringification, Date number conversion, the barrier's raw-slot canonicalization, the contract and check-mode kind classifiers, the GC census, and the generated strict and relational comparisons against a Number, the compare chains and the dynamic index-key normalizer. A non-Number `0x7FFE` word is now an object at each of these sites. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 200943e6621..175137312d3 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -312,8 +312,8 @@ mod opts; mod program_facts_view; pub use program_facts_view::{ ElisionFacts, FactsBody, FactsClass, FactsElementRead, FactsField, FactsFieldReadCall, - FactsIndexedOwner, - FactsNumericExpr, FactsNumericFunction, FactsRecv, FactsRep, ProgramFactsView, + FactsIndexedOwner, FactsNumericExpr, FactsNumericFunction, FactsRecv, FactsRep, + ProgramFactsView, }; #[cfg(test)] mod ordinary_param_guard_tests; @@ -360,8 +360,8 @@ mod typed_abi; mod typed_abi_opt_report; mod typed_entry; pub(crate) use typed_entry::{ - emit_int32_box_test, emit_ordered_number_test, emit_plain_number_bits_test, - emit_plain_number_test, typed_entry_arg_guard, + emit_int32_bits_test, emit_int32_box_test, emit_ordered_number_test, + emit_plain_number_bits_test, emit_plain_number_test, typed_entry_arg_guard, }; #[cfg(test)] mod unknown_func_tests; diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 10cdf40ea0c..e8b9b14665a 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -103,9 +103,7 @@ pub(crate) fn emit_typed_i32_guard_and_raw( use crate::types::{I1, I32, I64}; let bits = blk.bitcast_double_to_i64(value); - let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); - let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); - let is_tagged = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + let is_tagged = super::emit_int32_bits_test(blk, &bits); let (plain_ok, plain_raw) = emit_typed_i32_plain_guard_and_raw(blk, value, &bits); let admitted = blk.or(I1, &is_tagged, &plain_ok); @@ -125,9 +123,7 @@ pub(crate) fn emit_typed_i32_raw_assuming_guarded( use crate::types::{DOUBLE, I1, I32, I64}; let bits = blk.bitcast_double_to_i64(value); - let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); - let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); - let is_tagged = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + let is_tagged = super::emit_int32_bits_test(blk, &bits); let safe_plain = blk.select(I1, &is_tagged, DOUBLE, "0.0", value); let plain_raw = blk.fptosi(DOUBLE, &safe_plain, I32); let tagged_raw = blk.trunc(I64, &bits, I32); @@ -292,9 +288,7 @@ pub(crate) fn emit_typed_f64_guard(blk: &mut crate::block::LlBlock, arg: &str) - &crate::nanbox::i64_literal(crate::nanbox::SHORT_STRING_TAG), ); // JSValue::is_int32: the INT32 tag with any payload. - let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); - let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); - let is_int32 = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + let is_int32 = super::emit_int32_bits_test(blk, &bits); blk.or(I1, &is_plain_number, &is_int32) } @@ -305,9 +299,7 @@ pub(crate) fn emit_typed_f64_guard(blk: &mut crate::block::LlBlock, arg: &str) - pub(crate) fn emit_typed_f64_to_raw_guarded(blk: &mut crate::block::LlBlock, arg: &str) -> String { use crate::types::{DOUBLE, I1, I32, I64}; let bits = blk.bitcast_double_to_i64(arg); - let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); - let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); - let is_int32 = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + let is_int32 = super::emit_int32_bits_test(blk, &bits); let low = blk.trunc(I64, &bits, I32); let converted = blk.sitofp(I32, &low, DOUBLE); blk.select(I1, &is_int32, DOUBLE, &converted, arg) diff --git a/crates/perry-codegen/src/codegen/typed_entry.rs b/crates/perry-codegen/src/codegen/typed_entry.rs index 16f7b004046..17c0f000120 100644 --- a/crates/perry-codegen/src/codegen/typed_entry.rs +++ b/crates/perry-codegen/src/codegen/typed_entry.rs @@ -68,8 +68,15 @@ pub(crate) fn emit_ordered_number_test(blk: &mut LlBlock, value: &str) -> String /// `JSValue::is_int32` — the INT32 tag with any 32-bit payload. pub(crate) fn emit_int32_box_test(blk: &mut LlBlock, value: &str) -> String { let bits = blk.bitcast_double_to_i64(value); + emit_int32_bits_test(blk, &bits) +} + +/// THE emitted int32-Number test on raw bits (`perry_runtime::value:: +/// is_int32_bits`): tag `0x7FFE` with bits 32..47 clear. A top-16-only test +/// reads a `C.prototype` ref or an internal marker as an int32 Number. +pub(crate) fn emit_int32_bits_test(blk: &mut LlBlock, bits: &str) -> String { let identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); - let identity = blk.and(I64, &bits, &identity_mask); + let identity = blk.and(I64, bits, &identity_mask); blk.icmp_eq(I64, &identity, crate::nanbox::INT32_TAG_I64) } diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 7fd8fb1b554..77b6210a3d6 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -766,12 +766,8 @@ fn lower_strict_eq_inline_any( .block() .icmp_eq(I64, &r_tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); let both_sso = ctx.block().and(I1, &l_sso, &r_sso); - let l_i32 = ctx - .block() - .icmp_eq(I64, &l_tag, crate::nanbox::INT32_TAG_TOP16_I64); - let r_i32 = ctx - .block() - .icmp_eq(I64, &r_tag, crate::nanbox::INT32_TAG_TOP16_I64); + let l_i32 = crate::codegen::emit_int32_bits_test(ctx.block(), &l_bits); + let r_i32 = crate::codegen::emit_int32_bits_test(ctx.block(), &r_bits); let both_i32 = ctx.block().and(I1, &l_i32, &r_i32); let canonical = ctx.block().or(I1, &both_sso, &both_i32); ctx.block().cond_br(&canonical, &false_l, &slow_l); @@ -826,10 +822,7 @@ fn is_plain_number_or_undefined(ctx: &FnCtx<'_>, expr: &Expr) -> bool { /// participate in the numeric comparison. fn normalize_int32_immediate(ctx: &mut FnCtx<'_>, value: &str) -> String { let bits = ctx.block().bitcast_double_to_i64(value); - let top16 = ctx.block().lshr(I64, &bits, "48"); - let is_i32 = ctx - .block() - .icmp_eq(I64, &top16, crate::nanbox::INT32_TAG_TOP16_I64); + let is_i32 = crate::codegen::emit_int32_bits_test(ctx.block(), &bits); let raw = ctx.block().trunc(I64, &bits, I32); let decoded = ctx.block().sitofp(I32, &raw, DOUBLE); ctx.block().select(I1, &is_i32, DOUBLE, &decoded, value) @@ -881,10 +874,7 @@ fn lower_strict_eq_against_number( let compact = if plain { None } else { - let tag = ctx.block().lshr(I64, &bits, "48"); - let is_i32 = ctx - .block() - .icmp_eq(I64, &tag, crate::nanbox::INT32_TAG_TOP16_I64); + let is_i32 = crate::codegen::emit_int32_bits_test(ctx.block(), &bits); let integer = ctx .block() .call(I32, "llvm.fptosi.sat.i32.f64", &[(DOUBLE, number)]); @@ -946,13 +936,10 @@ fn lower_relational_against_number( ) -> String { let dynamic = if dynamic_is_left { l } else { r }; let bits = ctx.block().bitcast_double_to_i64(dynamic); - let top16 = ctx.block().lshr(I64, &bits, "48"); // Use the same raw-Number admission as the fully dynamic comparison. // Negative IEEE values and canonical NaNs precede the positive tag band. let is_plain_number = emit_is_unboxed_number(ctx, &bits); - let is_i32 = ctx - .block() - .icmp_eq(I64, &top16, crate::nanbox::INT32_TAG_TOP16_I64); + let is_i32 = crate::codegen::emit_int32_bits_test(ctx.block(), &bits); // undefined/null/false/true are four consecutive immediate values. // A range decides admission without separately classifying each value. debug_assert_eq!(crate::nanbox::TAG_UNDEFINED + 3, crate::nanbox::TAG_TRUE); diff --git a/crates/perry-codegen/src/expr/compare_chain.rs b/crates/perry-codegen/src/expr/compare_chain.rs index 893022d9fb1..239d0a47444 100644 --- a/crates/perry-codegen/src/expr/compare_chain.rs +++ b/crates/perry-codegen/src/expr/compare_chain.rs @@ -221,10 +221,7 @@ pub(crate) fn try_lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result) { f.no_inline = true; let b = f.create_block("entry"); let bits = b.bitcast_double_to_i64("%key"); - let tag = b.lshr(I64, &bits, "48"); - let boxed = b.icmp_eq(I64, &tag, crate::nanbox::INT32_TAG_TOP16_I64); + let boxed = crate::codegen::emit_int32_bits_test(b, &bits); b.cond_br(&boxed, "boxed.1", "raw.2"); let b = f.create_block("boxed"); diff --git a/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs index 83525c2123a..31401fbce03 100644 --- a/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs +++ b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs @@ -566,7 +566,7 @@ fn emit_receiver_kind_branch( let band_offset = blk.sub(I64, &tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); let in_tag_band = blk.icmp_ult(I64, &band_offset, "7"); let is_double = blk.xor(I1, &in_tag_band, "true"); - let is_int32 = blk.icmp_eq(I64, &tag, crate::nanbox::INT32_TAG_TOP16_I64); + let is_int32 = crate::codegen::emit_int32_bits_test(blk, &bits); let is_number = blk.or(I1, &is_double, &is_int32); let is_number = blk.and(I1, &is_number, pristine); blk.cond_br(&is_number, builtin_label, generic_label); diff --git a/crates/perry-codegen/src/stmt/switch_stmt.rs b/crates/perry-codegen/src/stmt/switch_stmt.rs index 5848b6c68c6..74316a091a8 100644 --- a/crates/perry-codegen/src/stmt/switch_stmt.rs +++ b/crates/perry-codegen/src/stmt/switch_stmt.rs @@ -458,16 +458,7 @@ struct NumericView { /// strictly equal a number. fn emit_canonical_numeric_view(ctx: &mut FnCtx<'_>, dv: &str) -> NumericView { let bits = ctx.block().bitcast_double_to_i64(dv); - let int32_band = ctx.block().and( - I64, - &bits, - &crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK), - ); - let is_int32 = ctx.block().icmp_eq( - I64, - &int32_band, - &crate::nanbox::i64_literal(crate::nanbox::INT32_TAG), - ); + let is_int32 = crate::codegen::emit_int32_bits_test(ctx.block(), &bits); let lo32 = ctx.block().trunc(I64, &bits, I32); let int32_as_double = ctx.block().sitofp(I32, &lo32, DOUBLE); let canonical = ctx diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index 30617a87953..a11c202db27 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -263,7 +263,9 @@ impl JsValue { /// True if the value is a 32-bit integer. #[inline] pub const fn is_int32(self) -> bool { - (self.0 & TAG_MASK) == INT32_TAG + // Bits 32..47 clear as well: other `0x7FFE` words (a `C.prototype` + // ref, internal markers) are not Numbers. + (self.0 & !0x0000_0000_FFFF_FFFF) == INT32_TAG } /// True if the value is a number (real f64 OR int32). Returns @@ -662,7 +664,12 @@ mod tests { let v = JsValue::from_int32(n); // One Number encoding: the raw f64 bits, never an INT32 word. assert_eq!(v.bits(), (n as f64).to_bits(), "{n}"); - assert!(!v.is_int32(), "{} must not be an INT32 word, got {:?}", n, v); + assert!( + !v.is_int32(), + "{} must not be an INT32 word, got {:?}", + n, + v + ); assert_eq!(v.to_int32(), n); assert!(v.is_number()); assert_eq!(v.to_number(), n as f64); diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 478d02a25d8..41df14be50a 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -101,7 +101,7 @@ pub(super) fn to_object(recv: f64) -> f64 { // Numbers: real f64 values (`is_number`) and INT32-tagged small integers // (0x7FFE), which `is_number` excludes because the tag sits in the // string/special band. - if top16(b) == 0x7FFE || JSValue::from_bits(b).is_number() { + if crate::value::is_int32_bits(b) || JSValue::from_bits(b).is_number() { return crate::builtins::js_boxed_number_new(recv); } // BigInt boxes into a `BigInt` wrapper object (`ToObject`): no indexed diff --git a/crates/perry-runtime/src/array/indexing_keyed.rs b/crates/perry-runtime/src/array/indexing_keyed.rs index 091f39c8461..67262653db0 100644 --- a/crates/perry-runtime/src/array/indexing_keyed.rs +++ b/crates/perry-runtime/src/array/indexing_keyed.rs @@ -175,7 +175,7 @@ pub extern "C" fn js_array_get_index_or_string(arr: *const ArrayHeader, idx: f64 return array_get_property_by_key(arr, key); } - let numeric = if (bits & crate::value::TAG_MASK) == crate::value::INT32_TAG { + let numeric = if crate::value::is_int32_bits(bits) { Some(crate::value::JSValue::from_bits(bits).as_int32() as f64) } else if !(0x7FF8..=0x7FFF).contains(&top16) { Some(idx) @@ -278,7 +278,7 @@ pub extern "C" fn js_array_set_index_or_string( // Treat numeric keys according to the array-index boundary. Only // integers in 0..2^32-2 extend element storage; 2^32-1 and larger are // ordinary string properties. - let numeric = if (bits & crate::value::TAG_MASK) == crate::value::INT32_TAG { + let numeric = if crate::value::is_int32_bits(bits) { Some(crate::value::JSValue::from_bits(bits).as_int32() as f64) } else if !(0x7FF8..=0x7FFF).contains(&top16) { Some(idx) @@ -449,7 +449,7 @@ fn canonical_index_of_set_key(idx: f64) -> Option { return crate::object::canonical_array_index(name); } // Numeric key. - let n = if (bits & crate::value::TAG_MASK) == crate::value::INT32_TAG { + let n = if crate::value::is_int32_bits(bits) { crate::value::JSValue::from_bits(bits).as_int32() as f64 } else if !(0x7FF8..=0x7FFF).contains(&top16) { idx diff --git a/crates/perry-runtime/src/array/indexing_read_cold.rs b/crates/perry-runtime/src/array/indexing_read_cold.rs index b1b1d93e1c3..d62e915ad37 100644 --- a/crates/perry-runtime/src/array/indexing_read_cold.rs +++ b/crates/perry-runtime/src/array/indexing_read_cold.rs @@ -187,7 +187,7 @@ unsafe fn read_cold_dispatch( /// and arrays with named storage. Sparse own values precede the prototype. #[inline(always)] unsafe fn plain_index_get(receiver: f64, key: f64) -> Option { - let n = if key.to_bits() >> 48 == 0x7FFE { + let n = if crate::value::is_int32_bits(key.to_bits()) { key.to_bits() as i32 as f64 } else { key diff --git a/crates/perry-runtime/src/buffer/cmp.rs b/crates/perry-runtime/src/buffer/cmp.rs index a67a2da5c06..fefee80468b 100644 --- a/crates/perry-runtime/src/buffer/cmp.rs +++ b/crates/perry-runtime/src/buffer/cmp.rs @@ -232,7 +232,7 @@ fn buffer_search_needle_with_encoding( }; } } - let byte_val = if top16 == 0x7FFE { + let byte_val = if crate::value::is_int32_bits(needle_bits) { (needle_bits as u32) & 0xFF } else if top16 < 0x7FF8 || (top16 == 0x7FF8 && needle_bits == 0x7FF8_0000_0000_0000) { ((needle as i64) & 0xFF) as u32 @@ -301,7 +301,7 @@ pub extern "C" fn js_buffer_index_of_enc( } } // Numeric byte needle — INT32_TAG or plain double - let byte_val = if top16 == 0x7FFE { + let byte_val = if crate::value::is_int32_bits(needle_bits) { // INT32_TAG: lower 32 bits are an i32 (needle_bits as u32) & 0xFF } else if top16 < 0x7FF8 || (top16 == 0x7FF8 && needle_bits == 0x7FF8_0000_0000_0000) { diff --git a/crates/perry-runtime/src/byte_cell_support/mod.rs b/crates/perry-runtime/src/byte_cell_support/mod.rs index 7823a0bb98e..d2c14a3cf8c 100644 --- a/crates/perry-runtime/src/byte_cell_support/mod.rs +++ b/crates/perry-runtime/src/byte_cell_support/mod.rs @@ -508,7 +508,7 @@ pub(crate) fn jsvalue_to_f64(v: f64) -> f64 { crate::collection_iter::throw_type_error("Cannot convert a Symbol value to a number"); } // INT32 tag - if top16 == 0x7FFE { + if crate::value::is_int32_bits(bits) { let n = (bits & 0xFFFF_FFFF) as i32; return n as f64; } diff --git a/crates/perry-runtime/src/closure/dispatch/value_call.rs b/crates/perry-runtime/src/closure/dispatch/value_call.rs index df3cbc445ad..6cb3f3e075a 100644 --- a/crates/perry-runtime/src/closure/dispatch/value_call.rs +++ b/crates/perry-runtime/src/closure/dispatch/value_call.rs @@ -407,8 +407,7 @@ unsafe fn call_array_this( // conversion at this bridge, and leave class references (the same tag) // intact. Only a slice containing a numeric INT32 needs scratch storage. let is_int = |raw: f64| { - raw.to_bits() & 0xFFFF_0000_0000_0000 == crate::value::INT32_TAG - && crate::object::class_ref_id(raw).is_none() + crate::value::is_int32_bits(raw.to_bits()) && crate::object::class_ref_id(raw).is_none() }; if !args.iter().copied().any(is_int) { return dispatch_body_args(closure, info, this, args); diff --git a/crates/perry-runtime/src/contracts.rs b/crates/perry-runtime/src/contracts.rs index dbc37bcfa6b..54aa1c86b90 100644 --- a/crates/perry-runtime/src/contracts.rs +++ b/crates/perry-runtime/src/contracts.rs @@ -358,7 +358,9 @@ fn kind_of(v: f64) -> u16 { } JS_HANDLE_TAG => K_OBJ, STRING_TAG | SHORT_STRING_TAG => K_STR, - INT32_TAG => K_NUM, + INT32_TAG if crate::value::is_int32_bits(bits) => K_NUM, + // A `C.prototype` ref or another non-Number `0x7FFE` word. + INT32_TAG => K_OBJ, BIGINT_TAG => K_BIGINT, TAG_MARKER => match bits & 0xFF { 2 => K_NULL, @@ -749,4 +751,16 @@ mod tests { assert_eq!(kind_of(f64::from_bits(STRING_TAG | 0x1000)), K_STR); assert_eq!(kind_of(f64::from_bits(BIGINT_TAG | 0x1000)), K_BIGINT); } + /// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class + /// 5, and a `0x7FFE` word with high payload bits: neither is an int32 + /// Number, and a top-16-only test reads both as one. + const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; + const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + + #[test] + fn a_non_number_0x7ffe_word_is_not_k_num() { + assert_eq!(kind_of(f64::from_bits(INT32_TAG | 3)), K_NUM); + assert_eq!(kind_of(f64::from_bits(PROTO_REF_5)), K_OBJ); + assert_eq!(kind_of(f64::from_bits(HIGH_BITS)), K_OBJ); + } } diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index ae35b1d1fdf..cc685be40d0 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -546,10 +546,12 @@ fn jsvalue_to_number(v: f64) -> f64 { _ => f64::NAN, } } - 0x7FFE => { + 0x7FFE if crate::value::is_int32_bits(bits) => { // INT32 ((bits & 0xFFFF_FFFF) as u32 as i32) as f64 } + // A non-Number `0x7FFE` word (a `C.prototype` ref) is an object. + 0x7FFE => f64::NAN, 0x7FFD => { // Pointer: a Date contributes its time value; any other object (and // arrays) coerce via OrdinaryToPrimitive(number) → ToNumber. diff --git a/crates/perry-runtime/src/date/tests.rs b/crates/perry-runtime/src/date/tests.rs index fef299c8abf..7f9e7e5b314 100644 --- a/crates/perry-runtime/src/date/tests.rs +++ b/crates/perry-runtime/src/date/tests.rs @@ -407,3 +407,19 @@ fn a_headerless_symbol_behind_a_date_cell_tag_is_not_a_date() { let date = alloc_date_cell(0.0); assert!(is_date_value(date)); } + +/// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class +/// 5, and a `0x7FFE` word with high payload bits: neither is an int32 +/// Number, and a top-16-only test reads both as one. +const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; +const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + +#[test] +fn a_non_number_0x7ffe_word_converts_to_nan() { + assert_eq!( + jsvalue_to_number(f64::from_bits(0x7FFE_0000_0000_0005)), + 5.0 + ); + assert!(jsvalue_to_number(f64::from_bits(PROTO_REF_5)).is_nan()); + assert!(jsvalue_to_number(f64::from_bits(PROTO_REF_5)).to_bits() == f64::NAN.to_bits()); +} diff --git a/crates/perry-runtime/src/gc/barrier_store.rs b/crates/perry-runtime/src/gc/barrier_store.rs index 3320a746cd2..5604c426595 100644 --- a/crates/perry-runtime/src/gc/barrier_store.rs +++ b/crates/perry-runtime/src/gc/barrier_store.rs @@ -86,7 +86,7 @@ fn canonicalize_typed_slot_store_bits( slot_index: usize, value_bits: u64, ) -> u64 { - if value_bits & TAG_MASK != crate::value::INT32_TAG { + if !crate::value::is_int32_bits(value_bits) { return value_bits; } if !slot_holds_raw_f64(parent_user, slot_index) { diff --git a/crates/perry-runtime/src/gc/census.rs b/crates/perry-runtime/src/gc/census.rs index 5fbebeef12a..69c5dc7d95c 100644 --- a/crates/perry-runtime/src/gc/census.rs +++ b/crates/perry-runtime/src/gc/census.rs @@ -281,7 +281,9 @@ fn slot_kind(bits: u64) -> usize { POINTER_TAG => 0, STRING_TAG => 1, SHORT_STRING_TAG => 2, - INT32_TAG => 3, + INT32_TAG if crate::value::is_int32_bits(bits) => 3, + // A non-Number `0x7FFE` word (a `C.prototype` ref). + INT32_TAG => 11, BIGINT_TAG => 9, JS_HANDLE_TAG => 10, TAG_MARKER => match bits { @@ -644,6 +646,16 @@ pub(super) fn side_tables() -> Vec { rows } +#[cfg(test)] +mod int32_exact_tests { + #[test] + fn a_non_number_0x7ffe_word_is_not_the_int32_kind() { + assert_eq!(super::slot_kind(0x7FFE_0000_0000_0005), 3); + assert_ne!(super::slot_kind(0x7FFE_0001_0000_0005), 3); + assert_ne!(super::slot_kind(0x7FFE_00FF_0000_0001), 3); + } +} + #[cfg(test)] mod regex_census_tests { #[test] diff --git a/crates/perry-runtime/src/gc/census_field_repr.rs b/crates/perry-runtime/src/gc/census_field_repr.rs index e1704037d05..4247da24b96 100644 --- a/crates/perry-runtime/src/gc/census_field_repr.rs +++ b/crates/perry-runtime/src/gc/census_field_repr.rs @@ -41,7 +41,8 @@ const C_NUMBER: u8 = C_INT32 | C_DOUBLE | C_NAN_INF; pub(super) fn value_class(bits: u64) -> u8 { use crate::value::*; match bits & TAG_MASK { - INT32_TAG => C_INT32, + INT32_TAG if crate::value::is_int32_bits(bits) => C_INT32, + INT32_TAG => C_OTHER, POINTER_TAG | STRING_TAG | SHORT_STRING_TAG | BIGINT_TAG | JS_HANDLE_TAG | TAG_MARKER => { C_OTHER } @@ -234,4 +235,10 @@ mod tests { assert_eq!(s["f64_slots"], REP_SLOTS as u64); assert_eq!(s["f64_nan_inf"], 0); } + + #[test] + fn a_non_number_0x7ffe_word_is_not_c_int32() { + assert_eq!(value_class(0x7FFE_0001_0000_0005), C_OTHER); + assert_eq!(value_class(0x7FFE_00FF_0000_0001), C_OTHER); + } } diff --git a/crates/perry-runtime/src/json/stringify_flat.rs b/crates/perry-runtime/src/json/stringify_flat.rs index ab8902a8418..1b8907b3739 100644 --- a/crates/perry-runtime/src/json/stringify_flat.rs +++ b/crates/perry-runtime/src/json/stringify_flat.rs @@ -172,7 +172,7 @@ pub(super) unsafe fn scalar_piece(bits: u64) -> Option { match bits & crate::value::TAG_MASK { STRING_TAG | crate::value::SHORT_STRING_TAG => return string_piece(bits), POINTER_TAG | BIGINT_TAG | crate::value::HANDLE_TAG => return None, - INT32_TAG => { + INT32_TAG if crate::value::is_int32_bits(bits) => { let mut digits = itoa::Buffer::new(); return Some(Piece::inline( digits.format((bits & INT32_MASK) as u32 as i32).as_bytes(), diff --git a/crates/perry-runtime/src/json/stringify_scalars.rs b/crates/perry-runtime/src/json/stringify_scalars.rs index 311e7e67720..7953e534140 100644 --- a/crates/perry-runtime/src/json/stringify_scalars.rs +++ b/crates/perry-runtime/src/json/stringify_scalars.rs @@ -30,7 +30,7 @@ pub(crate) unsafe fn write_number(buf: &mut String, value: f64) { // the sqlite binding (and other int32-tagged numbers) funnel here; numeric // literals are stored as plain f64 doubles by codegen and never take this // branch. Mirrors the BigInt funnel above. - if (value.to_bits() & 0xFFFF_0000_0000_0000) == INT32_TAG { + if crate::value::is_int32_bits(value.to_bits()) { let n = (value.to_bits() & INT32_MASK) as u32 as i32; let mut itoa_buf = itoa::Buffer::new(); super::stringify_copy::push_str(buf, itoa_buf.format(n)); diff --git a/crates/perry-runtime/src/node_submodules/diagnostics_tail.rs b/crates/perry-runtime/src/node_submodules/diagnostics_tail.rs index 421a8d9fa44..0b683dc9dc5 100644 --- a/crates/perry-runtime/src/node_submodules/diagnostics_tail.rs +++ b/crates/perry-runtime/src/node_submodules/diagnostics_tail.rs @@ -74,7 +74,7 @@ fn store_handle(store: f64) -> Option { return Some(token); } let bits = store.to_bits(); - if bits & crate::value::TAG_MASK == crate::value::INT32_TAG { + if crate::value::is_int32_bits(bits) { return Some((bits & crate::value::INT32_MASK) as i32 as i64); } if bits & crate::value::TAG_MASK == crate::value::POINTER_TAG { diff --git a/crates/perry-runtime/src/object/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index fede9eb3abb..a7b3fbca476 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -1148,7 +1148,7 @@ pub unsafe fn dispatch_buffer_method( .is_some_and(|idx| idx < crate::buffer::store::raw_length(buf_ptr as usize)) }) { own - } else if (key_bits >> 48) == 0x7FFE { + } else if crate::value::is_int32_bits(key_bits) { // int32 key let idx = (key_bits & 0xFFFF_FFFF) as i32; let buf_len = crate::buffer::store::raw_length(buf_ptr as usize) as i32; @@ -1186,7 +1186,7 @@ pub unsafe fn dispatch_buffer_method( .is_some_and(|idx| idx < crate::buffer::store::raw_length(buf_ptr as usize)) }) { own - } else if (key_bits >> 48) == 0x7FFE { + } else if crate::value::is_int32_bits(key_bits) { let idx = (key_bits & 0xFFFF_FFFF) as i32; let buf_len = crate::buffer::store::raw_length(buf_ptr as usize) as i32; idx >= 0 && idx < buf_len diff --git a/crates/perry-runtime/src/object/field_get_set/runtime_read_site.rs b/crates/perry-runtime/src/object/field_get_set/runtime_read_site.rs index a09bd8a1481..6f369c6e7da 100644 --- a/crates/perry-runtime/src/object/field_get_set/runtime_read_site.rs +++ b/crates/perry-runtime/src/object/field_get_set/runtime_read_site.rs @@ -235,7 +235,7 @@ pub(crate) unsafe fn get_atom(obj: *mut ObjectHeader, atom: *const crate::String #[inline] pub(crate) fn integer_key(key: f64) -> Option { let bits = key.to_bits(); - if bits & crate::value::TAG_MASK == crate::value::INT32_TAG { + if crate::value::is_int32_bits(bits) { return Some(i64::from(crate::value::JSValue::from_bits(bits).as_int32())); } (key.is_finite() && key.fract() == 0.0 && key.abs() <= 9_007_199_254_740_992.0) diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index cff8b915903..bf43a608f88 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -140,7 +140,7 @@ macro_rules! nm_general_closures { let $i32_arg = |n: usize| -> i32 { let v = $arg(n); let bits = v.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::value::is_int32_bits(bits) { return (bits & 0xFFFF_FFFF) as u32 as i32; } if v.is_nan() || v.is_infinite() { diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 6ccc84df186..2f476cc680a 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -323,8 +323,9 @@ pub(crate) unsafe fn apply_property_descriptor( // through array hole/OOB reads — flip the global flag. { let kb = key_value.to_bits(); - let is_numeric_key = - (kb >> 48) == 0x7FFE || crate::value::JSValue::from_bits(kb).is_number() || { + let is_numeric_key = crate::value::is_int32_bits(kb) + || crate::value::JSValue::from_bits(kb).is_number() + || { let sp = crate::value::js_get_string_pointer_unified(key_value) as *const crate::StringHeader; !sp.is_null() diff --git a/crates/perry-runtime/src/object/polymorphic_index.rs b/crates/perry-runtime/src/object/polymorphic_index.rs index 5d27d424fbc..2926311701c 100644 --- a/crates/perry-runtime/src/object/polymorphic_index.rs +++ b/crates/perry-runtime/src/object/polymorphic_index.rs @@ -96,7 +96,7 @@ fn integer_key_probe(raw: u64, idx: f64) -> Result { fn numeric_key_u32_index(value: f64) -> Option { let bits = value.to_bits(); - if (bits & crate::value::TAG_MASK) == crate::value::INT32_TAG { + if crate::value::is_int32_bits(bits) { let index = crate::value::JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index as u32); } @@ -109,7 +109,7 @@ fn numeric_key_u32_index(value: f64) -> Option { fn numeric_key_i32_index(value: f64) -> Option { let bits = value.to_bits(); - if (bits & crate::value::TAG_MASK) == crate::value::INT32_TAG { + if crate::value::is_int32_bits(bits) { let index = crate::value::JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index); } @@ -177,7 +177,8 @@ pub extern "C" fn js_object_get_index_polymorphic(obj_handle: i64, idx: f64) -> } // A `C.prototype` ref word; an INT32 number has no elements. 0x7FFE => { - if crate::object::class_prototype_ref_id(f64::from_bits(obj_handle as u64)).is_some() + if crate::object::class_prototype_ref_id(f64::from_bits(obj_handle as u64)) + .is_some() { return crate::value::js_dyn_index_get(f64::from_bits(obj_handle as u64), idx); } @@ -258,8 +259,7 @@ pub extern "C" fn js_object_get_index_polymorphic(obj_handle: i64, idx: f64) -> // own, then its [[Prototype]] chain, never the Buffer table. if crate::buffer::is_non_indexed_buffer_view(raw as usize) { if let Some(name) = buffer_key_name(idx) { - let key = - crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let v = crate::object::view_get(raw as *const crate::object::ObjectHeader, key); return f64::from_bits(v.bits()); } @@ -605,3 +605,7 @@ fn buffer_key_name(idx: f64) -> Option { Some(String::from_utf8_lossy(bytes).into_owned()) } } + +#[cfg(test)] +#[path = "polymorphic_index_int32_exact_tests.rs"] +mod int32_exact_tests; diff --git a/crates/perry-runtime/src/object/polymorphic_index_int32_exact_tests.rs b/crates/perry-runtime/src/object/polymorphic_index_int32_exact_tests.rs new file mode 100644 index 00000000000..7f8b2d9a962 --- /dev/null +++ b/crates/perry-runtime/src/object/polymorphic_index_int32_exact_tests.rs @@ -0,0 +1,16 @@ +//! A non-Number `0x7FFE` word (a `C.prototype` ref) is no numeric key. + +const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; +const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + +#[test] +fn a_non_number_0x7ffe_word_is_no_numeric_key() { + for bits in [PROTO_REF_5, HIGH_BITS] { + assert_eq!(super::numeric_key_u32_index(f64::from_bits(bits)), None); + assert_eq!(super::numeric_key_i32_index(f64::from_bits(bits)), None); + } + assert_eq!( + super::numeric_key_u32_index(f64::from_bits(0x7FFE_0000_0000_0005)), + Some(5) + ); +} diff --git a/crates/perry-runtime/src/pty/mod.rs b/crates/perry-runtime/src/pty/mod.rs index 805a1cca7b2..1ef2f137302 100644 --- a/crates/perry-runtime/src/pty/mod.rs +++ b/crates/perry-runtime/src/pty/mod.rs @@ -285,7 +285,7 @@ mod platform_impl { /// Best-effort i32 from a NaN-boxed numeric argument. fn pty_arg_i32(v: f64) -> i32 { let bits = v.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::value::is_int32_bits(bits) { return (bits & 0xFFFF_FFFF) as u32 as i32; } if v.is_finite() { diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index d6b541aca08..b4ba51bf964 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -176,9 +176,13 @@ fn is_truthy_bits(bits: u64) -> bool { return true; } // INT32_TAG: check if value is 0 - if (bits & TAG_MASK) == INT32_TAG { + if crate::value::is_int32_bits(bits) { return (bits & INT32_MASK) != 0; } + // Any other `0x7FFE` word (a `C.prototype` ref) is an object: truthy. + if (bits & TAG_MASK) == INT32_TAG { + return true; + } // String: empty string is falsy (check length == 0) if (bits & TAG_MASK) == STRING_TAG { let ptr = (bits & POINTER_MASK) as *const crate::string::StringHeader; @@ -1470,3 +1474,7 @@ mod error_stack_clone_tests; #[cfg(test)] #[path = "thread/error_cause_clone_tests.rs"] mod error_cause_clone_tests; + +#[cfg(test)] +#[path = "thread_int32_exact_tests.rs"] +mod int32_exact_tests; diff --git a/crates/perry-runtime/src/thread_int32_exact_tests.rs b/crates/perry-runtime/src/thread_int32_exact_tests.rs new file mode 100644 index 00000000000..609986a6913 --- /dev/null +++ b/crates/perry-runtime/src/thread_int32_exact_tests.rs @@ -0,0 +1,10 @@ +//! Worker truthiness: a `C.prototype` ref word is an object (truthy), even +//! with class id 0 in its payload. + +#[test] +fn a_prototype_ref_word_is_truthy() { + assert!(super::is_truthy_bits(0x7FFE_0001_0000_0000)); + assert!(super::is_truthy_bits(0x7FFE_0001_0000_0005)); + assert!(!super::is_truthy_bits(0x7FFE_0000_0000_0000)); + assert!(super::is_truthy_bits(0x7FFE_0000_0000_0005)); +} diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 55bd585e478..b710cf24ed2 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -491,7 +491,9 @@ fn stable_value_kind(bits: u64) -> u16 { BIGINT_TAG => STABLE_VALUE_BIGINT, JS_HANDLE_TAG => STABLE_VALUE_JS_HANDLE, SHORT_STRING_TAG => STABLE_VALUE_SHORT_STRING, - INT32_TAG => STABLE_VALUE_INT32, + INT32_TAG if crate::value::is_int32_bits(bits) => STABLE_VALUE_INT32, + // A non-Number `0x7FFE` word (a `C.prototype` ref): no stable kind. + INT32_TAG => STABLE_VALUE_POINTER, _ => STABLE_VALUE_NUMBER, } } @@ -1204,7 +1206,7 @@ fn is_numeric_value_bits(bits: u64) -> bool { fn finite_nonnegative_u32_index(index: f64) -> Option { let bits = index.to_bits(); - if (bits & TAG_MASK) == INT32_TAG { + if crate::value::is_int32_bits(bits) { let index = crate::value::JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index as u32); } @@ -1217,7 +1219,7 @@ fn finite_nonnegative_u32_index(index: f64) -> Option { fn finite_nonnegative_i32_index(index: f64) -> Option { let bits = index.to_bits(); - if (bits & TAG_MASK) == INT32_TAG { + if crate::value::is_int32_bits(bits) { let index = crate::value::JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index); } diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 394c67909b0..3b8da1bfae7 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -2671,3 +2671,23 @@ fn representation_change_takes_no_registry_lock_with_feedback_off() { reset_typed_feedback_for_tests(); }); } + +/// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class +/// 5, and a `0x7FFE` word with high payload bits: neither is an int32 +/// Number, and a top-16-only test reads both as one. +const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; +const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + +#[test] +fn a_non_number_0x7ffe_word_is_no_index_and_no_int32_kind() { + for bits in [PROTO_REF_5, HIGH_BITS] { + assert_eq!(finite_nonnegative_u32_index(f64::from_bits(bits)), None); + assert_eq!(finite_nonnegative_i32_index(f64::from_bits(bits)), None); + assert_ne!(stable_value_kind(bits), STABLE_VALUE_INT32); + } + assert_eq!( + finite_nonnegative_u32_index(f64::from_bits(0x7FFE_0000_0000_0005)), + Some(5) + ); + assert_eq!(stable_value_kind(0x7FFE_0000_0000_0005), STABLE_VALUE_INT32); +} diff --git a/crates/perry-runtime/src/typedarray/construct.rs b/crates/perry-runtime/src/typedarray/construct.rs index 81f1b93408a..f02443cb018 100644 --- a/crates/perry-runtime/src/typedarray/construct.rs +++ b/crates/perry-runtime/src/typedarray/construct.rs @@ -50,7 +50,7 @@ pub extern "C" fn js_typed_array_new(kind: i32, val: f64) -> *mut TypedArrayHead let rooted = scope.root_nanbox_f64(val); return typed_array_new_from_heap_source(kind, &rooted); } - if top16 == 0x7FFE { + if crate::value::is_int32_bits(bits) { // INT32_TAG — lower 32 bits are the signed length. let n = (bits & 0xFFFF_FFFF) as i32; let len = typed_array_length_or_throw(n as f64); diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index cbf2a6a88e1..64ab83cd2b8 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -106,7 +106,7 @@ fn raw_i64_receiver_is_managed(addr: usize) -> bool { fn finite_nonnegative_i32_index(index: f64) -> Option { let bits = index.to_bits(); - if (bits & TAG_MASK) == INT32_TAG { + if is_int32_bits(bits) { let index = JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index); } @@ -119,7 +119,7 @@ fn finite_nonnegative_i32_index(index: f64) -> Option { fn finite_nonnegative_u32_index(index: f64) -> Option { let bits = index.to_bits(); - if (bits & TAG_MASK) == INT32_TAG { + if is_int32_bits(bits) { let index = JSValue::from_bits(bits).as_int32(); return (index >= 0).then_some(index as u32); } diff --git a/crates/perry-runtime/src/value/dyn_index_uint8array_tests.rs b/crates/perry-runtime/src/value/dyn_index_uint8array_tests.rs index f31d7202cc1..4f39adecfa3 100644 --- a/crates/perry-runtime/src/value/dyn_index_uint8array_tests.rs +++ b/crates/perry-runtime/src/value/dyn_index_uint8array_tests.rs @@ -24,3 +24,27 @@ fn dynamic_uint8array_set_coerces_nanboxed_values_before_narrowing() { js_dyn_index_set_strict(receiver, index_one, boxed_i32(-1), 1); assert_eq!(js_dyn_index_get(receiver, index_one), 255.0); } + +/// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class +/// 5, and a `0x7FFE` word with high payload bits: neither is an int32 +/// Number, and a top-16-only test reads both as one. +const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; +const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + +#[test] +fn a_non_number_0x7ffe_word_is_no_dynamic_index() { + for bits in [PROTO_REF_5, HIGH_BITS] { + assert_eq!( + super::finite_nonnegative_u32_index(f64::from_bits(bits)), + None + ); + assert_eq!( + super::finite_nonnegative_i32_index(f64::from_bits(bits)), + None + ); + } + assert_eq!( + super::finite_nonnegative_u32_index(f64::from_bits(0x7FFE_0000_0000_0005)), + Some(5) + ); +} diff --git a/crates/perry-runtime/src/value/int32_exact_tests.rs b/crates/perry-runtime/src/value/int32_exact_tests.rs new file mode 100644 index 00000000000..1ba004b6483 --- /dev/null +++ b/crates/perry-runtime/src/value/int32_exact_tests.rs @@ -0,0 +1,40 @@ +//! The one int32-Number predicate (`tags::is_int32_bits`) and the shared +//! consumers built on it: a `0x7FFE` word with any of bits 32..47 set is not +//! an int32 Number. + +/// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class +/// 5, and a `0x7FFE` word with high payload bits: neither is an int32 +/// Number, and a top-16-only test reads both as one. +const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; +const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + +const INT32_5: u64 = 0x7FFE_0000_0000_0005; + +#[test] +fn only_a_clear_high_payload_is_an_int32_number() { + assert!(super::is_int32_bits(INT32_5)); + assert!(super::JSValue::from_bits(INT32_5).is_int32()); + for bits in [PROTO_REF_5, HIGH_BITS] { + assert!(!super::is_int32_bits(bits)); + assert!(!super::JSValue::from_bits(bits).is_int32()); + } +} + +#[test] +fn a_prototype_ref_word_is_truthy_and_no_integer_key() { + // Class id 0 in the payload: a top-16-only test reads int32 0 (falsy). + let proto_ref_0 = 0x7FFE_0001_0000_0000u64; + assert_eq!(super::js_is_truthy(f64::from_bits(proto_ref_0)), 1); + assert_eq!( + super::js_is_truthy(f64::from_bits(0x7FFE_0000_0000_0000)), + 0 + ); + assert_eq!( + crate::object::field_get_set::runtime_read_site::integer_key(f64::from_bits(PROTO_REF_5)), + None + ); + assert_eq!( + crate::object::field_get_set::runtime_read_site::integer_key(f64::from_bits(INT32_5)), + Some(5) + ); +} diff --git a/crates/perry-runtime/src/value/jsvalue.rs b/crates/perry-runtime/src/value/jsvalue.rs index fe8bc7088e4..71872948b1b 100644 --- a/crates/perry-runtime/src/value/jsvalue.rs +++ b/crates/perry-runtime/src/value/jsvalue.rs @@ -123,7 +123,7 @@ impl JSValue { /// Check if this is an int32 #[inline] pub fn is_int32(&self) -> bool { - (self.bits & !INT32_MASK) == INT32_TAG + super::tags::is_int32_bits(self.bits) } /// A `POINTER_TAG` word: a GC-headed heap allocation whose header may be diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index d09c8a3f9f7..6ed1e072c80 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -62,7 +62,7 @@ mod tests; // named constant for documentation + external tooling but not re-exported // since no in-crate consumer references it directly. pub(crate) use tags::{ - BIGINT_TAG, INT32_MASK, INT32_TAG, JS_HANDLE_TAG, POINTER_MASK, POINTER_TAG, + is_int32_bits, BIGINT_TAG, INT32_MASK, INT32_TAG, JS_HANDLE_TAG, POINTER_MASK, POINTER_TAG, SHORT_STRING_DATA_MASK, SHORT_STRING_LEN_MASK, SHORT_STRING_LEN_SHIFT, SHORT_STRING_TAG, STRING_TAG, TAG_FALSE, TAG_HOLE, TAG_MARKER, TAG_MASK, TAG_NULL, TAG_TDZ, TAG_TRUE, TAG_UNDEFINED, @@ -92,6 +92,9 @@ pub(crate) use tags::{ JS_HANDLE_OBJECT_GET_PROPERTY, JS_HANDLE_TO_STRING, }; +#[cfg(test)] +mod int32_exact_tests; + // ----- JSValue type + impls ----- pub use jsvalue::JSValue; diff --git a/crates/perry-runtime/src/value/tags.rs b/crates/perry-runtime/src/value/tags.rs index 87ecc5d7f6f..619aa9a70bb 100644 --- a/crates/perry-runtime/src/value/tags.rs +++ b/crates/perry-runtime/src/value/tags.rs @@ -64,6 +64,16 @@ pub const fn is_reference_bits(bits: u64) -> bool { pub(crate) const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; pub(crate) const INT32_MASK: u64 = 0x0000_0000_FFFF_FFFF; +/// THE "is this an int32 Number?" test: tag `0x7FFE` with bits 32..47 clear. +/// Other words in the `0x7FFE` band are not Numbers: the `C.prototype` ref +/// (`CLASS_PROTOTYPE_REF_FLAG`, bit 32), key-attribute and overflow route +/// words, the bound-closure inline-args marker. A test of the top 16 bits +/// alone reads those as int32 Numbers; every Number consumer asks this. +#[inline] +pub(crate) const fn is_int32_bits(bits: u64) -> bool { + bits & !INT32_MASK == INT32_TAG +} + /// String pointer tag: 0x7FFF_XXXX_XXXX_XXXX (48 bits for string pointer) pub(crate) const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; diff --git a/crates/perry-runtime/src/value/truthy.rs b/crates/perry-runtime/src/value/truthy.rs index 1c7c67cd67f..42651e1c5de 100644 --- a/crates/perry-runtime/src/value/truthy.rs +++ b/crates/perry-runtime/src/value/truthy.rs @@ -70,10 +70,14 @@ pub extern "C" fn js_is_truthy(value: f64) -> i32 { } // Check for int32 tag - if (bits & TAG_MASK) == INT32_TAG { + if is_int32_bits(bits) { let int_val = (bits & INT32_MASK) as i32; return if int_val == 0 { 0 } else { 1 }; } + // Any other `0x7FFE` word (a `C.prototype` ref) is an object: truthy. + if (bits & TAG_MASK) == INT32_TAG { + return 1; + } // Check for SHORT_STRING_TAG (inline SSO strings): falsy iff length is 0. // Without this branch SSO empties would fall through to the f64 path, diff --git a/crates/perry-runtime/src/wpfacts_check.rs b/crates/perry-runtime/src/wpfacts_check.rs index ce407fb10c2..03b35782d61 100644 --- a/crates/perry-runtime/src/wpfacts_check.rs +++ b/crates/perry-runtime/src/wpfacts_check.rs @@ -60,7 +60,9 @@ fn kind_of(bits: u64) -> u32 { } JS_HANDLE_TAG => K_OBJ, STRING_TAG | SHORT_STRING_TAG => K_STR, - INT32_TAG => K_NUM, + INT32_TAG if crate::value::is_int32_bits(bits) => K_NUM, + // A `C.prototype` ref or another non-Number `0x7FFE` word. + INT32_TAG => K_OBJ, BIGINT_TAG => K_BIGINT, TAG_MARKER => match bits & 0xFF { 2 => K_NULL, @@ -438,6 +440,18 @@ mod tests { assert!(!is_plain_f64(tag | POINTER_MASK), "{tag:#018x}"); } } + /// A `C.prototype` ref word (`CLASS_PROTOTYPE_REF_FLAG`, bit 32) for class + /// 5, and a `0x7FFE` word with high payload bits: neither is an int32 + /// Number, and a top-16-only test reads both as one. + const PROTO_REF_5: u64 = 0x7FFE_0001_0000_0005; + const HIGH_BITS: u64 = 0x7FFE_00FF_0000_0001; + + #[test] + fn a_non_number_0x7ffe_word_is_not_k_num() { + assert_eq!(kind_of(0x7FFE_0000_0000_0003), K_NUM); + assert_eq!(kind_of(PROTO_REF_5), K_OBJ); + assert_eq!(kind_of(HIGH_BITS), K_OBJ); + } } /// Raw metadata ABI used only by the compile-time verification mode. From ecb1839e0b961ddd2c272d9e90f87c7b62960fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 05:24:50 +0100 Subject: [PATCH 2/8] wpfacts/codegen: P3 B1, element reads proven plain Numbers drop their INT32 tests (C1 compare, C2 arithmetic leaf, C4 parameter entry); NUM seed audit; canonnum runtime sabotage --- .../wpfacts-p3-b1-canonical-numbers.md | 1 + .../src/codegen/facts_number_locals.rs | 9 ++ .../src/codegen/program_facts_view.rs | 5 + crates/perry-codegen/src/expr/binary.rs | 46 +++++- crates/perry-codegen/src/expr/compare.rs | 87 ++++++++++++ .../perry-codegen/src/expr/facts_canonical.rs | 127 +++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 1 + .../src/verify_facts/coverage.rs | 2 + crates/perry-runtime/src/math.rs | 14 +- crates/perry-wpfacts/NUM_SEEDS.md | 134 ++++++++++++++++++ crates/perry-wpfacts/src/bindings.rs | 4 + crates/perry-wpfacts/src/canonical_numbers.rs | 65 +++++++++ crates/perry-wpfacts/src/gen.rs | 4 + crates/perry-wpfacts/src/gen_expr.rs | 5 + crates/perry-wpfacts/src/lib.rs | 4 + crates/perry-wpfacts/tests/num_seeds_audit.rs | 109 ++++++++++++++ .../perry/src/commands/compile/compile_env.rs | 1 + .../src/commands/compile/program_facts.rs | 2 + scripts/check_file_size.sh | 2 +- scripts/p3-canonnum-runtime.sh | 23 +++ scripts/test-verify-facts-s0.py | 19 +++ .../canonical-number-negative.ts | 22 +++ .../number/test_p3_canonical_numbers.ts | 86 +++++++++++ 23 files changed, 768 insertions(+), 4 deletions(-) create mode 100644 changelog.d/wpfacts-p3-b1-canonical-numbers.md create mode 100644 crates/perry-codegen/src/expr/facts_canonical.rs create mode 100644 crates/perry-wpfacts/NUM_SEEDS.md create mode 100644 crates/perry-wpfacts/src/canonical_numbers.rs create mode 100644 crates/perry-wpfacts/tests/num_seeds_audit.rs create mode 100755 scripts/p3-canonnum-runtime.sh create mode 100644 scripts/verify_facts_s0/canonical-number-negative.ts create mode 100644 tests/parity/language/number/test_p3_canonical_numbers.ts diff --git a/changelog.d/wpfacts-p3-b1-canonical-numbers.md b/changelog.d/wpfacts-p3-b1-canonical-numbers.md new file mode 100644 index 00000000000..d52aea2d458 --- /dev/null +++ b/changelog.d/wpfacts-p3-b1-canonical-numbers.md @@ -0,0 +1 @@ +Performance: an element read whose value the whole-program facts prove a plain Number (every value stored into the array is a program Number or the audited Number result of a builtin) is compared with a bare `fcmp`, used as an arithmetic operand without an "is a Number" test, and passed to a Number parameter without an INT32 conversion at entry. A read that may also be `undefined` (a hole, or an index past the end) keeps exact JS equality: `undefined === undefined` is true and `NaN === NaN` false. Each builtin's Number result is audited against the runtime function that produces it (`perry-wpfacts/NUM_SEEDS.md`; a test keeps the table complete). `PERRY_VERIFY_FACTS=1` checks every trusted value is a plain double. `PERRY_FACTS_CANONICAL_NUMBERS=0` turns it off. A runtime built with `PERRY_FACTS_SABOTAGE=canonnum` (`scripts/p3-canonnum-runtime.sh`) is the negative control. diff --git a/crates/perry-codegen/src/codegen/facts_number_locals.rs b/crates/perry-codegen/src/codegen/facts_number_locals.rs index b9a838d43c7..3fce334aee4 100644 --- a/crates/perry-codegen/src/codegen/facts_number_locals.rs +++ b/crates/perry-codegen/src/codegen/facts_number_locals.rs @@ -171,7 +171,16 @@ pub(crate) fn none() -> &'static HashSet { /// The plain double of a Number argument: an INT32 box converts, every other /// Number is its own bits. +/// +/// P3 B1, C4 (DESIGN-p3 rev 3.1 §2.3): a Number the facts prove is a plain +/// double (every value reaching the parameter is a program Number or an +/// audited builtin NUM, `perry-wpfacts/NUM_SEEDS.md`), so with +/// `PERRY_FACTS_CANONICAL_NUMBERS` on the argument is taken as it is. The +/// entry witness (`emit_param_witnesses`) checks it in check mode. pub(crate) fn emit_entry_number(blk: &mut crate::block::LlBlock, arg: &str) -> String { + if crate::expr::facts_canonical::enabled() { + return arg.to_string(); + } let is_int = super::typed_entry::emit_int32_box_test(blk, arg); let bits = blk.bitcast_double_to_i64(arg); let low = blk.trunc(I64, &bits, I32); diff --git a/crates/perry-codegen/src/codegen/program_facts_view.rs b/crates/perry-codegen/src/codegen/program_facts_view.rs index 4912e9ff1f1..8b809739aa9 100644 --- a/crates/perry-codegen/src/codegen/program_facts_view.rs +++ b/crates/perry-codegen/src/codegen/program_facts_view.rs @@ -240,6 +240,10 @@ pub struct ElisionFacts { /// Method call byte offset → the call is a field element read of its /// receiver (wpfacts `ModuleBindings::field_read_calls`, b1). pub field_read_calls: std::collections::BTreeMap, + /// Element reads (by `element_offset`) whose value the facts prove a + /// plain Number; `true` when it may also be `undefined` (wpfacts + /// `canonical_numbers`, P3 B1). + pub number_element_reads: std::collections::BTreeMap, pub indexed_metadata_pristine: bool, pub numeric_functions: std::collections::BTreeMap, /// Class id → its own statics that hold their definition for the @@ -296,6 +300,7 @@ impl ElisionFacts { && self.number_calls.is_empty() && self.element_reads.is_empty() && self.field_read_calls.is_empty() + && self.number_element_reads.is_empty() && self.locals.is_empty() && self.this_of.is_empty() && self.local_bodies.is_empty() diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 932e83366ff..a8288deaccc 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -149,12 +149,18 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result } let mut leaves = Vec::new(); add_tree_leaves(expr, &mut leaves); + let facts: Vec = leaves + .iter() + .map(|leaf| facts_number_leaf(ctx, leaf)) + .collect(); let needs_test: Vec = leaves .iter() - .map(|leaf| !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf)) + .zip(&facts) + .map(|(leaf, f)| !f && !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf)) .collect(); with_operands_rooted(ctx, &leaves, |ctx, values| { + witness_facts_leaves(ctx, values, &facts)?; let mut cond: Option = None; for (value, is_tested) in values.iter().zip(needs_test.iter()) { if !is_tested { @@ -235,6 +241,36 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result }) } +/// P3 B1, C2 (`facts_canonical`): a guarded arithmetic leaf the facts prove a +/// plain Number needs no "is a Number" test. +fn facts_number_leaf(ctx: &FnCtx<'_>, leaf: &Expr) -> bool { + let proven = crate::expr::facts_canonical::of(ctx, leaf) + == Some(crate::expr::facts_canonical::Num::Number); + if proven { + crate::verify_facts::consumed( + crate::verify_facts::Fact::CanonicalNumber, + || serde_json::json!({ "arith": "leaf" }), + ); + crate::facts_consumed::used(); + } + proven +} + +/// Check mode: each fact-trusted leaf is a plain double. +fn witness_facts_leaves(ctx: &mut FnCtx<'_>, values: &[String], facts: &[bool]) -> Result<()> { + for (value, f) in values.iter().zip(facts) { + if *f { + crate::expr::facts_canonical::witness( + ctx, + value, + crate::expr::facts_canonical::Num::Number, + "arithmetic leaf", + )?; + } + } + Ok(()) +} + /// The `+`-shaped guard for the arithmetic operators that cannot concatenate. /// /// `-`, `*` and `/` apply ToNumeric to both operands, so unlike `+` there is @@ -262,13 +298,19 @@ fn lower_guarded_numeric_arith( fname: &str, ) -> Result { let leaves = [left, right]; + let facts: Vec = leaves + .iter() + .map(|leaf| facts_number_leaf(ctx, leaf)) + .collect(); let needs_test: Vec = leaves .iter() - .map(|leaf| !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf)) + .zip(&facts) + .map(|(leaf, f)| !f && !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf)) .collect(); let range_guard = is_bitwise_op(op) && needs_test.contains(&true); with_operands_rooted(ctx, &leaves, |ctx, values| { + witness_facts_leaves(ctx, values, &facts)?; let mut cond: Option = None; for (value, is_tested) in values.iter().zip(needs_test.iter()) { let is_num = if range_guard { diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 7fd8fb1b554..fefe00242d8 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -1266,6 +1266,87 @@ fn lower_string_strict_eq_inline( /// A compare operand that is statically a number (not a BigInt, not a value /// that may come back boxed from a raw-f64 fallback): two of them take the /// bare `fcmp`. +/// P3 B1, C1 (DESIGN-p3 rev 3.1 §2.3): a compare whose operands are both +/// proven plain Numbers, or Number-or-undefined, at least one of them by the +/// facts (`facts_canonical`). There is no INT32 prefix and no runtime helper: +/// +/// - relational: the ordered `fcmp` (`undefined` is a NaN pattern, so every +/// comparison with it is false, as in JS); +/// - `===` with at least one plain Number: `fcmp oeq` (`une` for `!==`); +/// - `===` of two Number-or-undefined: `fcmp oeq || (bits equal and +/// undefined)`, and `!==` is its exact negation (REVIEW-p3 D1). +/// +/// Check mode tests each fact-trusted operand (`facts_canonical::witness`). +fn lower_facts_number_compare( + ctx: &mut FnCtx<'_>, + op: CompareOp, + left: &Expr, + right: &Expr, + l: &str, + r: &str, +) -> Result> { + use super::facts_canonical::{self, Num}; + if matches!(op, CompareOp::LooseEq | CompareOp::LooseNe) { + return Ok(None); + } + let fl = facts_canonical::of(ctx, left); + let fr = facts_canonical::of(ctx, right); + if fl.is_none() && fr.is_none() { + return Ok(None); + } + let side = |ctx: &FnCtx<'_>, e: &Expr, f: Option| { + f.or_else(|| compare_operand_is_numeric(ctx, e).then_some(Num::Number)) + }; + let (Some(kl), Some(kr)) = (side(ctx, left, fl), side(ctx, right, fr)) else { + return Ok(None); + }; + crate::verify_facts::consumed( + crate::verify_facts::Fact::CanonicalNumber, + || serde_json::json!({ "compare": format!("{op:?}") }), + ); + crate::facts_consumed::used(); + if let Some(f) = fl { + facts_canonical::witness(ctx, l, f, "compare operand")?; + } + if let Some(f) = fr { + facts_canonical::witness(ctx, r, f, "compare operand")?; + } + let blk = ctx.block(); + let bit = match op { + CompareOp::Lt => blk.fcmp("olt", l, r), + CompareOp::Le => blk.fcmp("ole", l, r), + CompareOp::Gt => blk.fcmp("ogt", l, r), + CompareOp::Ge => blk.fcmp("oge", l, r), + CompareOp::Eq | CompareOp::Ne + if kl == Num::NumberOrUndefined && kr == Num::NumberOrUndefined => + { + let oeq = blk.fcmp("oeq", l, r); + let lb = blk.bitcast_double_to_i64(l); + let rb = blk.bitcast_double_to_i64(r); + let same = blk.icmp_eq(I64, &lb, &rb); + let undef = blk.icmp_eq(I64, &lb, crate::nanbox::TAG_UNDEFINED_I64); + let both_undef = blk.and(I1, &same, &undef); + let eq = blk.or(I1, &oeq, &both_undef); + if matches!(op, CompareOp::Ne) { + blk.xor(I1, &eq, "true") + } else { + eq + } + } + CompareOp::Eq => blk.fcmp("oeq", l, r), + CompareOp::Ne => blk.fcmp("une", l, r), + CompareOp::LooseEq | CompareOp::LooseNe => unreachable!(), + }; + let tagged = blk.select( + I1, + &bit, + I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, + ); + Ok(Some(blk.bitcast_i64_to_double(&tagged))) +} + fn compare_operand_is_numeric(ctx: &FnCtx<'_>, operand: &Expr) -> bool { is_numeric_expr(ctx, operand) && !expr_may_return_boxed_value_from_raw_f64_fallback(ctx, operand) @@ -1906,6 +1987,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(blk.bitcast_i64_to_double(&result_bits)); } + // P3 B1, C1 (`facts_canonical`): both operands proven plain + // Numbers (or Number-or-undefined), at least one by the facts. + if let Some(v) = lower_facts_number_compare(ctx, *op, left, right, &l, &r)? { + return Ok(v); + } + // An ordered relational compare (`<`, `<=`, `>`, `>=`) whose // operands aren't BOTH statically numeric needs the full ECMAScript // Abstract Relational Comparison: ToPrimitive (`{valueOf}`/`Date`), diff --git a/crates/perry-codegen/src/expr/facts_canonical.rs b/crates/perry-codegen/src/expr/facts_canonical.rs new file mode 100644 index 00000000000..d60f01072ca --- /dev/null +++ b/crates/perry-codegen/src/expr/facts_canonical.rs @@ -0,0 +1,127 @@ +//! P3 B1 (DESIGN-p3-canonical-numbers rev 3.1 §2): consumers that drop an +//! INT32 test because the facts prove a value a plain Number. +//! +//! The fact is wpfacts' NUM declaration (REVIEW-p3 R2): an element read whose +//! value node holds only Number at every copy +//! (`ElisionFacts::number_element_reads`), possibly with `undefined` for a +//! hole or an index past the end. Every Number a node can hold is a program +//! Number (codegen lowers literals and arithmetic to plain doubles) or comes +//! from a builtin whose NUM seed is audited against its runtime function +//! (`perry-wpfacts/NUM_SEEDS.md`). So such a value is a plain IEEE double and +//! never an INT32-boxed word. +//! +//! The consumers: +//! - C1 (`expr/compare.rs`): a compare whose operands are both proven lowers +//! to `fcmp`, with no INT32 prefix. Two Number-or-undefined operands keep +//! an identity arm for `undefined === undefined` (REVIEW-p3 D1). +//! - C2 (`expr/binary.rs`): a guarded arithmetic leaf proven a Number needs +//! no "is a Number" test. +//! +//! Check mode (`PERRY_VERIFY_FACTS=1`): every operand a consumer trusts is +//! tested "is a plain double" (`emit_plain_number_test`: outside the tag band +//! and not HANDLE_TAG; REVIEW-p3 R7), or is exactly `undefined` when the +//! fact allows it. A miss exits 86 (`Fact::CanonicalNumber`). +//! +//! `PERRY_FACTS_CANONICAL_NUMBERS=0` turns these consumers off (wpfacts then +//! exports no element values either). + +use anyhow::Result; +use perry_hir::{Expr, LogicalOp}; + +use super::FnCtx; +use crate::types::{DOUBLE, I1, I32, I64}; + +/// What the facts prove about a value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Num { + /// A plain double. + Number, + /// A plain double or `undefined`. + NumberOrUndefined, +} + +pub(crate) fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + !matches!( + std::env::var("PERRY_FACTS_CANONICAL_NUMBERS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// The facts' answer for `e`, when they cover it. +pub(crate) fn of(ctx: &FnCtx<'_>, e: &Expr) -> Option { + if !enabled() || ctx.wpfacts_off || ctx.wpfacts.interp_guard { + return None; + } + match e { + Expr::IndexGet { element_offset, .. } if *element_offset != 0 => ctx + .wpfacts + .number_element_reads + .get(element_offset) + .map(|&undef| { + if undef { + Num::NumberOrUndefined + } else { + Num::Number + } + }), + // `x ?? d`: `d` replaces exactly the `undefined` (and `null`, which a + // Number-or-undefined value never is). + Expr::Logical { + op: LogicalOp::Coalesce, + left, + right, + } => { + of(ctx, left)?; + let d = matches!(right.as_ref(), Expr::Integer(_) | Expr::Number(_)) + || of(ctx, right) == Some(Num::Number); + d.then_some(Num::Number) + } + _ => None, + } +} + +/// Check mode: the trusted `value` (the lowering of an operand `of` proved +/// `fact`) is a plain double, or `undefined` where the fact allows it. +pub(crate) fn witness(ctx: &mut FnCtx<'_>, value: &str, fact: Num, what: &str) -> Result<()> { + if !crate::verify_facts::enabled() { + return Ok(()); + } + crate::verify_facts::covered( + crate::verify_facts::Fact::CanonicalNumber, + || serde_json::json!({ "site": what }), + ); + let blk = ctx.block(); + let plain = crate::codegen::emit_plain_number_test(blk, value); + let ok = if fact == Num::NumberOrUndefined { + let bits = blk.bitcast_double_to_i64(value); + let undef = blk.icmp_eq(I64, &bits, crate::nanbox::TAG_UNDEFINED_I64); + blk.or(I1, &plain, &undef) + } else { + plain + }; + crate::verify_facts::tick(ctx); + let miss = ctx.new_block("canonical_number.verify_miss"); + let next = ctx.new_block("canonical_number.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 site_arg = crate::verify_facts::site_arg( + ctx, + &format!("canonical number {what}"), + crate::verify_facts::Fact::CanonicalNumber, + &[], + ); + let _ = crate::verify_facts::check( + ctx, + I32, + "perry_wpfacts_check_miss", + &[(DOUBLE, value)], + &site_arg, + ); + ctx.block().unreachable(); + ctx.current_block = next; + Ok(()) +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 20a9a976790..13268aeb2d6 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -126,6 +126,7 @@ mod channel; #[cfg(test)] mod class_method_arguments_object_tests; pub(crate) mod collection_receiver; +pub(crate) mod facts_canonical; pub(crate) mod facts_number; mod helpers; mod i32_fast_path; diff --git a/crates/perry-codegen/src/verify_facts/coverage.rs b/crates/perry-codegen/src/verify_facts/coverage.rs index 3c7204b7d96..58383742548 100644 --- a/crates/perry-codegen/src/verify_facts/coverage.rs +++ b/crates/perry-codegen/src/verify_facts/coverage.rs @@ -38,6 +38,7 @@ pub(crate) enum Fact { ProtoAdd, ElementRead, FieldReadCall, + CanonicalNumber, } impl Fact { pub(super) fn name(self) -> &'static str { @@ -75,6 +76,7 @@ impl Fact { Self::ProtoAdd => "prototype add", Self::ElementRead => "proven element read", Self::FieldReadCall => "field read call", + Self::CanonicalNumber => "canonical number", } } } diff --git a/crates/perry-runtime/src/math.rs b/crates/perry-runtime/src/math.rs index 5012c33f206..3dcfa6829f9 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -32,7 +32,19 @@ pub extern "C" fn js_math_trunc(value: f64) -> f64 { /// `js_math_round_value` (test262 Math/round/S15.8.2.15_A7). #[no_mangle] pub extern "C" fn js_math_round(value: f64) -> f64 { - crate::object::js_math_round_value(js_math_to_number(value)) + let rounded = crate::object::js_math_round_value(js_math_to_number(value)); + // P3 B1 negative control (DESIGN-p3 rev 3.1 §5, REVIEW-p3 D4): a runtime + // built with `PERRY_FACTS_SABOTAGE=canonnum` returns an INT32-boxed word + // from this declared-NUM builtin, so a consumer that trusts the NUM fact + // must be caught by its check-mode witness (rc 86). Build-time, so an + // ordinary build compiles the branch away. + if matches!(option_env!("PERRY_FACTS_SABOTAGE"), Some("canonnum")) + && rounded.fract() == 0.0 + && rounded.abs() < 2_147_483_648.0 + { + return f64::from_bits(crate::value::INT32_TAG | (rounded as i32 as u32 as u64)); + } + rounded } /// Math.sign(x) -> number diff --git a/crates/perry-wpfacts/NUM_SEEDS.md b/crates/perry-wpfacts/NUM_SEEDS.md new file mode 100644 index 00000000000..7e5fd598702 --- /dev/null +++ b/crates/perry-wpfacts/NUM_SEEDS.md @@ -0,0 +1,134 @@ +# NUM seed audit (P3 B1, DESIGN-p3 rev 3.1 §2.2, REVIEW-p3 D2) + +Every non-test use of `prim::NUM` in perry-wpfacts, one row each. A **seed** puts NUM into a node; its citation names the runtime function or the codegen lowering that produces the value, which must be a plain double (canonical IEEE, never a NaN-boxed tag). A **read/map** row tests the fact or maps a family to its prims and seeds nothing. + +There are no non-test callers of `JSValue::int32` in perry-runtime or perry-stdlib (DESIGN-p3 §1.1). So every runtime Number below is built by `JSValue::number` / `as f64` / f64 arithmetic. Raw-bits ingress (v8.deserialize, bun:ffi f64/f32) is not modeled by wpfacts (those values are ⊤) and is fixed separately (lane NANB). + +`tests/num_seeds_audit.rs` fails when the per-file count of `prim::NUM` uses differs from this table, so a new seed cannot land without a row. + +| file:line | kind | value and producer | +|---|---|---| +| `derive.rs:35` | read/map | read: tests or masks the fact, seeds nothing | +| `gen_rest.rs:158` | seed | native-module `array.push` new length: perry-runtime array/push_pop.rs js_array_push_f64 (length re-read, `as f64`) | +| `gen_rest.rs:170` | seed | native-module receiver method count: perry-runtime returns `as f64` (gen_rest.rs:166 comment); NUM joined with the call's own result node | +| `gen_rest.rs:545` | seed | Expr::DateNow: perry-runtime date.rs js_date_now (f64 ms) | +| `gen_rest.rs:549` | seed | Expr::DateParse: perry-runtime date.rs js_date_parse (f64, NaN on failure = canonical f64 NaN) | +| `gen_rest.rs:556` | seed | Expr::DateUtc: perry-runtime date.rs js_date_utc (f64) | +| `gen_rest.rs:617` | seed | HIR Math intrinsics (prim_kids): codegen expr/math_simple.rs (llvm.*.f64) and perry-runtime math.rs js_math_* (f64 arithmetic) | +| `gen_rest.rs:637` | seed | `math_range` result (ToNumber of a Math operand): codegen expr/dispatch.rs lower_math_operand / js_math_to_number (f64) | +| `gen_rest.rs:644` | seed | ParseInt/ParseFloat/NumberCoerce: perry-runtime builtins/numbers.rs js_parse_int, js_parse_float, js_number_coerce (f64) | +| `gen_rest.rs:660` | seed | `+x` / ToNumber helper result: codegen unary lowering (fadd 0 / js_number_coerce, f64) | +| `builtins.rs:1000` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins.rs:1640` | seed | timers: ret_tok(timer) + NUM, so NUM|timer object, never NUM-only. The NUM bit is a spurious over-approximation (REVIEW-p3 D2.5) | +| `builtins.rs:1801` | seed | Array.from map-callback index: perry-runtime array iteration passes the index `as f64` | +| `builtins.rs:1833` | seed | Array.from `this` length: NUM|UNDEF | +| `builtins.rs:1917` | seed | Math namespace calls: codegen math_simple.rs intrinsics; perry-runtime math.rs js_math_round/js_math_trunc/js_math_sign/js_math_log1p/js_math_random (f64 arithmetic). Split per member at B1 | +| `gen.rs:531` | seed | the shared NUM constant node (prim_const[0]); values flowing into it are audited at their own seed rows | +| `gen.rs:1286` | seed | `arguments.length`: codegen lowers the argument count `as f64` (sitofp) | +| `gen_call.rs:1063` | seed | js_iterator_delegate_kind (perry-runtime array/iterator_close.rs): returns a small kind code `as f64` | +| `gen_expr.rs:889` | seed | `>>>` result: codegen expr/binary.rs lshr + uitofp (f64) | +| `gen_expr.rs:899` | seed | unary `+`: codegen ToNumber (f64) | +| `gen_expr.rs:1522` | seed | numeric enum member value: an HIR numeric literal, lowered as a double constant | +| `solve.rs:46` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:236` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:776` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:790` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:1828` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:1840` | read/map | read: tests or masks the fact, seeds nothing | +| `solve.rs:1851` | read/map | read: tests or masks the fact, seeds nothing | +| `bindings.rs:193` | read/map | read: tests or masks the fact, seeds nothing | +| `bindings.rs:224` | read/map | read: tests or masks the fact, seeds nothing | +| `bindings.rs:392` | read/map | read: tests or masks the fact, seeds nothing | +| `bindings.rs:470` | read/map | read: tests or masks the fact, seeds nothing | +| `bindings.rs:589` | read/map | read: tests or masks the fact, seeds nothing | +| `dump.rs:470` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/arr_len.rs:42` | seed | array birth length `new Array(k)`: the header length `uitofp` | +| `solve/lookup.rs:1289` | seed | array `.length` through the chain: header length `uitofp` (array/indexing.rs js_array_length -> u32) | +| `solve/fields.rs:40` | seed | RegExp `lastIndex` at birth: the Number 0 | +| `solve/native_value.rs:171` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/apply.rs:1309` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/apply.rs:1337` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/apply.rs:1456` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/apply.rs:1472` | read/map | read: tests or masks the fact, seeds nothing | +| `solve/apply.rs:1473` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1486` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1492` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1502` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1503` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1513` | read/map | read: tests or masks the fact, seeds nothing | +| `gen/primitives.rs:64` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `gen/primitives.rs:100` | seed | computed enum member: NUM|STR, not NUM-only (no consumer drops a test) | +| `gen/primitives.rs:101` | seed | numeric enum member: a double constant | +| `gen/primitives.rs:110` | seed | number literal without an interval: a double constant | +| `gen/primitives.rs:121` | seed | number literal with its point interval: a double constant | +| `gen/primitives.rs:194` | seed | Math rule result (unary): codegen math intrinsic / js_math_* (f64) | +| `gen/primitives.rs:201` | seed | Math rule result (n-ary min/max): codegen logical_collections.rs lower_math_minmax_n (llvm.minimum/maximum) | +| `gen/narrow.rs:810` | seed | narrowing bound node: an interval-only node over program Numbers; no runtime value | +| `gen/glue.rs:228` | seed | arrayRecordIndex default: codegen array_record lowering, an i32 counter `sitofp` | +| `gen/glue.rs:230` | seed | arrayRecordLength: codegen array_record payload length `uitofp` | +| `gen/glue.rs:233` | seed | arrayRecordForUpdate: codegen `mode ? 0 : update` over program Numbers | +| `builtins/loads.rs:292` | seed | string computed key: STR|NUM|UNDEF|BOOL, not NUM-only | +| `builtins/loads.rs:381` | seed | global `NaN`/`Infinity`/`undefined`: NUM|UNDEF; the constants are canonical doubles | +| `builtins/loads.rs:633` | seed | function `length`: closure arity `as f64`; joined with own fields (lookup.rs:221; REVIEW-p3 D2.1) | +| `builtins/loads.rs:667` | seed | class `length`: as above | +| `builtins/loads.rs:672` | seed | Array `length`: header length `uitofp` / array/indexing.rs js_array_length | +| `builtins/loads.rs:677` | seed | array computed key: UNDEF|NUM plus a function token, not NUM-only | +| `builtins/loads.rs:726` | seed | `.byteOffset` (B1-exposed): typedarray/access.rs, integer `as f64` | +| `builtins/loads.rs:731` | seed | buffer `size`/`length`/`byteLength`/`byteOffset`: typedarray/access.rs js_typed_array_length, integer `as f64` | +| `builtins/members.rs:81` | seed | Function.prototype `length` member: as loads.rs:633 | +| `builtins/members.rs:111` | seed | Array.prototype `length` member: as loads.rs:672 | +| `builtins/members.rs:147` | seed | String.prototype `length`: string length `as f64` | +| `builtins/members.rs:242` | seed | Map/Set `size`: perry-runtime map.rs/set.rs js_map_size/js_set_size, `as f64` | +| `builtins/members.rs:335` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins/members.rs:663` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins/members.rs:748` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins/members.rs:1373` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins/native_protocol.rs:61` | seed | EventEmitter `_` bookkeeping keys: NUM|UNDEF | +| `builtins/native_protocol.rs:675` | seed | EventEmitter listenerCount/getMaxListeners: perry-runtime event emitter counts `as f64` | +| `builtins/effects.rs:385` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | +| `builtins/constructors.rs:187` | seed | typed-array elements: NUM|BIGINT, not NUM-only | +| `builtins/string_methods.rs:94` | seed | replacer argument: STR|NUM|UNDEF, not NUM-only | +| `builtins/string_methods.rs:215` | seed | charCodeAt/indexOf/lastIndexOf: perry-runtime string/char_ops.rs js_string_char_code_at, string/slice_ops.rs js_string_index_of/js_string_last_index_of (i32/u16 `as f64`; NaN for charCodeAt out of range = canonical NaN) | +| `builtins/string_methods.rs:216` | seed | codePointAt: NUM|UNDEF; string/char_ops.rs js_string_code_point_at | +| `builtins/string_methods.rs:282` | seed | localeCompare: string/compare.rs js_string_locale_compare (-1/0/1 `as f64`) | +| `builtins/string_methods.rs:350` | seed | @@search: RegExp search index `as f64` (string regexp path) | +| `builtins/string_methods.rs:359` | seed | match record `index`: regexp match index `as f64` | +| `builtins/string_methods.rs:436` | seed | split lastIndex: a modeled internal value (spec step), integer | +| `builtins/string_methods.rs:451` | seed | split capture index: modeled internal integer | +| `builtins/collections.rs:182` | seed | generator resume op code: a small integer the codegen generator step passes `as f64` | +| `builtins/collections.rs:574` | seed | entries() index field: the iteration index `as f64` | +| `builtins/collections.rs:588` | seed | callback index: array/iter_methods.rs, `as f64` | +| `builtins/collections.rs:700` | seed | constructed length: header length `uitofp` | +| `builtins/collections.rs:719` | seed | species length argument: an integer `as f64` | +| `builtins/collections.rs:885` | seed | push/pop/shift/unshift/splice new length on array-likes: array/push_pop.rs, array/splice_slice.rs; also above 2^32 a plain double (REVIEW-p3 p4) | +| `builtins/collections.rs:1003` | seed | index argument/result of the index-walking methods: array/search.rs js_array_indexOf_f64 -> i32, then codegen sitofp | +| `builtins/collections.rs:1050` | seed | push(spread) result: as collections.rs:885 | +| `builtins/collections.rs:1177` | seed | index-returning array methods (indexOf/findIndex/...): array/search.rs, array/iter_methods.rs js_array_findIndex (i32 `as f64`) | +| `builtins/collections.rs:1194` | seed | lastIndexOf/findIndexOf: NUM|BOOL, not NUM-only | +| `builtins/collections.rs:1410` | seed | Date getters/valueOf: perry-runtime date.rs js_date_get_* (f64) | +| `builtins/collections.rs:1438` | seed | Date setters' result: date.rs (f64 time value) | +| `builtins/collections.rs:1536` | seed | TextEncoder.encode result elements: as gen_rest/web.rs:36 | +| `builtins/collections.rs:1543` | seed | encodeInto read/written: as gen_rest/web.rs:51 | +| `builtins/collections.rs:1561` | seed | Timer @@toPrimitive: the timer id `as f64` | +| `builtins/typed_array_methods.rs:69` | seed | buffer length: typedarray/access.rs, integer `as f64` | +| `builtins/typed_array_methods.rs:76` | seed | fresh typed-array elements: NUM|BIGINT, not NUM-only | +| `builtins/typed_array_methods.rs:91` | seed | typed-array callback index: typedarray/iterate.rs, `as f64` | +| `builtins/typed_array_methods.rs:93` | seed | typed-array element: NUM|BIGINT, not NUM-only | +| `builtins/typed_array_methods.rs:113` | seed | at(): NUM|BIGINT|UNDEF, not NUM-only | +| `builtins/typed_array_methods.rs:142` | seed | indexOf/lastIndexOf/findIndex results: typedarray/iterate.rs js_typed_array_index_of (i32 `as f64`) | +| `builtins/typed_array_methods.rs:175` | seed | find/findLast: NUM|BIGINT|UNDEF, not NUM-only | +| `builtins/typed_array_methods.rs:176` | seed | other typed-array Number results (length, reduce of numbers...): typedarray/*.rs, f64 | +| `derive/element.rs:120` | read/map | read: tests or masks the fact, seeds nothing | +| `gen_rest/web.rs:36` | seed | TextEncoder.encode result elements: a Uint8Array; typed-array element reads canonicalize (perry-runtime typedarray/access.rs; REVIEW-p3 t4/t5) | +| `gen_rest/web.rs:51` | seed | TextEncoder.encodeInto `read`/`written`: perry-runtime text.rs, counts `as f64` | +| `gen_rest/builtin_objects.rs:375` | seed | typed-array view elements (shared buffer): typed-array reads canonicalize NaN (typedarray/access.rs; t4/t5) | +| `gen_rest/builtin_objects.rs:393` | seed | typed-array elements: as above | +| `gen_rest/nodes.rs:33` | seed | typed-array elements: NUM|BIGINT, not NUM-only | +| `gen_rest/collections.rs:15` | seed | push(...spread) new length: array/push_pop.rs, `as f64` | +| `gen_rest/collections.rs:43` | seed | copyWithin operand ToNumber: operand conversion, not a value the program reads | +| `gen_rest/collections.rs:48` | seed | copyWithin end operand ToNumber: as above | +| `gen_rest/collections.rs:63` | seed | callback index argument: perry-runtime array/iter_methods.rs passes the index `as f64` | +| `gen_rest/collections.rs:152` | seed | groupBy callback index: as above | +| `gen_rest/collections.rs:213` | seed | callback index (from/forEach): as above | +| `canonical_numbers.rs:49` | read/map | read: tests or masks the fact, seeds nothing (P3 B1's element-value test) | +| `canonical_numbers.rs:50` | read/map | read: tests or masks the fact, seeds nothing (P3 B1's element-value test) | diff --git a/crates/perry-wpfacts/src/bindings.rs b/crates/perry-wpfacts/src/bindings.rs index a0ff8497326..0839e7be657 100644 --- a/crates/perry-wpfacts/src/bindings.rs +++ b/crates/perry-wpfacts/src/bindings.rs @@ -336,6 +336,10 @@ pub struct ModuleBindings { /// Method call byte offsets proven field element reads /// (`field_read_calls`, b1), sorted. pub field_read_calls: Vec<(u32, crate::field_read_calls::FieldReadCall)>, + /// Element reads whose value is a plain Number at every copy, by + /// `element_offset`, sorted; `true` when the value may also be + /// `undefined` (`canonical_numbers`, P3 B1). + pub number_element_reads: Vec<(u32, bool)>, } /// An element read `a[i]` whose every receiver is a program Array that diff --git a/crates/perry-wpfacts/src/canonical_numbers.rs b/crates/perry-wpfacts/src/canonical_numbers.rs new file mode 100644 index 00000000000..e5206c8a84f --- /dev/null +++ b/crates/perry-wpfacts/src/canonical_numbers.rs @@ -0,0 +1,65 @@ +//! P3 B1 (DESIGN-p3-canonical-numbers rev 3.1 §2): element reads whose value +//! the facts prove a Number, or a Number or `undefined`. +//! +//! The fact is the NUM declaration itself (REVIEW-p3 R2): a node holds only +//! the Number primitive when every value reaching it is a program Number +//! (literals and arithmetic, which codegen lowers to plain doubles) or comes +//! from a builtin whose NUM seed is audited against its runtime producer +//! (`NUM_SEEDS.md`, checked by `tests/num_seeds_audit.rs`). There is no +//! separate encoding bit: a value the solver cannot see is ⊤, never NUM-only. +//! +//! An element read qualifies when, at every copy of the read, its value node +//! holds Number (and possibly `undefined`, for a hole or an index past the +//! end) and nothing else: no other primitive and no token. A copy that does +//! not qualify drops the site. + +use crate::bindings::ModuleBindings; +use crate::gen::Gen; +use crate::model::prim; + +/// `PERRY_FACTS_CANONICAL_NUMBERS=0` exports nothing. +pub fn enabled() -> bool { + !matches!( + std::env::var("PERRY_FACTS_CANONICAL_NUMBERS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +/// Fill `ModuleBindings::number_element_reads` (module docs). +pub(crate) fn collect(g: &Gen<'_>, bindings: &mut [ModuleBindings]) { + if g.m.aborted { + return; + } + // (module, offset) -> Some(may be undefined), or None once a copy fails. + let mut sites: std::collections::BTreeMap<(u32, u32), Option> = Default::default(); + // A summarized body's value node has one copy per instance + // (`Inst::fresh`); the fact must hold for each of them too. + let mut copies: std::collections::HashMap> = Default::default(); + for inst in 0..g.m.sum.insts.len() as u32 { + g.m.inst_copies(inst, |orig, copy| { + copies.entry(orig).or_default().push(copy) + }); + } + let nodes = g.element_values.iter().flat_map(|&(mi, offset, n)| { + std::iter::once(n) + .chain(copies.get(&n).into_iter().flatten().copied()) + .map(move |c| (mi, offset, c)) + }); + for (mi, offset, n) in nodes { + let node = &g.m.nodes[n as usize]; + let number = node.pts.is_empty() + && node.prim & prim::NUM != 0 + && node.prim & !(prim::NUM | prim::UNDEF) == 0; + let undef = node.prim & prim::UNDEF != 0; + let entry = sites.entry((mi, offset)).or_insert(Some(false)); + *entry = match (*entry, number) { + (Some(prev), true) => Some(prev || undef), + _ => None, + }; + } + for ((mi, offset), fact) in sites { + if let (Some(undef), Some(mb)) = (fact, bindings.get_mut(mi as usize)) { + mb.number_element_reads.push((offset, undef)); + } + } +} diff --git a/crates/perry-wpfacts/src/gen.rs b/crates/perry-wpfacts/src/gen.rs index e730b139c27..f82cb917b4d 100644 --- a/crates/perry-wpfacts/src/gen.rs +++ b/crates/perry-wpfacts/src/gen.rs @@ -150,6 +150,9 @@ pub struct Gen<'a> { pub cjs_unknown_path: NodeId, pub read_sites: Vec, pub(crate) element_sites: Vec, + /// `(module, element_offset, value node)` of every `IndexGet` copy + /// (`canonical_numbers`, P3 B1). + pub(crate) element_values: Vec<(u32, u32, NodeId)>, /// Exports whose value is a class declaration (design §11.2). pub export_class: FxHashMap<(u32, Atom), TmplId>, /// Exports whose value is a function declaration, and the published @@ -595,6 +598,7 @@ impl<'a> Gen<'a> { cjs_unknown_path, read_sites: Vec::new(), element_sites: Vec::new(), + element_values: Vec::new(), export_class: FxHashMap::default(), export_fn: FxHashMap::default(), fn_contracts: Vec::new(), diff --git a/crates/perry-wpfacts/src/gen_expr.rs b/crates/perry-wpfacts/src/gen_expr.rs index 2a62bdfa98e..5752f8c8488 100644 --- a/crates/perry-wpfacts/src/gen_expr.rs +++ b/crates/perry-wpfacts/src/gen_expr.rs @@ -651,6 +651,11 @@ impl<'a> Gen<'a> { pub(crate) fn expr(&mut self, fx: &Fx, e: &Expr) -> NodeId { let prev = std::mem::replace(&mut self.m.cur_fn, fx.f); let n = self.expr_inner(fx, e); + if let Expr::IndexGet { element_offset, .. } = e { + if *element_offset != 0 { + self.element_values.push((fx.module, *element_offset, n)); + } + } if self.diagnostic.is_some() { self.diagnostic_expr(fx, e, n); } diff --git a/crates/perry-wpfacts/src/lib.rs b/crates/perry-wpfacts/src/lib.rs index 18f0c6765f7..63a6879972d 100644 --- a/crates/perry-wpfacts/src/lib.rs +++ b/crates/perry-wpfacts/src/lib.rs @@ -18,6 +18,7 @@ pub mod bindings; mod builtins; +pub mod canonical_numbers; mod ctor_phase; mod decl_units; pub mod delta; @@ -874,6 +875,9 @@ fn compute_act<'a>( cfg.sabotage.as_deref() == Some("fieldreadcall"), ); } + if canonical_numbers::enabled() { + canonical_numbers::collect(&g, &mut bindings); + } if g.m.aborted { // Over budget: claim nothing (sound), keep the shapes for the dump. templates.clear(); diff --git a/crates/perry-wpfacts/tests/num_seeds_audit.rs b/crates/perry-wpfacts/tests/num_seeds_audit.rs new file mode 100644 index 00000000000..db871064b44 --- /dev/null +++ b/crates/perry-wpfacts/tests/num_seeds_audit.rs @@ -0,0 +1,109 @@ +//! P3 B1 (DESIGN-p3 rev 3.1 §2.2, REVIEW-p3 D2): every non-test use of +//! `prim::NUM` in perry-wpfacts has a row in `NUM_SEEDS.md` citing the runtime +//! function or lowering that produces the value. The counts are compared per +//! file, so moving a line does not break the test; adding a seed does, until +//! its row (and its audit) is added. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `prim::NUM` uses outside `#[cfg(test)]` modules and test files. +fn source_counts(root: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path: PathBuf = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + continue; + } + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + if !name.ends_with(".rs") + || name == "tests.rs" + || name.ends_with("_tests.rs") + || name.ends_with("_test.rs") + || rel.split('/').any(|c| c == "tests") + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + let mut depth: i64 = 0; + let mut test_depth: Option = None; + let mut pending = false; + let mut n = 0; + for line in text.lines() { + let s = line.trim(); + if s.starts_with("#[cfg(test)]") { + pending = true; + } + if pending && s.starts_with("mod ") && s.ends_with(';') + || pending && s.starts_with("pub mod ") && s.ends_with(';') + { + pending = false; + } + if pending && line.contains('{') && test_depth.is_none() { + test_depth = Some(depth); + pending = false; + } + if test_depth.is_none() && line.contains("prim::NUM") && !s.starts_with("//") { + n += 1; + } + depth += line.matches('{').count() as i64 - line.matches('}').count() as i64; + if let Some(d) = test_depth { + if depth <= d { + test_depth = None; + } + } + } + if n > 0 { + out.insert(rel, n); + } + } + } + out +} + +/// Rows of the audit table, per file. +fn table_counts(table: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for line in table.lines() { + let Some(rest) = line.strip_prefix("| `") else { + continue; + }; + let loc = rest.split('`').next().unwrap(); + let file = loc.rsplit_once(':').map_or(loc, |(f, _)| f); + *out.entry(file.to_string()).or_insert(0) += 1; + } + out +} + +#[test] +fn every_num_seed_has_an_audit_row() { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let table = std::fs::read_to_string(crate_dir.join("NUM_SEEDS.md")).unwrap(); + let rows = table_counts(&table); + let uses = source_counts(&crate_dir.join("src")); + assert_eq!( + uses, rows, + "prim::NUM uses (left) and NUM_SEEDS.md rows (right) differ: audit the new seed, cite its runtime producer, and add its row" + ); +} + +/// Every seed row cites a producer (no empty or placeholder citations). +#[test] +fn every_seed_row_cites_a_producer() { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let table = std::fs::read_to_string(crate_dir.join("NUM_SEEDS.md")).unwrap(); + for line in table.lines().filter(|l| l.starts_with("| `")) { + let cols: Vec<&str> = line.split(" | ").collect(); + assert!(cols.len() >= 3, "malformed row: {line}"); + let cit = cols[2].trim_end_matches(" |").trim(); + assert!(cit.len() > 12, "row without a citation: {line}"); + } +} diff --git a/crates/perry/src/commands/compile/compile_env.rs b/crates/perry/src/commands/compile/compile_env.rs index 989c80f67b2..22c9af40806 100644 --- a/crates/perry/src/commands/compile/compile_env.rs +++ b/crates/perry/src/commands/compile/compile_env.rs @@ -68,6 +68,7 @@ pub(super) const COMPILE_ENV_VARS: &[&str] = &[ "PERRY_FACTS_ARRAY_LENGTHS", "PERRY_FACTS_CONST_LENGTH", "PERRY_FACTS_FIELD_READ_CALLS", + "PERRY_FACTS_CANONICAL_NUMBERS", "PERRY_FACTS_GUARD_PRESENCE", "PERRY_FACTS_GUARD_RANGES", "PERRY_FACTS_SITE_ARGS", diff --git a/crates/perry/src/commands/compile/program_facts.rs b/crates/perry/src/commands/compile/program_facts.rs index 300fdcff778..2039d583efb 100644 --- a/crates/perry/src/commands/compile/program_facts.rs +++ b/crates/perry/src/commands/compile/program_facts.rs @@ -532,6 +532,8 @@ fn elision_for(pf: &perry_wpfacts::ProgramFacts, path: &str) -> perry_codegen::E out.call_arg_ranges.insert(*offset, iv.clone()); } out.number_calls.extend(mb.number_calls.iter().copied()); + out.number_element_reads + .extend(mb.number_element_reads.iter().copied()); for (offset, c) in &mb.field_read_calls { let default = match &c.default { None => None, diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index 1f8d21cb32d..2ed08ca0872 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -28,7 +28,7 @@ crates/perry-codegen/src/codegen/mod.rs 3565 # 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 2826 +crates/perry-codegen/src/expr/mod.rs 2827 # Loop versioning and representation evidence share lowering state; decomposition is tracked in #1435. crates/perry-codegen/src/stmt/loops.rs 8290 # Representation-aware map core; further decomposition is tracked in #1435. diff --git a/scripts/p3-canonnum-runtime.sh b/scripts/p3-canonnum-runtime.sh new file mode 100755 index 00000000000..c13dce591e9 --- /dev/null +++ b/scripts/p3-canonnum-runtime.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# P3 B1 negative control (DESIGN-p3 rev 3.1 §5, REVIEW-p3 D4): build a runtime +# whose `js_math_round` returns an INT32-boxed word, for the S0 entry +# `canonical-number-negative` (scripts/test-verify-facts-s0.py +# --canonnum-runtime /release). +# +# scripts/p3-canonnum-runtime.sh +# +# The switch is build-time (`option_env!` in perry-runtime/src/math.rs), so the +# archive must really be rebuilt: the script fails (STALE-ARCHIVE) when +# libperry_runtime.a did not change. +set -euo pipefail +target=${1:?usage: p3-canonnum-runtime.sh } +archive="$target/release/libperry_runtime.a" +before=$(stat -c %Y "$archive" 2>/dev/null || stat -f %m "$archive" 2>/dev/null || echo 0) +PERRY_FACTS_SABOTAGE=canonnum CARGO_TARGET_DIR="$target" cargo build-perry --release +after=$(stat -c %Y "$archive" 2>/dev/null || stat -f %m "$archive") +if [ "$after" = "$before" ]; then + echo "STALE-ARCHIVE: $archive was not rebuilt" >&2 + exit 1 +fi +bash "$(dirname "$0")/gc_call_effects/sidecars.sh" "$target/release" +echo "canonnum runtime: $target/release" diff --git a/scripts/test-verify-facts-s0.py b/scripts/test-verify-facts-s0.py index e5ce8628475..0c4585222d6 100644 --- a/scripts/test-verify-facts-s0.py +++ b/scripts/test-verify-facts-s0.py @@ -39,6 +39,9 @@ def main(): parser.add_argument("--perry", type=Path, required=True) parser.add_argument("--node", default="node") parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--canonnum-runtime", type=Path, + help="target/release of a runtime built with PERRY_FACTS_SABOTAGE=canonnum " + "(scripts/p3-canonnum-runtime.sh); without it only the correct arm runs") args = parser.parse_args() args.perry = args.perry.resolve() args.out = args.out.resolve() @@ -120,6 +123,22 @@ def oracle(source, *argv): assert observed.returncode == 0 and observed.stdout == oracle(source), observed save(arm, status=observed.returncode) + # P3 B1 (DESIGN-p3 rev 3.1 §5, REVIEW-p3 D4): this arm sabotages the + # RUNTIME, not the facts. `js_math_round` built with + # PERRY_FACTS_SABOTAGE=canonnum returns an INT32-boxed word, and the + # canonical-number witness must report it with rc 86 exactly. + source = fixtures / "canonical-number-negative.ts" + exe, e = compile_(source, "canonical-number-negative-correct") + observed = observe(exe, e) + assert observed.returncode == 0 and observed.stdout == oracle(source), observed + save("canonical-number-negative-correct", status=0) + if args.canonnum_runtime: + runtime = str(args.canonnum_runtime.resolve()) + exe, e = compile_(source, "canonical-number-negative-false", PERRY_RUNTIME_DIR=runtime) + value = report(observe(exe, e)) + assert value["predicate"] == "canonical number", value + save("canonical-number-negative-false", status=86) + source = fixtures / "parallel-bodies.ts" exe, e = compile_(source, "parallel-bodies") observed = observe(exe, e) diff --git a/scripts/verify_facts_s0/canonical-number-negative.ts b/scripts/verify_facts_s0/canonical-number-negative.ts new file mode 100644 index 00000000000..14dd21a42bb --- /dev/null +++ b/scripts/verify_facts_s0/canonical-number-negative.ts @@ -0,0 +1,22 @@ +// P3 B1 (DESIGN-p3 rev 3.1 §5): `Math.round` is a declared-NUM builtin. A +// runtime built with PERRY_FACTS_SABOTAGE=canonnum returns an INT32-boxed word +// from it. The value is stored into an array, and the element read the facts +// prove a plain Number reaches a compare (C1), an arithmetic leaf (C2) and a +// Number parameter (C4): the canonical-number witness must report it (rc 86). +// The input is not a constant, so `Math.round` really runs. +function twice(x: number): number { + return x * 2; +} +function run(seed: number): string { + const vals: number[] = []; + for (let i = 0; i < 4; i++) vals.push(Math.round(seed + i + 0.4)); + let hits = 0; + let total = 0; + for (let i = 0; i < vals.length; i++) { + if (vals[i] === seed + i) hits++; + total = total + vals[i] * 3; + total += twice(vals[i]); + } + return hits + " " + total; +} +console.log(run(process.argv.length)); diff --git a/tests/parity/language/number/test_p3_canonical_numbers.ts b/tests/parity/language/number/test_p3_canonical_numbers.ts new file mode 100644 index 00000000000..5cfd5d71261 --- /dev/null +++ b/tests/parity/language/number/test_p3_canonical_numbers.ts @@ -0,0 +1,86 @@ +// P3 B1 controls (DESIGN-p3 rev 3.1 §5): compares and arithmetic over element +// reads the facts prove plain Numbers (C1, C2) and Number parameters (C4). +// Every result must match node exactly, with the fact on, off and in check +// mode. Loops run inside functions with parameter bounds. + +// Holey arrays and reads past the end: Number-or-undefined (REVIEW-p3 D1). +const holey = [1, , 3]; +holey.length = 10; +function same(i: number, j: number): boolean { return holey[i] === holey[j]; } +function differ(i: number, j: number): boolean { return holey[i] !== holey[j]; } +console.log(same(5, 6), same(1, 5), same(1, 20), same(0, 0), same(0, 2), differ(5, 6), differ(0, 2)); +function rel(i: number): string { + const v = holey[i]; + return [v < 1, v <= 1, v > 1, v >= 1, !(v < 1)].join(","); +} +console.log(rel(0), rel(1), rel(2), rel(15)); + +// NaN, -0, +0 (both operands proven Numbers). +const z = [0, -0, NaN, 0 / 0, Infinity, -Infinity]; +function eq(i: number, j: number): boolean { return z[i] === z[j]; } +console.log(eq(0, 1), eq(2, 2), eq(2, 3), eq(3, 3), eq(4, 4), eq(4, 5), eq(0, 9), eq(9, 9)); +function zrel(n: number): string { + let s = ""; + for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) s += (z[i] < z[j] ? "<" : z[i] > z[j] ? ">" : "="); + return s; +} +console.log(zrel(z.length)); + +// `?? d` over a Number-or-undefined read is a Number: arithmetic (C2) and compare. +const counts = [3, , 5]; +function sum(n: number): number { + let s = 0; + for (let i = 0; i < n; i++) s = s + (counts[i] ?? 0) * 2 - (counts[i] ?? 1); + return s; +} +console.log(sum(5), sum(0)); + +// Builtin NUM producers stored and read back: Math.round, .length, push, indexOf. +function produced(k: number): number[] { + const out: number[] = []; + out.push(Math.round(k + 0.4)); + out.push(out.length); + out.push([5, 6, 7].indexOf(k)); + out.push(out.push(9)); + return out; +} +function useProduced(k: number): string { + const p = produced(k); + let s = ""; + for (let i = 0; i < p.length; i++) s += (p[i] === k ? "k" : p[i] > k ? "+" : "-") + (p[i] * 3 | 0) + " "; + return s; +} +console.log(useProduced(6), useProduced(2)); + +// int32 overflow to double, through arithmetic on proven reads. +const big = [2147483647, -2147483648, 4294967295]; +function wrap(i: number): string { + const v = big[i]; + return [v + 1, v * 2, v | 0, v >>> 0, v - 1 === 2147483646].join(","); +} +console.log(wrap(0), wrap(1), wrap(2)); + +// A Number parameter (C4) fed by proven reads and literals. +function half(x: number): number { return x / 2; } +function halves(n: number): number { + let s = 0; + for (let i = 0; i < n; i++) s += half(z[i % 2]) + half(counts[0] ?? 0); + return s; +} +console.log(halves(4), Object.is(half(-0), -0)); + +// Values the facts do not cover keep their tests: JSON.parse through any, BigInt. +function parsed(text: string): string { + const xs: any[] = JSON.parse(text); + let s = ""; + for (let i = 0; i < xs.length; i++) s += (xs[i] === 1) + ":" + (xs[i] + 1) + " "; + return s; +} +console.log(parsed("[1, \"1\", null, 2.5]")); +const bigs = [1n, 2n]; +function bigsum(n: number): bigint { + let s = 0n; + for (let i = 0; i < n; i++) s = s + bigs[i] * 2n; + return s; +} +console.log(String(bigsum(2))); From f6ea04a4d71b863259431e676ada1289a86ce76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 05:27:50 +0100 Subject: [PATCH 3/8] NUM_SEEDS: fix the typed-array indexOf citation; cite the dynamic add arm for arithmetic results (spot check) --- crates/perry-wpfacts/NUM_SEEDS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/perry-wpfacts/NUM_SEEDS.md b/crates/perry-wpfacts/NUM_SEEDS.md index 7e5fd598702..6b10f97c51e 100644 --- a/crates/perry-wpfacts/NUM_SEEDS.md +++ b/crates/perry-wpfacts/NUM_SEEDS.md @@ -50,11 +50,11 @@ There are no non-test callers of `JSValue::int32` in perry-runtime or perry-stdl | `solve/apply.rs:1337` | read/map | read: tests or masks the fact, seeds nothing | | `solve/apply.rs:1456` | read/map | read: tests or masks the fact, seeds nothing | | `solve/apply.rs:1472` | read/map | read: tests or masks the fact, seeds nothing | -| `solve/apply.rs:1473` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | -| `solve/apply.rs:1486` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | -| `solve/apply.rs:1492` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | -| `solve/apply.rs:1502` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | -| `solve/apply.rs:1503` | seed | result of a ToNumber/ToNumeric operator that codegen lowers itself (expr/binary.rs, expr/unary.rs: fadd/fsub/fmul/fdiv, toint32 + sitofp); a raw double by the lowering contract | +| `solve/apply.rs:1473` | seed | numeric result of an arithmetic or `+` operator: codegen expr/binary.rs and expr/unary.rs lower it inline (fadd/fsub/fmul/fdiv, toint32 + sitofp), and the dynamic arm calls perry-runtime value/dynamic_arith.rs js_dynamic_string_or_number_add / dynamic_add_non_plain, which decodes an INT32 operand (`as_int32() as f64`) and returns `a_num + b_num` (f64) | +| `solve/apply.rs:1486` | seed | numeric result of an arithmetic or `+` operator: codegen expr/binary.rs and expr/unary.rs lower it inline (fadd/fsub/fmul/fdiv, toint32 + sitofp), and the dynamic arm calls perry-runtime value/dynamic_arith.rs js_dynamic_string_or_number_add / dynamic_add_non_plain, which decodes an INT32 operand (`as_int32() as f64`) and returns `a_num + b_num` (f64) | +| `solve/apply.rs:1492` | seed | numeric result of an arithmetic or `+` operator: codegen expr/binary.rs and expr/unary.rs lower it inline (fadd/fsub/fmul/fdiv, toint32 + sitofp), and the dynamic arm calls perry-runtime value/dynamic_arith.rs js_dynamic_string_or_number_add / dynamic_add_non_plain, which decodes an INT32 operand (`as_int32() as f64`) and returns `a_num + b_num` (f64) | +| `solve/apply.rs:1502` | seed | numeric result of an arithmetic or `+` operator: codegen expr/binary.rs and expr/unary.rs lower it inline (fadd/fsub/fmul/fdiv, toint32 + sitofp), and the dynamic arm calls perry-runtime value/dynamic_arith.rs js_dynamic_string_or_number_add / dynamic_add_non_plain, which decodes an INT32 operand (`as_int32() as f64`) and returns `a_num + b_num` (f64) | +| `solve/apply.rs:1503` | seed | numeric result of an arithmetic or `+` operator: codegen expr/binary.rs and expr/unary.rs lower it inline (fadd/fsub/fmul/fdiv, toint32 + sitofp), and the dynamic arm calls perry-runtime value/dynamic_arith.rs js_dynamic_string_or_number_add / dynamic_add_non_plain, which decodes an INT32 operand (`as_int32() as f64`) and returns `a_num + b_num` (f64) | | `solve/apply.rs:1513` | read/map | read: tests or masks the fact, seeds nothing | | `gen/primitives.rs:64` | read/map | family/realm/const mapping: no value of its own; the seeds that use it are audited at their own rows | | `gen/primitives.rs:100` | seed | computed enum member: NUM|STR, not NUM-only (no consumer drops a test) | @@ -115,7 +115,7 @@ There are no non-test callers of `JSValue::int32` in perry-runtime or perry-stdl | `builtins/typed_array_methods.rs:91` | seed | typed-array callback index: typedarray/iterate.rs, `as f64` | | `builtins/typed_array_methods.rs:93` | seed | typed-array element: NUM|BIGINT, not NUM-only | | `builtins/typed_array_methods.rs:113` | seed | at(): NUM|BIGINT|UNDEF, not NUM-only | -| `builtins/typed_array_methods.rs:142` | seed | indexOf/lastIndexOf/findIndex results: typedarray/iterate.rs js_typed_array_index_of (i32 `as f64`) | +| `builtins/typed_array_methods.rs:142` | seed | indexOf/lastIndexOf/findIndex results: perry-runtime object/native_call_method/typed_array.rs:271 (`js_array_indexOf_jsvalue(..) as f64`, `js_array_last_index_of_jsvalue(..) as f64`), reached from object/typed_array_proto_thunks.rs ta_index_of_thunk / ta_last_index_of_thunk | | `builtins/typed_array_methods.rs:175` | seed | find/findLast: NUM|BIGINT|UNDEF, not NUM-only | | `builtins/typed_array_methods.rs:176` | seed | other typed-array Number results (length, reduce of numbers...): typedarray/*.rs, f64 | | `derive/element.rs:120` | read/map | read: tests or masks the fact, seeds nothing | From fb95dcae112a4a5b12fca8b66a6a79ca83d3b7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 05:58:18 +0100 Subject: [PATCH 4/8] codegen tests: count the exact int32 test (INT32_TAG word), not lshr 48 / 32766 (cherry picked from commit a58890bc017572de7d5c7046e3d430c5f852f2aa) --- crates/perry-codegen/src/expr/compare_chain_tests.rs | 10 ++++++++-- crates/perry-codegen/src/stmt/byte_result_tests.rs | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/expr/compare_chain_tests.rs b/crates/perry-codegen/src/expr/compare_chain_tests.rs index 7909567bed6..c48d2e7defc 100644 --- a/crates/perry-codegen/src/expr/compare_chain_tests.rs +++ b/crates/perry-codegen/src/expr/compare_chain_tests.rs @@ -78,7 +78,9 @@ fn forty_dynamic_comparisons_classify_once_and_share_the_slow_call() { let ir = ir(chain(40), vec![]); assert_eq!(ir.matches("call double @js_rel_ge(").count(), 1, "{ir}"); assert_eq!(ir.matches("sitofp i32").count(), 1, "{ir}"); - assert_eq!(ir.matches("lshr i64").count(), 1, "{ir}"); + // The one exact int32 test (`emit_int32_bits_test`), shared by the graph. + assert_eq!(ir.matches(crate::nanbox::INT32_TAG_I64).count(), 1, "{ir}"); + assert!(!ir.contains("lshr i64"), "{ir}"); assert!(ir.contains("phi i1"), "{ir}"); assert!( !ir.contains("phi double") || ir.matches("phi double").count() <= 2, @@ -170,7 +172,11 @@ fn mixed_graph_does_not_decode_a_constructively_numeric_local() { }; let good = ir(expr.clone(), vec![local.clone()]); assert_eq!(good.matches("sitofp i32").count(), 1, "{good}"); - assert_eq!(good.matches("lshr i64").count(), 1, "{good}"); + assert_eq!( + good.matches(crate::nanbox::INT32_TAG_I64).count(), + 1, + "{good}" + ); let dynamic = ir( expr, vec![ diff --git a/crates/perry-codegen/src/stmt/byte_result_tests.rs b/crates/perry-codegen/src/stmt/byte_result_tests.rs index 3dc452ba157..73210036756 100644 --- a/crates/perry-codegen/src/stmt/byte_result_tests.rs +++ b/crates/perry-codegen/src/stmt/byte_result_tests.rs @@ -108,7 +108,7 @@ fn admitted_general_byte_result_does_not_use_a_pointer_slot() { "Number-or-undefined was put in a GC pointer slot:\n{result}" ); assert_eq!( - result.matches("32766").count(), + result.matches(crate::nanbox::INT32_TAG_I64).count(), 1, "only the constant-side Number normalizer needs a compact tag check:\n{result}" ); From d993aab50eb97a78dbc31243706c2b1f34e4b9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 06:36:14 +0100 Subject: [PATCH 5/8] check_file_size: re-assert typed_feedback.rs (+1 kind arm) and typed_feedback/tests.rs (+15 exact-int32 test) --- scripts/check_file_size.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index 1f8d21cb32d..6d81f953a66 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -36,11 +36,11 @@ crates/perry-runtime/src/map.rs 3040 # Representation-aware set core; further decomposition is tracked in #1435. crates/perry-runtime/src/set.rs 2347 # Representation feedback core; further decomposition is tracked in #1435. -crates/perry-runtime/src/typed_feedback.rs 2436 +crates/perry-runtime/src/typed_feedback.rs 2437 # Native ABI proof cases; test organization is a separate follow-up. crates/perry-codegen/tests/native_proof_regressions.rs 15680 # Representation feedback regression cases; test organization is a separate follow-up. -crates/perry-runtime/src/typed_feedback/tests.rs 2289 +crates/perry-runtime/src/typed_feedback/tests.rs 2304 # Lowering report schema and its emitters; split emitters in a follow-up. crates/perry/src/commands/compile/lowering_report.rs 2321 # CallInliner argument and return remapping share one walker; split the traversal in a follow-up. From f84d3045eb43e261710915bd96e38ca778661122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 06:47:31 +0100 Subject: [PATCH 6/8] P3 B1 audit: the V8 and sqlite INT32-producer comments are stale; raw-bits tests for v8 wire int32 and node:sqlite integers --- .../src/child_process/v8_serde.rs | 4 ++ .../child_process/v8_serde_canonical_tests.rs | 43 +++++++++++++++++++ .../src/closure/dispatch/value_call.rs | 10 +++-- .../src/json/stringify_scalars.rs | 9 ++-- crates/perry-stdlib/src/sqlite/bind.rs | 5 +++ crates/perry-wpfacts/NUM_SEEDS.md | 9 ++++ 6 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 crates/perry-runtime/src/child_process/v8_serde_canonical_tests.rs diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index b58c3947dc2..1945a7ca25f 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -1275,3 +1275,7 @@ fn make_typed_array(kind: u8, bytes: &[u8]) -> f64 { }); cp_box_ptr(ta as *const u8) } + +#[cfg(test)] +#[path = "v8_serde_canonical_tests.rs"] +mod canonical_tests; diff --git a/crates/perry-runtime/src/child_process/v8_serde_canonical_tests.rs b/crates/perry-runtime/src/child_process/v8_serde_canonical_tests.rs new file mode 100644 index 00000000000..12d96812a8f --- /dev/null +++ b/crates/perry-runtime/src/child_process/v8_serde_canonical_tests.rs @@ -0,0 +1,43 @@ +//! P3 B1 audit (perry-wpfacts/NUM_SEEDS.md, "int32-box producers"): a V8 +//! wire int32 (`'I'` + zigzag varint), as `v8.serialize` writes for small +//! integers, deserializes to the raw double, never an INT32-tagged word. + +use super::{v8_deserialize, v8_serialize}; + +/// `0xFF 0x0F` (version 15), then `'I'` and the zigzag varint of `n`. +fn wire_int32(n: i32) -> Vec { + let mut z = ((n << 1) ^ (n >> 31)) as u32; + let mut out = vec![0xFF, 0x0F, b'I']; + loop { + let byte = (z & 0x7F) as u8; + z >>= 7; + if z == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } + out +} + +#[test] +fn a_wire_int32_deserializes_to_the_raw_double() { + for n in [0, 5, -7, 1, -1, i32::MAX, i32::MIN] { + let v = v8_deserialize(&wire_int32(n)); + assert_eq!(v.to_bits(), (n as f64).to_bits(), "wire int32 {n}"); + } +} + +#[test] +fn serialize_then_deserialize_keeps_small_integers_raw() { + for n in [0.0, 5.0, -7.0, 2147483647.0, -2147483648.0] { + let bytes = v8_serialize(n); + // Skip the version tag and its varint. + let mut at = 1; + while bytes[at] & 0x80 != 0 { + at += 1; + } + assert_eq!(bytes.get(at + 1), Some(&b'I'), "{n} is written as a wire int32"); + assert_eq!(v8_deserialize(&bytes).to_bits(), f64::to_bits(n), "{n}"); + } +} diff --git a/crates/perry-runtime/src/closure/dispatch/value_call.rs b/crates/perry-runtime/src/closure/dispatch/value_call.rs index df3cbc445ad..271b0084278 100644 --- a/crates/perry-runtime/src/closure/dispatch/value_call.rs +++ b/crates/perry-runtime/src/closure/dispatch/value_call.rs @@ -403,9 +403,13 @@ unsafe fn call_array_this( return dispatch_body_args(closure, info, this, &vec![0.0; n]); } let args = std::slice::from_raw_parts(args_ptr, n); - // V8 supplies INT32-tagged numbers; compiled code supplies doubles. Keep - // conversion at this bridge, and leave class references (the same tag) - // intact. Only a slice containing a numeric INT32 needs scratch storage. + // Defensive decode of INT32-tagged words; class references (the same tag) + // stay intact. No caller supplies one today: the V8 embedding that did + // (perry-jsruntime) was removed in #1696, `js_create_callback` is a stub + // (`closure/v8_stubs.rs`), and the remaining callers (perry-ffi + // `Closure::call`, ext-http) pass plain doubles (P3 B1 audit, + // perry-wpfacts/NUM_SEEDS.md). Deleted with the other INT32 consumers in + // P3 B4b. Only a slice containing a numeric INT32 needs scratch storage. let is_int = |raw: f64| { raw.to_bits() & 0xFFFF_0000_0000_0000 == crate::value::INT32_TAG && crate::object::class_ref_id(raw).is_none() diff --git a/crates/perry-runtime/src/json/stringify_scalars.rs b/crates/perry-runtime/src/json/stringify_scalars.rs index 311e7e67720..c12b52c39ed 100644 --- a/crates/perry-runtime/src/json/stringify_scalars.rs +++ b/crates/perry-runtime/src/json/stringify_scalars.rs @@ -26,10 +26,11 @@ pub(crate) unsafe fn write_number(buf: &mut String, value: f64) { } // An int32 is NaN-boxed (INT32_TAG = 0x7FFE); its bits ARE an IEEE NaN, so it // would otherwise fall into the `is_nan()` → "null" arm below and silently - // drop the value. Decode and emit the signed integer. Integer columns from - // the sqlite binding (and other int32-tagged numbers) funnel here; numeric - // literals are stored as plain f64 doubles by codegen and never take this - // branch. Mirrors the BigInt funnel above. + // drop the value. Decode and emit the signed integer. No Number producer + // makes such a word today: sqlite integer columns are raw doubles + // (`perry-stdlib` sqlite `bind.rs` tests, #6561), and so are codegen's + // numbers (P3 B1 audit, perry-wpfacts/NUM_SEEDS.md). The arm stays until + // P3 B4b deletes the INT32 consumers. Mirrors the BigInt funnel above. if (value.to_bits() & 0xFFFF_0000_0000_0000) == INT32_TAG { let n = (value.to_bits() & INT32_MASK) as u32 as i32; let mut itoa_buf = itoa::Buffer::new(); diff --git a/crates/perry-stdlib/src/sqlite/bind.rs b/crates/perry-stdlib/src/sqlite/bind.rs index 4da6f90f336..87dd858ae0d 100644 --- a/crates/perry-stdlib/src/sqlite/bind.rs +++ b/crates/perry-stdlib/src/sqlite/bind.rs @@ -655,6 +655,11 @@ mod tests { for n in [0i64, 5, -1, i32::MAX as i64, i32::MIN as i64, 1 << 40] { let v = unsafe { super::sqlite_value_to_jsvalue(&super::SqliteValue::Integer(n)) }; assert_eq!(v.bits(), (n as f64).to_bits(), "INTEGER {n}"); + // node:sqlite's column reader (`sqlite3_column_int64` -> + // `node_sqlite_integer_value`): the same raw double (P3 B1 audit, + // NUM_SEEDS.md "int32-box producers"). + let v = unsafe { super::node_sqlite_integer_value(n, false) }; + assert_eq!(v.bits(), (n as f64).to_bits(), "node:sqlite INTEGER {n}"); } } diff --git a/crates/perry-wpfacts/NUM_SEEDS.md b/crates/perry-wpfacts/NUM_SEEDS.md index 6b10f97c51e..21710517b56 100644 --- a/crates/perry-wpfacts/NUM_SEEDS.md +++ b/crates/perry-wpfacts/NUM_SEEDS.md @@ -132,3 +132,12 @@ There are no non-test callers of `JSValue::int32` in perry-runtime or perry-stdl | `gen_rest/collections.rs:213` | seed | callback index (from/forEach): as above | | `canonical_numbers.rs:49` | read/map | read: tests or masks the fact, seeds nothing (P3 B1's element-value test) | | `canonical_numbers.rs:50` | read/map | read: tests or masks the fact, seeds nothing (P3 B1's element-value test) | + +## INT32-box producers claimed by code comments (CWN, P3 B1) +Two runtime comments claimed live INT32-tagged Number producers. Neither is live: no code path writes an INT32 word for +a Number, and the comments were stale (fixed in P3 B1). + +| comment | code proof | running proof (qb9, main dda8f1e84 vs node 26.8.1) | +|---|---|---| +| `perry-runtime/src/closure/dispatch/value_call.rs:410` "V8 supplies INT32-tagged numbers" | The V8 embedding (perry-jsruntime) was removed in #1696. `js_create_callback` is now only a stub that returns `undefined` (`closure/v8_stubs.rs:232`, and the weak Mach-O definition at :103), so nothing reaches `js_closure_v8_callback` with V8 values. The other entry, `js_closure_call_array`, is called by perry-ffi `Closure::call` (args are `JsValue`s, and `from_int32` is `n as f64`) and by ext-http (array elements). | `v8.deserialize(v8.serialize(5))`, an array `[5, -7, 0, 2147483647]` and an object `{a: 5, b: {c: 5}}`: `Object.is(x, 5)`, `x === 5`, `typeof x`, `x + 0.5` and the `Float64Array` round trip all match node. Raw bits: `v8_serde_canonical_tests.rs` asserts that a wire int32 (`'I'` + zigzag) deserializes to exactly `(n as f64).to_bits()` (`v8_serde.rs:727 int_to_value` is `v as f64`). | +| `perry-runtime/src/json/stringify_scalars.rs:33` "Integer columns from the sqlite binding" | All sqlite integer readers return `JSValue::number(n as f64)`: node:sqlite `bind.rs:400 node_sqlite_integer_value`; user-function arguments `sqlite_callbacks.rs:124 integer_value_checked`; bun:sqlite and better `bind.rs:18 sqlite_value_to_jsvalue`; the ext crate `perry-ext-better-sqlite3/src/lib.rs:90` via perry-ffi `from_int32` = `n as f64`. | node:sqlite `INTEGER` rows (5, -7, 2147483647) through `.all()` and `.get()`: all checks match node. Raw bits: the `bind.rs` test `integer_columns_are_raw_double_numbers` asserts `(n as f64).to_bits()` for both `sqlite_value_to_jsvalue` and `node_sqlite_integer_value`. | From 92cfaa3085d93eb388e8a3e941cc529b0d403b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 06:47:41 +0100 Subject: [PATCH 7/8] NUM_SEEDS: keep the claims table out of the per-file seed count --- crates/perry-wpfacts/NUM_SEEDS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/perry-wpfacts/NUM_SEEDS.md b/crates/perry-wpfacts/NUM_SEEDS.md index 21710517b56..b913271da68 100644 --- a/crates/perry-wpfacts/NUM_SEEDS.md +++ b/crates/perry-wpfacts/NUM_SEEDS.md @@ -139,5 +139,5 @@ a Number, and the comments were stale (fixed in P3 B1). | comment | code proof | running proof (qb9, main dda8f1e84 vs node 26.8.1) | |---|---|---| -| `perry-runtime/src/closure/dispatch/value_call.rs:410` "V8 supplies INT32-tagged numbers" | The V8 embedding (perry-jsruntime) was removed in #1696. `js_create_callback` is now only a stub that returns `undefined` (`closure/v8_stubs.rs:232`, and the weak Mach-O definition at :103), so nothing reaches `js_closure_v8_callback` with V8 values. The other entry, `js_closure_call_array`, is called by perry-ffi `Closure::call` (args are `JsValue`s, and `from_int32` is `n as f64`) and by ext-http (array elements). | `v8.deserialize(v8.serialize(5))`, an array `[5, -7, 0, 2147483647]` and an object `{a: 5, b: {c: 5}}`: `Object.is(x, 5)`, `x === 5`, `typeof x`, `x + 0.5` and the `Float64Array` round trip all match node. Raw bits: `v8_serde_canonical_tests.rs` asserts that a wire int32 (`'I'` + zigzag) deserializes to exactly `(n as f64).to_bits()` (`v8_serde.rs:727 int_to_value` is `v as f64`). | -| `perry-runtime/src/json/stringify_scalars.rs:33` "Integer columns from the sqlite binding" | All sqlite integer readers return `JSValue::number(n as f64)`: node:sqlite `bind.rs:400 node_sqlite_integer_value`; user-function arguments `sqlite_callbacks.rs:124 integer_value_checked`; bun:sqlite and better `bind.rs:18 sqlite_value_to_jsvalue`; the ext crate `perry-ext-better-sqlite3/src/lib.rs:90` via perry-ffi `from_int32` = `n as f64`. | node:sqlite `INTEGER` rows (5, -7, 2147483647) through `.all()` and `.get()`: all checks match node. Raw bits: the `bind.rs` test `integer_columns_are_raw_double_numbers` asserts `(n as f64).to_bits()` for both `sqlite_value_to_jsvalue` and `node_sqlite_integer_value`. | +| claim at `perry-runtime/src/closure/dispatch/value_call.rs:410` "V8 supplies INT32-tagged numbers" | The V8 embedding (perry-jsruntime) was removed in #1696. `js_create_callback` is now only a stub that returns `undefined` (`closure/v8_stubs.rs:232`, and the weak Mach-O definition at :103), so nothing reaches `js_closure_v8_callback` with V8 values. The other entry, `js_closure_call_array`, is called by perry-ffi `Closure::call` (args are `JsValue`s, and `from_int32` is `n as f64`) and by ext-http (array elements). | `v8.deserialize(v8.serialize(5))`, an array `[5, -7, 0, 2147483647]` and an object `{a: 5, b: {c: 5}}`: `Object.is(x, 5)`, `x === 5`, `typeof x`, `x + 0.5` and the `Float64Array` round trip all match node. Raw bits: `v8_serde_canonical_tests.rs` asserts that a wire int32 (`'I'` + zigzag) deserializes to exactly `(n as f64).to_bits()` (`v8_serde.rs:727 int_to_value` is `v as f64`). | +| claim at `perry-runtime/src/json/stringify_scalars.rs:33` "Integer columns from the sqlite binding" | All sqlite integer readers return `JSValue::number(n as f64)`: node:sqlite `bind.rs:400 node_sqlite_integer_value`; user-function arguments `sqlite_callbacks.rs:124 integer_value_checked`; bun:sqlite and better `bind.rs:18 sqlite_value_to_jsvalue`; the ext crate `perry-ext-better-sqlite3/src/lib.rs:90` via perry-ffi `from_int32` = `n as f64`. | node:sqlite `INTEGER` rows (5, -7, 2147483647) through `.all()` and `.get()`: all checks match node. Raw bits: the `bind.rs` test `integer_columns_are_raw_double_numbers` asserts `(n as f64).to_bits()` for both `sqlite_value_to_jsvalue` and `node_sqlite_integer_value`. | From 0d43f44ebfa6ae4637d18f00f2c4ffa4f04b7393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 07:17:13 +0100 Subject: [PATCH 8/8] codegen test: a proven Number parameter takes its argument's bits at entry (P3 B1 C4) --- .../src/codegen/facts_number_locals_tests.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/codegen/facts_number_locals_tests.rs b/crates/perry-codegen/src/codegen/facts_number_locals_tests.rs index 4420d565702..8d70c30c757 100644 --- a/crates/perry-codegen/src/codegen/facts_number_locals_tests.rs +++ b/crates/perry-codegen/src/codegen/facts_number_locals_tests.rs @@ -118,10 +118,21 @@ fn proven_bindings_take_raw_double_ops_and_own_no_root_slot() { 0, "a proven Number binding never holds a pointer:\n{body}" ); - assert!( - body.contains(crate::nanbox::INT32_TAG_I64), - "a proven parameter converts an INT32 box at entry:\n{body}" - ); + // P3 B1, C4: a proven Number is a plain double (the NUM fact is audited, + // `perry-wpfacts/NUM_SEEDS.md`), so the parameter takes its argument's bits + // with no INT32 decode at entry; the entry witness checks it in check + // mode. `PERRY_FACTS_CANONICAL_NUMBERS=0` restores the decode. + if crate::expr::facts_canonical::enabled() { + assert!( + !body.contains(crate::nanbox::INT32_TAG_I64), + "a proven parameter takes its argument's bits at entry:\n{body}" + ); + } else { + assert!( + body.contains(crate::nanbox::INT32_TAG_I64), + "a proven parameter converts an INT32 box at entry:\n{body}" + ); + } } #[test]