Status: Draft / RFC, v2 (2026-07-27)
Scope: all value classes — scalars, strings, objects, typed arrays, closures — not only numeric code.
Driving benchmark: bcryptjs _encipher (real, unrolled) — must compile to unboxed native and beat V8.
Perry's canonical value representation is the NaN-boxed double. Native machine types
(NativeRep::{I32,U32,F64,F32,I1,Ptr,…}) exist, but they are region-local overlays: the
NativeRep doc comments say it — "region-local," "optimizer-local," "Public ABI remains
JsValue," — and a whole materialize_*_to_js_value family re-boxes a native value at every
JS-visible boundary. Concretely, verified in emitted IR:
- A provably-i32 loop accumulator
lis stored asalloca double; post--O3it is aphi doublecarried across the loop back-edge, so every iteration doesfptosi double %l(unbox, twice) andsitofp i32 → double(rebox). LLVM cannot remove them — the canonical representation isdoubleand the| 0truncation isn't a provable no-op on a double. (Counts survive-O3: fptosi=3, sitofp=6,phi double=3 in a trivial 8-element mixer.) - Function params are
double %argN— the ABI is uniformly boxed. A hot function called millions of times receives its typed-array/number args boxed and re-unboxes them on every call. - A typed-array element is raw i32 in memory, but the access path re-boxes the result to a double before the consumer sees it.
- Numeric locals are even registered as GC roots (
js_shadow_slot_bind) though a number can never be a pointer — wasted root-set work.
Consequence: statically-typed code round-trips through the box on every op and every call.
This is why AOT Perry loses to V8's JIT on integer-heavy code (measured on a faithful, unrolled
bcryptjs _encipher: 834 ms vs Node 184 ms for the same work), and why expression-level
fast paths don't compose — each is another overlay on a boxed canonical, and they invert on
unrolled code (adding types to the real _encipher regressed it 834 → 2732 ms, because ~80
per-read guards plus boundary re-materialization dominate). Optimizing individual expressions
cannot fix a representation problem.
Goal: make the native (unboxed) representation the canonical, first-class representation of any value whose type is statically proven — for every value class where a sound unboxed form exists (scalars, strings, objects, typed arrays, closures) — confining NaN-boxing to values that are provably polymorphic. End-to-end: locals, params, returns, and typed heap slots.
Non-goals: unboxing genuinely-polymorphic code (it stays boxed — NaN-boxing remains the default representation, not the only one); building a JIT / deopt tier; changing observable JavaScript semantics in any way.
- Static Hermes (Meta) — an AOT compiler for JS/TS that uses static types to emit unboxed native code, with no NaN-box for typed values. This RFC is the same architecture applied to Perry.
- V8 / JavaScriptCore / SpiderMonkey — do representation selection dynamically (type feedback +
deopt). Perry, being AOT with no deopt tier, must prove types statically (conservatively,
falling back to
Boxed) rather than speculate. That is AOT's job — and its advantage: no warmup, no deopt overhead, so a statically-proven kernel can beat the JIT, not merely match it.
The lattice covers every class; the win profile and constraints differ per class. Two facts of
Perry's runtime shape the table: (a) under NaN-boxing a plain number is stored as its own double
bits (tags live in NaN space), so a proven-F64 value is already bit-identical unboxed — its win
is eliminating tag-checks and dynamic dispatch, not changing bits; (b) Perry's GC moves objects
(evacuation with root rewriting), so raw-pointer representations must remain registered, rewritable
roots — their win is static dispatch and layout, not root elimination.
| Class | Unboxed rep | Primary win | GC | Notes |
|---|---|---|---|---|
I32/U32 |
i32 |
kill f64↔i32 round-trips; native bitwise/index | not a root | value-range proof required (§4.7) |
F64 |
double (same bits) |
eliminate tag-checks + dynamic dispatch on use | not a root | IEEE-754 semantics untouched (§4.8) |
F32 |
float |
storage/SIMD only when source-proven (Math.fround, Float32Array) |
not a root | never introduce precision loss (§4.8) |
Bool |
i1 |
branch directly; no TAG_TRUE/FALSE compare | not a root | |
String |
StringHeader* |
skip untag/retag; direct string-helper calls (no js_jsvalue_to_string dispatch) |
rooted + rewritten | short-string (inline payload) values stay by-value |
Object(shape S) |
ObjHeader* + static shape |
direct field offsets (no hash lookup), static method dispatch | rooted + rewritten | the dominant win for real apps (property access ≫ arithmetic in web workloads); eligibility in §4.6 |
TypedArray(kind) |
header ptr (+ hoisted data ptr/len in region) | guard-free element access once kind is proven | rooted + rewritten | data-ptr hoisting invalidated at safepoints if backing can move/detach |
Array<number> (Ptr<NumArray>) |
ArrayHeader*; elements are raw f64 in place (the NaN-box of a number IS its double bits; TAG_HOLE marks holes) |
Phase 4a.0-4a.2: inline guarded tiers — header-proof tests instead of out-of-line guard calls, zero runtime calls on the fast path. Phase 4a.3 (collectors/ptr_numarray.rs): fully guard-free element load/store under the collector proof at sites with a per-site in-bounds proof — no header tests, no bounds check, no barrier/note |
rooted + rewritten | density lattice Dense ⊒ HolesOK ⊒ Boxed (§5.7); a hole-OBSERVING read needs the TAG_HOLE→undefined select (guard-free reads are therefore number-context only), hole-DEFAULT consumers (||0, ??0, |0, >>>0, numeric +) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend |
Closure/Function |
code ptr + env ptr | static call targets (extends existing FuncRef) |
env rooted | |
SmallBigInt |
i64 |
native 64-bit arithmetic | not a root | overflow → boxed BigInt path, exists today |
Null/Undefined |
singleton tags | fold checks statically | — | |
polymorphic / union / escaping-to-Any |
Boxed | — | boxed rules | permanent, by design |
Sequencing by payoff: scalars fix the math/crypto class; objects-with-static-shape is the class that moves real applications (gscmaster/Next.js hot paths are property access and string handling, not bitwise math). The architecture is built once; each class is a lowering + inference extension on the same lattice, ABI, and boundary rules.
Every SSA value, local, param, and return — and, in Phase 4, every heap slot — carries a
representation: Boxed(JsValue) ⊒ { I32, U32, F64, F32, Bool, Str, Ptr<Shape|TA-kind>, ClosureRef, SmallBigInt, … }. Boxed is top and always sound. This replaces "canonical = Boxed, native =
transient overlay" with "representation is a property of the value."
Interprocedural flow analysis / abstract interpretation:
- Seeds: literals,
new Int32Array(...)/new C(...)and friends, TypeScript annotations, and known builtin result types (Math.*,.length, typed-array element reads, string ops). - Propagate through assignments, phis, calls (interprocedural summaries), and returns; meet
at joins, unifying to
Boxedon conflict. - Conservative: anything unproven becomes
Boxed. Soundness beats coverage — no deopt net.
Soundness barriers (forced Boxed or explicit guards). Dynamic-JavaScript constructs defeat
static proof and MUST demote affected values to Boxed (or interpose a checked guard):
property accessors (getters/setters) and Proxy on any object whose shape the value's proof
depends on; eval / new Function / with in scope; indirect or unresolved calls (a value passed
to an unknown callee escapes to Boxed at that edge); native/FFI boundaries (the existing
manifest-declared reps remain the contract); reflection (Reflect.*, Object.defineProperty,
getOwnPropertyDescriptor) against shape-proven objects; delete on a shape-proven object;
prototype mutation (setPrototypeOf, __proto__ writes) reaching a proven shape; and exceptional
control flow (a value's representation at a catch join is the meet over all potentially-throwing
paths — in practice Boxed unless all throwing paths agree). The inference treats each of these as
a hard meet-to-Boxed edge; none of them may be "optimized through."
Locals and params get native slots per representation; loop phis are typed; ops stay native end-to-end. Operation semantics are representation-preserving — lowering may never change an observable numeric result:
- JS number arithmetic is IEEE-754 double.
+ - * /on proven numbers lower to nativedoubleops (bit-identical to today, minus the tag checks). AnI32-rep value entering ordinary arithmetic promotes toF64(sitofp, exact for all i32) unless the operation itself is one whose JS semantics are integral: I32ops are exact only where ECMAScript already defines integral semantics: bitwise (& | ^ << >> >>>— ToInt32/ToUint32 wrapped by spec),Math.imul, integer-typed-array element load/store, and array indexing. Additive chains may stay inI32only under a value-range proof that no intermediate exceeds i32 (the existingknown_finite_magnitude_bitscomposition); otherwise they promote toF64. Lowering must never introduce integer wrapping the source doesn't have.F32is storage-only unless the value is source-proven single-precision (Math.fround,Float32Arrayelements); computation widens todoublefirst (fpext), matching JS. No double→float demotion is ever inferred.- Widening is free and always allowed (
I32 → F64exact;Bool → I32exact). Narrowing is never inferred — it exists only where the spec defines it (ToInt32 contexts) or a range proof makes it exact.
Functions are specialized on the representation tuple of (params, return):
- A statically-monomorphic call site (
_encipher(lr, 0, P, S)withP = new Int32Array(...)) calls a specialized entry taking raw args (ptr,i32, …); dispatch is chosen statically. - Specialization key = the representation tuple. Entries are cached/deduplicated per key — all call sites proving the same tuple share one specialized body.
- Budgets: per-function specialization count cap (start: 2 — boxed + one dominant tuple) and a
module-wide entry cap (
PERRY_SPECIALIZED_ABI_MAX, default 64). When a budget is exceeded, additional call sites route to the boxed entry (always present, always correct). Cold/polymorphic callers never specialize. Anti-bloat is proven empirically: thebinary-sizeCI job measures the COMPILER binary, not user output, so it cannot see monomorphization bloat — instead, a large real corpus is compiled with the flag on vs. off (PERRY_SPECIALIZED_ABI=0, object cache cleared between arms) and the emitted binaries must be within ~1% of each other. - The boxed entry is the permanent fallback ABI; specialization is purely additive.
- Box (materialize) only when a native value flows into a
Boxedcontext (polymorphic call,Anystore,console.log): scalars re-tag (sitofp/uitofp/tag-or), pointers re-tag with their NaN-box tag. Cheap, local, explicit in IR. - Unbox (accept) when a boxed value enters a proven-typed context, under an explicit per-representation acceptance contract — mismatches take a checked path (throw where the spec throws; route to the boxed generic path where the spec coerces), never silent wrong-value coercion:
| Target rep | Accepts | Everything else |
|---|---|---|
F64 |
any number (bit-identical) | non-number → boxed path / TypeError per the consuming op's spec |
I32/U32 |
number with exact integral value in range (checked); in a spec ToInt32/ToUint32 context: any number via NaN-safe toint32_wrap (never raw fptosi — poison on NaN) |
non-number → the op's spec behavior via the boxed path |
F32 |
number proven single-precision-exact | boxed path |
Bool |
TAG_TRUE/TAG_FALSE | boxed truthiness path |
Str |
STRING/SHORT_STRING tagged | boxed path |
Ptr<Shape> |
pointer whose class/shape matches the proof | boxed dynamic dispatch |
NaN, ±Infinity, −0, fractional values, and out-of-range integers are all rejected by the integral contracts (they take the checked/boxed path) — exactness is the contract, coercion happens only where the spec places it.
Perry's generational GC moves objects (evacuation with reference rewriting), which dictates the pointer-rep rules:
- Unboxed scalars (
I32/U32/F64/F32/Bool/SmallBigInt) are not roots — their shadow-slot bindings are dropped (a structural root-set reduction). - Unboxed pointers (
Str,Ptr<…>, closure envs) remain precise, rewritable roots: the slot is registered exactly as boxed pointer slots are today, and evacuation rewrites it. The rep changes the tag discipline, not root discipline. - Specialized-ABI pointer params must be safe across nested calls and exceptions: on entry the specialized body spills each raw pointer param to a registered shadow slot (same mechanism as today's param binding — it already survives rewriting); all uses reload after any safepoint (call, allocation, loop back-edge poll). Derived interior pointers (a hoisted typed-array data pointer) are region-local between safepoints only and are recomputed from the rewritten header pointer after any safepoint — never live across one. No pinning is required; this is the same rebase-after-safepoint contract the masked-window region code uses today.
- Unwinding runs the existing frame-pop path; registered slots die with the frame — no new exception machinery.
Each representation above shipped its GC-safety argument in its own PR, verified once by hand at merge time, while the collector changed underneath all of them (#6910 mark/rewrite root-word parity, #6921 typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 operand rooting). Nobody verified the cross-product. It is now a maintained gate rather than a set of one-time arguments:
- The matrix.
scripts/gc_repsel_matrix.shruns the whole representation corpus against every GC arm —PERRY_GC_FORCE_EVACUATE,PERRY_GC_VERIFY_EVACUATION,PERRY_GEN_GC=0,PERRY_WRITE_BARRIERS=0,PERRY_CONSERVATIVE_STACK_SCAN=off,PERRY_GC_MOVING_LOOP_POLLS=1, their combinations, and each representation flag OFF x evacuation — byte-exact against the pinned Node oracle. Wired into thegc-stressCI job: a fast 4-arm subset gates every PR, the full arm list runs on push. - A NEW REPRESENTATION MUST REGISTER ITS GAP FILE in
test-parity/gc_repsel_corpus.txt. The script fails when atest_gap_repsel_*/test_gap_specabi_*file exists that is not registered. This is the GC-side counterpart of the single-decoder refactor #6910 established for mark/rewrite: adding a representation teaches all the paths at once, or CI says so. - Liveness is part of the result. Setting a GC env var does not prove the GC did anything. The
first automatic collection needs ~1M escaping allocations, so a small gap test performs zero
collections and every GC arm against it is inert (#6942, #6946, #6950). The harness therefore
asserts liveness from the collector's own
PERRY_GC_TRACE/PERRY_GC_DIAGoutput and reports an output-matching cell under an inert arm as UNVERIFIED, never green.test-files/test_gap_repsel_gc_stress.tsis the corpus member built to be live: it holds each representation's local across escaping allocation churn heavy enough to reach the collector. A new representation should extend that file as well as adding its own, or its GC arms stay inert. - What the matrix cannot verify today. No reachable configuration in an AOT-compiled program
performs an evacuating minor: every automatic collection is a full mark-sweep taken under
ManualGcScanGuard::force_full_scan(), which additionally pins raw locals conservatively (#6950, extending #6946 from thegc()path). The rebase-after-safepoint contract in the bullets above — the core GC claim of every pointer representation — is therefore still argued, not tested. #6942 tracks making it testable; when that lands, the matrix's evacuating arms flip from UNVERIFIED to green with no change to the harness.
Unboxed storage extends to heap slots where the container's shape is proven and stable:
-
Eligibility: an object qualifies for unboxed field layout only if its shape is closed-world-proven — no dynamic keyed writes of unknown keys, no
delete, no accessor orProxyinterposition on it, no reflection against it, no prototype mutation, and no polymorphic alias (every reference to it carries the same shape proof; a single escaping alias toBoxedcontext demotes the layout to boxed). Escape analysis makes this decision; the default remains today's boxed field storage. -
Shape transitions: adding a proven field transitions between static layouts at compile time; any runtime shape change reachable at all (only possible when analysis over-approximated reachability of a barrier) is prevented by the eligibility rules, not patched at runtime.
-
Observation equivalence:
for…in/Object.keysorder, descriptor reads, andJSON.stringifymust be indistinguishable from boxed layout; codegen materializes boxed views on demand at those (rare, already-slow) surfaces. -
Barriers: typed pointer fields keep the existing generational write-barrier; typed scalar fields need none (another structural win).
-
Typed-array element reads stop re-boxing when the consumer is typed (the element is raw in memory today; only the access path boxes it).
-
Plain numeric arrays (
Ptr<NumArray>, Phase 4a). Phase 4a.0-4a.2 shipped the inline guarded tiers (per-access header-proof tests on the raw-f64 / raw-f64-or-holes bits instead of out-of-line guard calls; zero runtime calls on the fast path). Phase 4a.3 (collectors/ptr_numarray.rs) ships the collector: a localnumber[]qualifies for fully guard-free element access under provenance + containment, exactly likePtr<Shape>locals — single-Letprovenance (new Array(<static n>)or the empty literal[]; the first-increment scope excludes non-empty literals,.fillchains, and param-sized allocations), every use a numeric-key element read, an element write whose value is numeric-by-construction,.length, a numericpush, or a barereturn; and the module-wide §5.2 barrier kill extended with the array-specific barrier (any indexed write through a.prototypeobject — a polluted prototype changes what a HOLE read observes, and the guard-free read cannot consult the runtime pollution byte). Length-shrinking / reordering / hole-materializing mutators (arr.length = n,pop/shift/splice/unshift/copyWithin,sort/reverse— the latter arrive as disqualifying method calls) anddelete(module-wide, via §5.2) all demote. The stale-binding exemption is part of eligibility: containment means no callee ever receives the array (the Phase 2 specialized-ABI caller-allocated growth pattern cannot occur), every in-function growth site writes the live head back to the local slot, and consumers re-derive the base from the (shadow-bound) slot per access. Eligibility carries a density latticeDense ⊒ HolesOK ⊒ Boxed:Dense(empty-literal provenance; growth only through numeric pushes — no hole can exist) drops the hole handling entirely;HolesOK(new Array(n)) emits guard-free reads ONLY in ToNumber contexts, where the proof-gated 2-instruction canonical-NaN form is bit-exact (TAG_HOLE→ quiet NaN ≡ToNumber(undefined)); anything that can store a non-numeric value demotes toBoxed(stays on the guarded tiers). Guard-free stores additionally require a canonical-raw-f64 RHS and a per-site in-bounds proof (static index range vs the allocation length — permanent because length can only grow — or a bounded-loop fact). Hole-vs-undefined observability (in,Object.keys,JSON.stringify) is preserved structurally: those surfaces reference the local as a bare value and therefore disqualify it, and bare (non-ToNumber) element reads never lower guard-free. -
Unboxed object fields: assessed and REJECTED (Phase 4b). The "unboxed field layout" bullets at the top of this section were scoped down after recon, and the eligibility machinery they describe was deliberately not built. Three findings drove that:
numberfields are already bit-unboxed. NaN-boxing reserves only0x7FF9..=0x7FFF, so a number field slot already holds raw IEEE bits;raw_f64_mask(gc/layout.rs::layout_raw_f64_bits) is a proof bit, not a storage change. Phase 3b already deleted the read-side guard on proven receivers, so no unboxing win remains.- Raw string handles at rest would break SSO — short strings live inline in the NaN box and would have to be heap-materialized just to be stored "unboxed" — and buy nothing.
- Raw
i1/i32slots would need a third mask plus a layout probe at ~25 direct slot-read sites —JSON.stringify,util.inspect,v8IPC serde and descriptor reads among them. Those are hot paths, not the "rare, already-slow" surfaces the observation-equivalence bullet assumes, so the probe would cost more than the representation saves.
What Phase 4b ships instead is the bookkeeping the existing boxed layout was paying needlessly:
-
4b.1 — a class-field store on a
Ptr<Shape>-proven receiver retiresjs_gc_note_slot_layoutwhen the value is a non-pointer by construction, andjs_string_addref_if_heap_stringwhen the value provably cannot be a heap string (the strictly weaker condition, which is why the two are gated independently — an object or array literal retires the addref but keeps the note). The generational write barrier is untouched. The note elision is sound in every layout state the receiver can be in:UNKNOWNandPOINTER_FREEshort-circuit inside the note; an intact descriptor falls through thepointer_maskarm untouched; and underSIDE_MASKthe note would only ever clear the slot's bit, so skipping it leaves a stale set bit over a non-pointer, which costs one extra visit and nothing else —mark_field_into_worklistre-validates every slot word, and the evacuation rewrite path routes through the same function. The addref elision is keyed on the value expression, never the declared field type: Perry does not validate declared types at runtime, so aboolean-declared field can legitimately receive a string through anany, and a wrong elision there silently corrupts an aliased string on the next in-place append.Two scope notes. A pointer-valued store into a pointer-masked slot is deliberately not elided, even though it is a no-op under an intact descriptor:
lower_new_implhas an exit (the standalone-ctor-symbol branch wherecall_local_constructor_symbolyieldsNone) that returns a freshly allocated instance without emittingjs_gc_init_typed_shape_layout, and such an object sits atPOINTER_FREEwhere the note is the only thing that ever sets the pointer-mask bit the collector reads. Closing that exit (#6921) is the prerequisite for the stronger elision. Likewise the guarded (non-Ptr<Shape>) class-field store keeps both calls — its receiver can be a runtime-constructed object that never had a descriptor installed. -
4b.2 —
runtime_store_jsvalue_slotcanonicalizes an INT32-boxed numeric store into a raw-f64-masked slot (the object twin ofcanonicalize_array_numeric_store_bits), instead of letting one FFI/native-supplied integer evict that object's typed descriptor permanently and one-way.
- Phase 0 — Representation as a first-class IR property + inference skeleton;
Boxeddefault everywhere → zero behavior change; scaffolding + tests. - Phase 1 — Canonical unboxed scalar locals (typed slots + typed loop phis; drop scalar
shadow roots). In flight:
perf/repsel-p1-canonical-i32-locals(I32/U32 first; F64 tag-check elision and Bool follow the same mechanism). - Phase 2 — Specialized ABI / bounded monomorphization (§5.4) for statically-typed call
sites. Unblocks unrolled
_encipher(raw-typed args; the per-access kind-guard is proven away). - Phase 3 — Strings + pointer locals (
Str,Ptr<Shape>,TypedArray): rooted unboxed locals, static string-helper calls, static field offsets and method dispatch on shape-proven objects. This is the phase that moves real web workloads. - Phase 4 — Typed heap fields (§5.7) + typed-array-access unboxing to typed consumers.
Benchmark protocol (all perf claims): quiet dedicated arm64 machine (load < 3 — the project's
M1 test box), Perry built at the tested commit (perry-dev profile), PERRY_NO_AUTO_OPTIMIZE=1,
object cache cleared between env-flag arms (rm -rf node_modules/.cache/perry — env flags are
cache-keyed but arms must not share objects), min-of-9 wall-clock runs, reported alongside the
machine's load average. Node oracle: the repo-pinned version (.node-version, currently 26.5.0),
default flags, same machine, same min-of-9. Pass threshold "beats V8": Perry min-of-9 <
Node min-of-9 on the same inputs, with the structural IR proof attached (see below) so the result
is not load-luck.
_encipherend-state test: the real (unrolled, untyped-source) bcryptjs_encipher(extracted verbatim from bcryptjs@3.0.3, 2.1M-call harness, golden outputslr0=-1623241632 lr1=-1640632493) compiles to unboxed native i32/f64 end-to-end — structural proof: the post-opt -O3function contains zerojs_dyn_index_get/js_dynamic_*/js_typed_array_*calls and zerofptosi/sitofpin the Feistel body — and beats Node per the protocol above, byte-exact.- Loop-kernel test: the minimal typed mixer (
slot.ts) post--O3hot loop isphi i32with zerofptosi/sitofp. - Exactness harness: the existing byte-for-byte gap corpus (
test-files/test_gap_*.ts, ~410 programs,./scripts/run_gap_tests.shagainst the pinned Node) plus the conformance-smoke shards — zero new failures at every phase. Polymorphic/dynamic programs must show unchanged representations (spot-checked via--trace llvm).
- Soundness under no-deopt: must prove, not speculate; conservative
Boxedfallback; boundary acceptance contracts (§5.5) must throw/route, never coerce. The central correctness burden — §5.2's barrier list is the checklist. - Compile-time cost of interprocedural inference (summaries, caching, on-demand analysis).
- Code size from monomorphization (bounded by §5.4 budgets; proven by the flag-on/off
emitted-binary corpus measurement — the
binary-sizeCI job only sees the compiler itself). - GC complexity: pointer reps under a moving collector (§5.6) — the rebase-after-safepoint contract must be enforced mechanically (a lint/verifier pass over region-local derived pointers).
- Scope: Perry's single largest architectural line — HIR, type system, codegen, ABI, GC, object model. It replaces the incremental fast-path strategy with a permanent representation model.
Measured during the bcryptjs _encipher performance investigation (2026-07-27; protocol of §7):
l = phi doublepost--O3with per-iterationfptosi/sitofpthat LLVM cannot remove; a representation-selected slot would bephi i32with zero conversions.- Real
_encipher— untyped source vs. hand-typed params, expression-fast-paths on vs. off: 834 / 2732 / 6103 ms (Node 184 ms). Typing the real code regressed it: overlays do not cross the ABI, and per-read guards multiply on unrolled bodies. - The expression-level fast paths that motivated this RFC (inline non-BigInt bitwise; inline checked-f64 typed-array-param reads; int-valued-local i32 promotion) are correct, byte-exact, and help looped, statically-typed kernels (3541 → 1005 ms on a typed reproduction), but were 0% on real (untyped-param) bcryptjs and net-negative on unrolled code — the empirical case for a structural, rather than incremental, fix.