jz 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +99 -72
  2. package/bench/README.md +253 -100
  3. package/bench/bench.svg +58 -81
  4. package/cli.js +85 -12
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6196 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +222 -35
  9. package/interop.js +240 -141
  10. package/layout.js +34 -18
  11. package/module/array.js +99 -111
  12. package/module/collection.js +124 -30
  13. package/module/console.js +1 -1
  14. package/module/core.js +163 -19
  15. package/module/json.js +3 -3
  16. package/module/math.js +162 -3
  17. package/module/number.js +268 -13
  18. package/module/object.js +37 -5
  19. package/module/regex.js +8 -7
  20. package/module/simd.js +37 -5
  21. package/module/string.js +203 -168
  22. package/module/typedarray.js +565 -112
  23. package/package.json +26 -7
  24. package/src/abi/string.js +29 -29
  25. package/src/ast.js +19 -2
  26. package/src/autoload.js +3 -0
  27. package/src/compile/analyze-scans.js +174 -11
  28. package/src/compile/analyze.js +101 -4
  29. package/src/compile/cse-load.js +200 -0
  30. package/src/compile/emit-assign.js +82 -20
  31. package/src/compile/emit.js +592 -51
  32. package/src/compile/index.js +504 -89
  33. package/src/compile/infer.js +36 -1
  34. package/src/compile/loop-divmod.js +109 -0
  35. package/src/compile/loop-model.js +91 -0
  36. package/src/compile/loop-square.js +102 -0
  37. package/src/compile/narrow.js +275 -41
  38. package/src/compile/peel-stencil.js +215 -0
  39. package/src/compile/plan/advise.js +55 -1
  40. package/src/compile/plan/common.js +29 -0
  41. package/src/compile/plan/index.js +8 -1
  42. package/src/compile/plan/inline.js +180 -22
  43. package/src/compile/plan/literals.js +313 -24
  44. package/src/compile/plan/scope.js +115 -39
  45. package/src/compile/program-facts.js +21 -2
  46. package/src/ctx.js +96 -19
  47. package/src/helper-counters.js +137 -0
  48. package/src/ir.js +157 -20
  49. package/src/kind-traits.js +34 -3
  50. package/src/kind.js +79 -4
  51. package/src/op-policy.js +5 -2
  52. package/src/ops.js +119 -0
  53. package/src/optimize/index.js +1274 -151
  54. package/src/optimize/recurse.js +182 -0
  55. package/src/optimize/vectorize.js +4187 -253
  56. package/src/prepare/index.js +75 -14
  57. package/src/prepare/lift-iife.js +149 -0
  58. package/src/reps.js +6 -2
  59. package/src/static.js +9 -0
  60. package/src/type.js +63 -51
  61. package/src/wat/assemble.js +286 -21
  62. package/src/widen.js +21 -0
  63. package/src/wat/optimize.js +0 -3760
@@ -27,7 +27,8 @@
27
27
 
28
28
  import parseWat from 'watr/parse'
29
29
  import { ctx, err, inc, resolveIncludes, PTR, LAYOUT, declGlobal } from '../ctx.js'
30
- import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR } from '../ast.js'
30
+ import { T, isBlockBody, isReassigned, refsName, REFS_IN_EXPR, returnExprs } from '../ast.js'
31
+ import { valTypeOf } from '../kind.js'
31
32
  import { intLiteralValue } from '../static.js'
32
33
  import {
33
34
  analyzeBody, unboxablePtrs, cseSafeLoadBases, boxedCaptures,
@@ -37,6 +38,14 @@ import { typedElemAux } from '../../layout.js'
37
38
  import { VAL, updateRep, REP_FIELDS } from '../reps.js'
38
39
  import { inferLocals } from './infer.js'
39
40
  import { optimizeFunc, treeshake } from '../optimize/index.js'
41
+ import { strengthReduceLoopDivMod } from './loop-divmod.js'
42
+ import { narrowBoundedSquare } from './loop-square.js'
43
+ import { peelClampedStencil } from './peel-stencil.js'
44
+ import { cseLoads } from './cse-load.js'
45
+
46
+ // Monotonic across all functions so a CSE temp never collides (even after later inlining).
47
+ let __cseCtr = 0
48
+ const freshCseName = () => `${T}cse${__cseCtr++}`
40
49
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
41
50
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
42
51
  import {
@@ -54,10 +63,12 @@ import {
54
63
  I32_MIN, I32_MAX, dollar,
55
64
  } from '../ir.js'
56
65
  import plan from './plan/index.js'
66
+ import { foldStaticConstAggregates } from './plan/literals.js'
57
67
  import {
58
68
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
59
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
69
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits, stripDeadElTable,
60
70
  } from '../wat/assemble.js'
71
+ import { instrumentHelperCallsites } from '../helper-counters.js'
61
72
 
62
73
  // =============================================================================
63
74
  // Single-source export semantics
@@ -130,15 +141,21 @@ const timePhase = (profiler, name, fn) => profiler?.time ? profiler.time(name, f
130
141
  * fractional Number gets the same truncation it would get from `arr[n]`).
131
142
  */
132
143
  const isBoundaryWrapped = (func) => {
133
- if (!isExported(func) || func.raw || func.sig.results.length !== 1) return false
144
+ if (!isExported(func) || func.raw) return false
145
+ // Multi-value return: every lane is an f64 NaN-box carrier (the `return [a,b,…]` emit forces
146
+ // asF64 per lane; result narrowing only touches single-result funcs), so any lane may hold a
147
+ // box whose NaN payload JSC/V8 erases at the boundary — wrap to i64-carry every lane.
148
+ if (func.sig.results.length !== 1) return true
134
149
  if (func.sig.results[0] !== 'f64' || func.sig.ptrKind != null) return true
135
- // A boolean result rides the 0/1 number carrier internally; the export thunk
136
- // boxes it to the TRUE_NAN/FALSE_NAN atom so the host sees a real boolean.
137
- if (func.valResult === VAL.BOOL) return true
138
- // A bigint result rides the i64-reinterpreted-f64 carrier internally; the export thunk converts
139
- // it to a real Number so a JS host doesn't see raw i64 bits (`() => 100n` was returning 4.94e-322).
140
- if (func.valResult === VAL.BIGINT) return true
141
- return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null)
150
+ // Any result that isn't a proven plain number can be a NaN-box — a heap pointer,
151
+ // a null/undef/bool atom, a bigint carrier, or a dynamic value — so it crosses as
152
+ // i64 and JSC (Safari) can't canonicalize the payload away. A proven-number result
153
+ // stays f64: free, and a number is never a NaN-box. `_resultNumeric` is set in
154
+ // analyzeFuncForEmit (covers value-bound arrows narrowValResults skips).
155
+ if (!func._resultNumeric) return true
156
+ // Number result, but a param may still carry a box — a pointer-ABI param, or a
157
+ // dynamic f64 param flagged `boundaryI64` during analyze — so wrap for i64 params.
158
+ return func.sig.params.some(p => p.type !== 'f64' || p.ptrKind != null || p.boundaryI64)
142
159
  }
143
160
 
144
161
  // Static-string intern index (the `internStrings` pass). Open-addressing table
@@ -209,20 +226,39 @@ const ensureThrowRuntime = (sec) => {
209
226
  sec.tags.push(['export', '"__jz_last_err_bits"', ['global', '$__jz_last_err_bits']])
210
227
  }
211
228
 
212
- // Drop the $__jz_err tag + __jz_last_err_bits globals when optimization
213
- // eliminated every actual throw site. ensureThrowRuntime runs before
214
- // optimizeModule so dead-throw analysis can see the tag/global as live; once
215
- // opt has finished, an unused tag still forces consumers (wasmtime, wasm2c) to
216
- // enable the exceptions proposal just to parse the module. User-written
217
- // throw/try/catch/finally is an ABI contract (JS-side may inspect
218
- // __jz_last_err_bits), so `userThrows` keeps the runtime declared regardless;
219
- // the prune fires only when `throws` was set purely by stdlib pattern matching
220
- // or compiler-internal coercion sites.
229
+ // Drop the $__jz_err tag + __jz_last_err_bits globals when no throw can be CAUGHT.
230
+ // ensureThrowRuntime runs before optimizeModule so dead-throw analysis sees the
231
+ // tag/global as live; once opt has finished, an unused tag still forces consumers
232
+ // (wasmtime, wasm2c, wabt) to enable the exceptions proposal just to PARSE the module.
233
+ //
234
+ // When `!userThrows`, every `throw` is compiler-internal (bounds / coercion / type
235
+ // errors) and with no user try/catch — uncatchable: nothing inspects the thrown
236
+ // value, so it is semantically a trap. The exceptions proposal is needed only to
237
+ // DECLARE the tag a `throw` references; lowering each surviving uncatchable throw to
238
+ // `unreachable` keeps the module in the wasm MVP, so every runtime can parse it
239
+ // (V8 alone enables exceptions by default, which masked this). A pure-recursion or
240
+ // typed-array kernel (nqueens, anything pulling __to_num) thus stops emitting a Tag
241
+ // section it can never use. User-written throw/try/catch/finally is an ABI contract
242
+ // (JS-side may inspect __jz_last_err_bits), so `userThrows` keeps the runtime intact.
221
243
  const pruneUnusedThrowRuntime = (sec) => {
222
244
  if (!ctx.runtime.throws || ctx.runtime.userThrows) return
223
- const hasThrow = (n) => Array.isArray(n) && (n[0] === 'throw' || n.some(hasThrow))
245
+ // A catch handler (try_table) appears only under userThrows; defensively bail if one
246
+ // is present so a caught throw is never silently turned into a trap.
247
+ const hasCatch = (n) => Array.isArray(n) &&
248
+ (n[0] === 'try_table' || n[0] === 'catch' || n[0] === 'catch_all' || n.some(hasCatch))
249
+ for (const arr of [sec.funcs, sec.stdlib, sec.start])
250
+ for (const f of arr) if (hasCatch(f)) return
251
+ // Rewrite every surviving `(throw $__jz_err …)` to `(unreachable)` (same polymorphic
252
+ // stack type — a drop-in in any position). The thrown operand is side-effect-free
253
+ // (a local read / const), so dropping it loses nothing.
254
+ const lowerThrows = (n) => {
255
+ if (!Array.isArray(n)) return n
256
+ if (n[0] === 'throw') return ['unreachable']
257
+ for (let i = 1; i < n.length; i++) n[i] = lowerThrows(n[i])
258
+ return n
259
+ }
224
260
  for (const arr of [sec.funcs, sec.stdlib, sec.start])
225
- for (const f of arr) if (hasThrow(f)) return
261
+ for (let i = 0; i < arr.length; i++) arr[i] = lowerThrows(arr[i])
226
262
  sec.tags = sec.tags.filter(t => !(Array.isArray(t) &&
227
263
  ((t[0] === 'tag' && t[1] === '$__jz_err') ||
228
264
  (t[0] === 'export' && t[1] === '"__jz_last_err_bits"'))))
@@ -346,7 +382,9 @@ function scanAndTagNonEscapingClosures(body) {
346
382
  // their synthetic labels can't collide with the parent frame's.
347
383
  function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
348
384
  ctx.func.stack = []
385
+ ctx.func.zeroInitSeen = new Set() // names whose `let x=0` zero-init was elided once; a 2nd is a real re-init (unrolled bodies)
349
386
  ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
387
+ ctx.func.refinements = new Map() // flow-sensitive type facts (typeof/instanceof guards) — per-function; clear so none leak across bodies
350
388
  ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
351
389
  ctx.func.uniq = uniq
352
390
  ctx.func.current = sig
@@ -379,6 +417,19 @@ function analyzeFuncForEmit(func, programFacts) {
379
417
  const { paramReps } = programFacts
380
418
  if (func.raw) return null
381
419
 
420
+ // Strength-reduce per-iteration `i % w` / `(i/w)|0` to incremental i32 counters
421
+ // (idempotent: a reduced loop has no modulo left to match). Before analyze so the
422
+ // counters are typed/narrowed like any i32 local. Off at L0 / `loopIVDivMod:false`.
423
+ const _o = ctx.transform.optimize
424
+ if (_o && _o.loopIVDivMod !== false && isBlockBody(func.body)) func.body = strengthReduceLoopDivMod(func.body)
425
+ // Bounded-square narrowing: `i*i` under an `i*i < CONST` (CONST ≤ 2³⁰) guard → Math.imul,
426
+ // so the sieve's product/counter chain carries i32 instead of f64. Before analyze so the
427
+ // Math.imul typed/narrows like any i32. Off at L0 / `loopSquare:false`.
428
+ if (_o && _o.loopSquare !== false && isBlockBody(func.body)) func.body = narrowBoundedSquare(func.body)
429
+ // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
430
+ // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
431
+ if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
432
+
382
433
  const { name, body, sig } = func
383
434
  enterFunc(sig, body)
384
435
 
@@ -412,15 +463,28 @@ function analyzeFuncForEmit(func, programFacts) {
412
463
  // ToNumber string-parse dep tree (`__to_str`→`__itoa`/`__toExp`/`__mkstr`/…)
413
464
  // treeshake away — a ~4× module shrink that, decisively, lets V8 tier the hot
414
465
  // fill loop up properly (the bloated module JITs the *identical* loop ~2× slower).
415
- if (func.exported && block) {
466
+ // Block AND expression bodies: value-bound arrows (`export let f = (a,b) => a*b`) are
467
+ // skipped by narrowValResults, so without trusting their params here they'd fall to the
468
+ // i64 boundary carrier. The closure path runs the same proof at line ~1300.
469
+ if (func.exported) {
416
470
  for (const p of sig.params) {
417
471
  if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
418
472
  && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
419
473
  && !ctx.func.localReps?.get(p.name)?.val
420
- && paramAllUsesNumeric(body, p.name))
474
+ // Numeric either by PROOF (ToNumber-forcing uses) or by the export
475
+ // boundary contract (never used as a string → wrapVal guarantees a
476
+ // number). The latter catches `acc + cre` float kernels whose `+` would
477
+ // otherwise pull a per-iteration string-concat fork (julia, floatbeats).
478
+ && (paramAllUsesNumeric(body, p.name) || paramNeverString(body, p.name)))
421
479
  updateRep(p.name, { val: VAL.NUMBER })
422
480
  }
423
481
  }
482
+ // Sound load-CSE: cache a repeated pure typed-array load `arr[idx]` when every intervening
483
+ // store writes a provably-different element (idx2 ≠ idx). Recovers the fft butterfly's redundant
484
+ // `re[a]` load. Before analyze so the introduced temp is typed/narrowed like any local.
485
+ if (_o && _o.loadCSE !== false && block && ctx.types.typedElem?.size)
486
+ cseLoads(body, n => ctx.types.typedElem.has(n), freshCseName)
487
+
424
488
  if (block) {
425
489
  seedLocalIntConsts(body)
426
490
  }
@@ -434,6 +498,8 @@ function analyzeFuncForEmit(func, programFacts) {
434
498
  if (bodyFacts?.valTypes) {
435
499
  for (const [name, vt] of bodyFacts.valTypes) updateRep(name, { val: vt })
436
500
  }
501
+ // Never-relocated array bindings — the `[]` reader skips the forwarding follow.
502
+ if (bodyFacts?.neverGrown) for (const name of bodyFacts.neverGrown) updateRep(name, { neverGrown: true })
437
503
  // Proven uint32 accumulator locals — readVar tags reads `.unsigned` so the
438
504
  // f64 round-trip widens with convert_i32_u (not _s).
439
505
  if (bodyFacts?.unsignedLocals) for (const n of bodyFacts.unsignedLocals) updateRep(n, { unsigned: true })
@@ -512,6 +578,32 @@ function analyzeFuncForEmit(func, programFacts) {
512
578
  if (ctx.func.localReps?.get(name)?.intCertain === true) cellTypes.add(name)
513
579
  }
514
580
 
581
+ // Snapshot each param's JS-boundary carrier while reps are live — synthesizeBoundaryWrappers
582
+ // runs after they're torn down. A dynamic f64 param crosses as i64 (the carrier JSC can't
583
+ // canonicalize) iff it can hold a NaN-box, i.e. it isn't proven numeric. Numeric (NUMBER /
584
+ // BOOL → 0/1) params keep f64; pointer-ABI (ptrKind, type i32) and jsstring params are
585
+ // classified directly in the wrapper, so leave their flag false here.
586
+ if (isExported(func)) for (const p of sig.params) {
587
+ if (p.jsstring || p.ptrKind != null || p.type !== 'f64') { p.boundaryI64 = false; continue }
588
+ const rv = ctx.func.localReps?.get(p.name)?.val
589
+ p.boundaryI64 = rv !== VAL.NUMBER && rv !== VAL.BOOL
590
+ }
591
+
592
+ // Result-numeric proof for the boundary carrier. Block bodies get func.valResult from
593
+ // narrowValResults; value-bound arrows (`export let f = (a,b) => a*b`) don't, so prove via
594
+ // the return expression(s) with params now trusted numeric. A proven-number f64 result
595
+ // never carries a NaN-box → crosses as plain f64; anything else rides i64 (Safari-safe).
596
+ if (isExported(func))
597
+ func._resultNumeric = func.valResult === VAL.NUMBER ||
598
+ (func.valResult == null && sig.results[0] === 'f64' &&
599
+ (() => {
600
+ const rex = returnExprs(body)
601
+ // Void body (falls off → undefined, which callers ignore) keeps the f64 carrier:
602
+ // undefined isn't a reference, so no i64 is needed and wrapping every void export
603
+ // is pure overhead. A non-empty set must be all-NUMBER to stay f64.
604
+ return rex.length === 0 || rex.every(e => valTypeOf(e) === VAL.NUMBER)
605
+ })())
606
+
515
607
  return {
516
608
  block,
517
609
  locals: new Map(ctx.func.locals),
@@ -520,27 +612,61 @@ function analyzeFuncForEmit(func, programFacts) {
520
612
  flatObjects: new Map(ctx.func.flatObjects),
521
613
  sliceViews: new Set(ctx.func.sliceViews),
522
614
  cseLoadBases,
615
+ distinctParams: func.distinctParams || null,
523
616
  typedElem: ctx.types.typedElem ? new Map(ctx.types.typedElem) : null,
524
617
  localReps: cloneRepMap(ctx.func.localReps),
525
618
  }
526
619
  }
527
620
 
528
621
  function seedLocalIntConsts(body) {
622
+ // Fold each never-reassigned local `const`/`let NAME = EXPR` to a known i32, so a
623
+ // divisor / bound / size built from earlier consts (`rr = R|0; win = 2*rr+1`) becomes
624
+ // a compile-time literal — which lets the int-divide lowering hand the wasm backend a
625
+ // constant divisor to magic-multiply (no runtime sdiv), array bounds resolve, etc.
626
+ // Mirrors the module-scope fold (evalConst above); a string ref resolves through the
627
+ // intConst already recorded on its rep, and the fixpoint lets a later const see an
628
+ // earlier one regardless of declaration order. Skips nested functions (own scope).
629
+ const evalC = (n) => {
630
+ if (typeof n === 'number') return Number.isInteger(n) ? n : null
631
+ if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return Number.isInteger(n[1]) ? n[1] : null
632
+ if (typeof n === 'string') return intLiteralValue(n) // a seeded intConst / literal local
633
+ if (!Array.isArray(n)) return null
634
+ const [op, a, b] = n
635
+ const va = evalC(a); if (va == null) return null
636
+ if (op === 'u-' || (op === '-' && b === undefined)) return -va
637
+ const vb = evalC(b); if (vb == null) return null
638
+ switch (op) {
639
+ case '+': return va + vb; case '-': return va - vb; case '*': return va * vb
640
+ case '&': return va & vb; case '|': return va | vb; case '^': return va ^ vb
641
+ case '<<': return va << vb; case '>>': return va >> vb; case '>>>': return va >>> vb
642
+ default: return null
643
+ }
644
+ }
645
+ const decls = []
529
646
  const walk = (node) => {
530
647
  if (!Array.isArray(node)) return
531
648
  const [op, ...args] = node
532
649
  if (op === '=>') return
533
650
  if (op === 'let' || op === 'const') {
534
- for (const decl of args) {
535
- if (!Array.isArray(decl) || decl[0] !== '=' || typeof decl[1] !== 'string') continue
536
- const value = intLiteralValue(decl[2])
537
- if (value != null && !isReassigned(body, decl[1])) updateRep(decl[1], { intConst: value })
538
- }
651
+ for (const decl of args)
652
+ if (Array.isArray(decl) && decl[0] === '=' && typeof decl[1] === 'string' && !isReassigned(body, decl[1])) decls.push(decl)
539
653
  return
540
654
  }
541
655
  for (const arg of args) walk(arg)
542
656
  }
543
657
  walk(body)
658
+ const seeded = new Set()
659
+ let changed = true
660
+ while (changed) {
661
+ changed = false
662
+ for (const decl of decls) {
663
+ if (seeded.has(decl[1])) continue
664
+ const value = evalC(decl[2])
665
+ if (value != null && Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX) {
666
+ updateRep(decl[1], { intConst: value }); seeded.add(decl[1]); changed = true
667
+ }
668
+ }
669
+ }
544
670
  }
545
671
 
546
672
  // ── Loop-invariant exported-param coercion hoist ────────────────────────────
@@ -562,21 +688,47 @@ function seedLocalIntConsts(body) {
562
688
  const PARAM_REASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=',
563
689
  '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=', '++', '--'])
564
690
  // Binary ops that unconditionally ToNumber BOTH operands, so a bare param operand
565
- // is a pure numeric use. `+` is excluded (may concatenate); comparisons / `===`
566
- // are excluded (they branch on type, never coerce a string operand to number).
691
+ // is a pure numeric use. `+` is excluded (may concatenate); `===`/`==` are excluded
692
+ // (they branch on type, never coerce a string operand to number).
567
693
  const NUM_BIN_OPS = new Set(['*', '/', '%', '**', '&', '|', '^', '<<', '>>', '>>>'])
568
-
569
- /** True iff every use of param `name` in `body` is an unconditional-numeric
570
- * operand, so coercing it to a number once at entry is observationally exact.
571
- * Rejects conservatively: reassignment and any appearance outside a numeric
572
- * operator (member/index/call-arg/return/compare/concat). Two transparencies:
694
+ // Relational ops: jz has no lexicographic compare for an untyped operand — `<`
695
+ // lowers to `f64.lt`, taking the string path only when a *known-string* operand
696
+ // is present (emit.js cmpOp). So a bare param compared against a non-string is a
697
+ // pure numeric use, same as NUM_BIN_OPS. A string-literal counterpart (`x < "m"`)
698
+ // signals string intent and is rejected (handled in the walk below).
699
+ const REL_OPS = new Set(['<', '<=', '>', '>='])
700
+ // A string literal/template operand poisons relational numeric inference.
701
+ const isStrLiteral = (n) => Array.isArray(n) && (n[0] === 'str' || n[0] === 'template')
702
+
703
+ /** True iff every use of param `name` in `body` is numeric-COMPATIBLE *and* at
704
+ * least one use is numeric-PROVING — so coercing it to a number once at entry is
705
+ * observationally exact. Two verdict levels guard against a polymorphic slot
706
+ * passing on absence of evidence:
707
+ * - PROVING (`proven=true`): arithmetic / relational / bitwise / unary operand —
708
+ * JS ToNumbers these, and a string/array value would have shown a disqualifying
709
+ * use elsewhere.
710
+ * - COMPATIBLE-ONLY: the length slot of `new TypedArray(x)` / `new ArrayBuffer(x)`.
711
+ * A number sizes the buffer, but an array is COPIED and a buffer VIEWED — so a
712
+ * bare param here proves nothing. A param used *solely* as `new Float64Array(arr)`
713
+ * stays unproven and keeps the polymorphic ctor dispatch (else array-copy is lost).
714
+ * Any other appearance (member/call-arg/return/concat/`===`/reassignment) rejects.
715
+ * Two transparencies:
573
716
  * - copy aliases: `let x = name` makes `x` carry the same value, so `x`'s uses
574
717
  * must be numeric too (fixpoint-collected). Catches `let T = t` then `…T…`.
575
718
  * - captured closures: a non-shadowing inner arrow captures the binding by
576
719
  * reference, so its body's uses count — we recurse instead of rejecting.
577
720
  * Catches floatbeat helpers `let s=(f)=>…t…` that read the param numerically. */
578
- function paramAllUsesNumeric(body, name) {
721
+ // requireProof=true (default): the param has a ToNumber-FORCING use (PROVES numeric).
722
+ // requireProof=false: the param merely has NO string-requiring use (numeric-COMPATIBLE).
723
+ // Forwarding recursions use the latter — a callee receiving the param need only be
724
+ // string-free (e.g. fbm's `ph`, used additively inside Math.sin), since the OUTER
725
+ // param earns its own proof from its own uses; requiring the callee be self-proven
726
+ // wrongly rejected forwards into additive-only params.
727
+ function paramAllUsesNumeric(body, name, _seen = new Set(), requireProof = true) {
579
728
  if (body == null) return false
729
+ // Local closure defs (`let f = (p,…) => …`) so a call `f(name)` can be judged by
730
+ // f's own param numericity (see the call-arg handler in the walk).
731
+ const closures = new Map() // name → { params:[string], body }
580
732
  // Fixpoint-collect copy aliases: `let/const x = <name-or-alias>`.
581
733
  const names = new Set([name])
582
734
  for (let grew = true; grew;) {
@@ -584,15 +736,25 @@ function paramAllUsesNumeric(body, name) {
584
736
  const collect = (node) => {
585
737
  if (!Array.isArray(node)) return
586
738
  if ((node[0] === 'let' || node[0] === 'const') && node.length === 2
587
- && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string'
588
- && typeof node[1][2] === 'string' && names.has(node[1][2]) && !names.has(node[1][1])) {
589
- names.add(node[1][1]); grew = true
739
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string') {
740
+ const init = node[1][2]
741
+ if (typeof init === 'string' && names.has(init) && !names.has(node[1][1])) { names.add(node[1][1]); grew = true }
742
+ else if (Array.isArray(init) && init[0] === '=>' && !closures.has(node[1][1])) {
743
+ const ps = Array.isArray(init[1]) ? init[1].slice(1) : [init[1]] // ['()', p0, p1] → [p0,p1]
744
+ if (ps.every(p => typeof p === 'string')) closures.set(node[1][1], { params: ps, body: init[2] })
745
+ }
590
746
  }
591
747
  for (let i = 1; i < node.length; i++) collect(node[i])
592
748
  }
593
749
  collect(body)
594
750
  }
595
- let ok = true
751
+ let ok = true, proven = false
752
+ // A param in a numeric-operand slot is a PROVING use; recurse into a non-param sub-expr.
753
+ const numOperand = (n) => { if (names.has(n)) proven = true; else walk(n) }
754
+ // Positional call args, flattening the `(, a b c)` node multi-arg calls parse to —
755
+ // without this a forward like `fbm(x, y, t, …)` never matched its param positions.
756
+ const flat1 = (a) => Array.isArray(a) && a[0] === ',' ? a.slice(1).flatMap(flat1) : [a]
757
+ const callArgList = (n) => n.slice(2).flatMap(flat1)
596
758
  const walk = (node) => {
597
759
  if (!ok) return
598
760
  if (typeof node === 'string') { if (names.has(node)) ok = false; return } // bare use → reject
@@ -617,19 +779,161 @@ function paramAllUsesNumeric(body, name) {
617
779
  }
618
780
  if (PARAM_REASSIGN_OPS.has(op) && names.has(node[1])) { ok = false; return }
619
781
  if (NUM_BIN_OPS.has(op) && node.length === 3) { // numeric binary: operands are ToNumber'd
782
+ numOperand(node[1]); numOperand(node[2])
783
+ return
784
+ }
785
+ if (REL_OPS.has(op) && node.length === 3) { // relational: numeric unless a known string is present
786
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
787
+ numOperand(node[1]); numOperand(node[2])
788
+ return
789
+ }
790
+ // `new TypedArray(x)` / `new ArrayBuffer(x)`: the length argument is ToNumber'd
791
+ // on the alloc path, but a pointer arg is copied (array) or viewed (buffer).
792
+ // A bare param in the length slot is numeric-COMPATIBLE but not PROVING — skip it
793
+ // (no reject, no proof); other args walk normally. A param used *solely* as
794
+ // `new Float64Array(param)` thus stays unproven → keeps the polymorphic ctor (so
795
+ // `f(arr)` copies the array instead of mis-sizing a zero buffer).
796
+ if (op === '()' && typeof node[1] === 'string' && node[1].startsWith('new.')
797
+ && (node[1].endsWith('Array') || node[1] === 'new.ArrayBuffer')) {
798
+ for (let i = 2; i < node.length; i++) if (!names.has(node[i])) walk(node[i])
799
+ return
800
+ }
801
+ // Call of a LOCAL closure `f(…name…)`: forwarding the param flows its value into
802
+ // f's positional param. If that param is itself all-numeric (recursively, with a
803
+ // cycle guard), `name` in that slot is numeric-COMPATIBLE — neither rejected nor
804
+ // proving (so a param used *only* as a forwarded arg stays unproven, like the ctor
805
+ // length slot). Unknown / non-numeric callees fall through and reject (a string
806
+ // could flow in). Covers heapsort's `heapify(n)` and crc32's `crc32(buf)`.
807
+ if (op === '()' && typeof node[1] === 'string' && closures.has(node[1]) && !_seen.has(node[1])) {
808
+ const cl = closures.get(node[1])
809
+ const args = callArgList(node)
810
+ for (let i = 0; i < args.length; i++) {
811
+ if (!names.has(args[i])) { walk(args[i]); continue }
812
+ const param = cl.params[i]
813
+ if (param == null || !paramAllUsesNumeric(cl.body, param, new Set([..._seen, node[1]]), false)) { ok = false; return }
814
+ }
815
+ return
816
+ }
817
+ // Same forwarding judgement for a call to a MODULE-LEVEL user function (sibling,
818
+ // not a body-local closure): `frame` passing its param into a helper `fbm(x,y,t,…)`.
819
+ // Without this the bare arg fell through and rejected, leaving an exported numeric
820
+ // param (plasma/raymarcher's `t`) unproven → per-pixel `__to_num` + polymorphic-`+`
821
+ // string forks. Judge by the callee param's own numericity (recursive, cycle-guarded).
822
+ if (op === '()' && typeof node[1] === 'string' && !_seen.has(node[1])) {
823
+ const fn = ctx.func.map?.get(node[1])
824
+ if (fn && fn.body && !fn.raw && Array.isArray(fn.sig?.params) && !fn.rest) {
825
+ const args = callArgList(node)
826
+ for (let i = 0; i < args.length; i++) {
827
+ if (!names.has(args[i])) { walk(args[i]); continue }
828
+ const p = fn.sig.params[i]
829
+ if (!p || !paramAllUsesNumeric(fn.body, p.name, new Set([..._seen, node[1]]), false)) { ok = false; return }
830
+ }
831
+ return
832
+ }
833
+ }
834
+ // `Math.f(...)` ToNumbers every argument (Math operates on numbers), so a bare
835
+ // param in any arg slot is a PROVING numeric use — same contract as `*`/`-`.
836
+ // Without this, `Math.sin(t)` rejected the param via the generic-call fallthrough,
837
+ // so a numeric kernel like `Math.sin(tick) + …` lost its NUMBER proof and paid a
838
+ // per-use `__to_num` + a polymorphic-`+` string-concat fork (interference example).
839
+ // The callee is the lowered `math.sin` string at emit time (post-autoload), or the
840
+ // raw `(. Math sin)` member pre-lowering — match both.
841
+ const isMathCall = op === '()' && (
842
+ (typeof node[1] === 'string' && node[1].startsWith('math.')) ||
843
+ (Array.isArray(node[1]) && node[1][0] === '.' && node[1][1] === 'Math'))
844
+ if (isMathCall) {
845
+ const numArg = (a) => { if (Array.isArray(a) && a[0] === ',') { numArg(a[1]); numArg(a[2]) } else numOperand(a) }
846
+ for (let i = 2; i < node.length; i++) numArg(node[i])
847
+ return
848
+ }
849
+ // Binary `+` is overloaded (numeric add | string concat). A string-literal
850
+ // operand means concat intent → reject. Otherwise it is numeric-COMPATIBLE but
851
+ // not self-PROVING (a string param would concat) — recurse the non-param operand
852
+ // and treat a bare param as compatible (neither prove nor reject), exactly like
853
+ // paramNeverString. The numeric proof must still come from a ToNumber-forcing use
854
+ // (`*`, `Math.*`, …); a param used ONLY in `+` stays unproven (sound).
855
+ if (op === '+' && node.length === 3) {
856
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
620
857
  if (!names.has(node[1])) walk(node[1])
621
858
  if (!names.has(node[2])) walk(node[2])
622
859
  return
623
860
  }
624
- if (op === '-' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return } // unary negate
625
- if (op === '-' && node.length === 3) { if (!names.has(node[1])) walk(node[1]); if (!names.has(node[2])) walk(node[2]); return }
861
+ if (op === '-' && node.length === 2) { numOperand(node[1]); return } // unary negate
862
+ if (op === '-' && node.length === 3) { numOperand(node[1]); numOperand(node[2]); return }
626
863
  // `u-`/`u+` are the normalized unary minus/plus (prepare rewrites `-x`/`+x`); both ToNumber.
627
- if ((op === 'u-' || op === 'u+') && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return }
628
- if (op === '+' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return } // unary + = ToNumber
629
- if (op === '~' && node.length === 2) { if (!names.has(node[1])) walk(node[1]); return }
864
+ if ((op === 'u-' || op === 'u+') && node.length === 2) { numOperand(node[1]); return }
865
+ if (op === '+' && node.length === 2) { numOperand(node[1]); return } // unary + = ToNumber
866
+ if (op === '~' && node.length === 2) { numOperand(node[1]); return }
630
867
  for (let i = 1; i < node.length; i++) walk(node[i]) // bare param reaching here → rejected above
631
868
  }
632
869
  walk(body)
870
+ return requireProof ? (ok && proven) : ok
871
+ }
872
+
873
+ // String methods whose receiver MUST be a string — their presence proves the
874
+ // param is (sometimes) string and disqualifies the boundary-numeric trust.
875
+ const STRING_RECV_METHODS = new Set([
876
+ 'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith', 'toUpperCase',
877
+ 'toLowerCase', 'normalize', 'localeCompare', 'padStart', 'padEnd', 'repeat',
878
+ 'trim', 'trimStart', 'trimEnd', 'split', 'match', 'matchAll', 'replace',
879
+ 'replaceAll', 'substring', 'substr', 'concat', 'indexOf', 'lastIndexOf',
880
+ 'includes', 'slice',
881
+ ])
882
+
883
+ /** True iff no use of exported f64 param `name` REQUIRES it to be a string — so
884
+ * the interop boundary contract (`wrapVal` passes a JS number straight to an f64
885
+ * param; a string arg is a type misuse already unsupported, returning NaN) makes
886
+ * it provably numeric. Weaker than `paramAllUsesNumeric`: that PROVES numericity
887
+ * from ToNumber-forcing ops, this DISPROVES stringness so binary `+` (the common
888
+ * `accumulator + cre` shape) no longer pessimistically pulls the string-concat
889
+ * fork into a pure float kernel. Only sound under the export boundary — never use
890
+ * for locals/closures, whose values can genuinely be strings.
891
+ *
892
+ * Disqualifying (string-requiring) uses:
893
+ * - `+` with a string-literal/template operand (`"px" + name`) — concat intent
894
+ * - a string-receiver method call (`name.charCodeAt(…)`, `name.split(…)`)
895
+ * - `name[k]` / `name.length` is NOT disqualifying (works on arrays/typed too,
896
+ * but an f64 param is neither — so a member access means the caller passed a
897
+ * pointer, out of the f64-number contract; conservatively we reject it)
898
+ * - passing `name` to a call / returning it / storing into an aggregate: the
899
+ * value escapes where it could be ToString'd; reject conservatively. */
900
+ function paramNeverString(body, name) {
901
+ if (body == null) return false
902
+ let ok = true
903
+ const walk = (node) => {
904
+ if (!ok || node == null) return
905
+ if (typeof node === 'string') { if (node === name) ok = false; return } // bare escape → reject
906
+ if (!Array.isArray(node)) return
907
+ const op = node[0]
908
+ if (op === '=>') return // shadowing-safe: closure handled conservatively (escape)
909
+ // `+` (binary): a string-literal/template operand makes it concat → reject.
910
+ // Otherwise the param is in an arithmetic add; recurse the non-name operand.
911
+ if (op === '+' && node.length === 3) {
912
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
913
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
914
+ return
915
+ }
916
+ // Numeric/relational/bitwise binary + unary: param operand is fine, recurse rest.
917
+ if ((NUM_BIN_OPS.has(op) || REL_OPS.has(op)) && node.length === 3) {
918
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
919
+ return
920
+ }
921
+ if ((op === 'u-' || op === 'u+' || op === '~') && node.length === 2) {
922
+ if (node[1] !== name) walk(node[1]); return
923
+ }
924
+ if (op === '-' && (node.length === 2 || node.length === 3)) {
925
+ for (let i = 1; i < node.length; i++) if (node[i] !== name) walk(node[i])
926
+ return
927
+ }
928
+ // Member access / method call on the param → it's a pointer, not an f64 number:
929
+ // reject (out of contract). `.`/`?.`/`[]` with the name as receiver.
930
+ if ((op === '.' || op === '?.' || op === '[]') && node[1] === name) { ok = false; return }
931
+ // `=`/compound reassignment of the param to a non-numeric value: reject if it
932
+ // could become a string. A reassignment makes the param mutable — conservatively
933
+ // require the RHS to be string-free too (recurse), and the target isn't a use.
934
+ for (let i = 1; i < node.length; i++) walk(node[i])
935
+ }
936
+ walk(body)
633
937
  return ok
634
938
  }
635
939
 
@@ -728,6 +1032,11 @@ function emitFunc(func, funcFacts, programFacts) {
728
1032
  // it the pass is a no-op. `$`-prefixed to match WAT local names directly.
729
1033
  if (funcFacts.cseLoadBases?.size)
730
1034
  fn.cseLoadBases = new Set([...funcFacts.cseLoadBases].map(n => `$${n}`))
1035
+ // Param-distinctness fact (alias analysis): typed-array params proven mutually-distinct buffers
1036
+ // at every call site. `$`-prefixed to match WAT param names; read by hoistInvariantLoop to hoist
1037
+ // a load from one such param across a store to another (they can't alias).
1038
+ if (funcFacts.distinctParams?.size)
1039
+ fn.distinctParams = new Set([...funcFacts.distinctParams].map(n => `$${n}`))
731
1040
  // Inline `(export ...)` attribute only for the syntactic inline-export
732
1041
  // form (`export function foo`, snapshot in `func.exported` at defFunc
733
1042
  // time). Re-exports (`function foo; export { foo }`) and aliases (`export
@@ -868,17 +1177,26 @@ function synthesizeBoundaryWrappers() {
868
1177
  for (const func of ctx.func.list) {
869
1178
  if (!isBoundaryWrapped(func)) continue
870
1179
  const { name, sig } = func
871
- // Quiet NaN-box ABI: every boundary value is f64. A number is a plain f64; a
872
- // tagged value (heap pointer, null/undef/bool atom) is an f64 whose quiet-NaN
873
- // (0x7FF8…) payload carries the tag. Quiet-NaN payloads are preserved across the
874
- // JS↔wasm call boundary by every real engine (and non-JS hosts don't canonicalize
875
- // at all), so no i64 carrier is needed the wasm signature is self-describing
876
- // (f64 everywhere) and a consumer discriminates a tagged value by the NaN prefix.
877
- // Env requirement: a non-canonicalizing NaN boundary. To support a canonicalizing
878
- // engine, a per-position i64 carrier would re-enter here (param/result type i64 +
879
- // `i64.reinterpret_f64`) plus a `jz:i64exp` section for interop.js.
880
- const resultBool = func.valResult === VAL.BOOL && sig.ptrKind == null
881
- const resultBigint = func.valResult === VAL.BIGINT && sig.ptrKind == null
1180
+ // i64 boundary carrier (Safari-safe). A genuine number is never a NaN-box, so it crosses
1181
+ // as plain f64 (zero cost). Everything that can be a NaN-box — heap pointer, null/undef/
1182
+ // bool atom, bigint carrier, or a dynamic value crosses as i64: JSC (Safari) canonicalizes
1183
+ // f64 NaN payloads at the JS↔wasm boundary, erasing the box. The wasm signature is
1184
+ // self-describing; interop.js wrap() reinterprets BigInt↔f64 by bits, driven by the
1185
+ // `jz:i64exp` section emitted below. Non-JS hosts (WASI) read the same signature i64 is
1186
+ // just int64 there, no BigInt.
1187
+ const resultPtr = sig.ptrKind != null
1188
+ const resultBool = func.valResult === VAL.BOOL && !resultPtr
1189
+ const resultBigint = func.valResult === VAL.BIGINT && !resultPtr
1190
+ // Dynamic f64 result: not pointer/bool/bigint and not a proven number → may be a NaN-box
1191
+ // at runtime, so i64. (An i32-carrier result is numeric → stays f64 via convert below.)
1192
+ const resultDynamic = !resultPtr && !resultBool && !resultBigint
1193
+ && sig.results[0] === 'f64' && !func._resultNumeric
1194
+ const resultI64 = resultPtr || resultBool || resultBigint || resultDynamic
1195
+ // jz:i64exp `r` marks results interop must reinterpret then `mem.read`. A bigint result is
1196
+ // i64 too, but the BigInt *is* the value (no reinterpret) — so it stays unmarked.
1197
+ const resultReinterpret = resultPtr || resultBool || resultDynamic
1198
+ // i64 carrier per param: pointer-ABI (offset) or a dynamic f64 param (boundaryI64).
1199
+ const paramIsI64 = (p) => !p.jsstring && (p.ptrKind != null || p.boundaryI64)
882
1200
  // Inline `(export ...)` attribute only when the func decl carried the
883
1201
  // inline-export keyword (`export function foo`). For re-exports
884
1202
  // (`function foo; export { foo as bar }`) the `name` is the *internal*
@@ -888,26 +1206,58 @@ function synthesizeBoundaryWrappers() {
888
1206
  const wrapNode = func.exported
889
1207
  ? ['func', `$${name}$exp`, ['export', `"${name}"`]]
890
1208
  : ['func', `$${name}$exp`]
891
- // jsstring params flow as externref end-to-end; every other boundary value is f64.
892
- sig.params.forEach((p) => {
893
- wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : 'f64'])
1209
+ // jsstring params flow as externref end-to-end; boxed params ride i64; numbers f64.
1210
+ const i64Params = []
1211
+ sig.params.forEach((p, i) => {
1212
+ wrapNode.push(['param', `$${p.name}`, p.jsstring ? 'externref' : paramIsI64(p) ? 'i64' : 'f64'])
1213
+ if (paramIsI64(p)) i64Params.push(i)
894
1214
  })
895
- wrapNode.push(['result', resultBigint ? 'i64' : 'f64'])
1215
+ // Track externref param positions so interop.js can pass JS values raw (skipping
1216
+ // `mem.wrapVal`) at those slots — today only `jsstring` params; future externref carriers
1217
+ // wire here too. `extParams` is per-slot: false | { def: '...' } for a JS-side default.
1218
+ const extParams = sig.params.map(p => !p.jsstring ? false : p.jsstringDefault != null ? { def: p.jsstringDefault } : true)
1219
+ if (extParams.some(Boolean)) func._exportExtParams = extParams
1220
+ // Inner→wrapper argument list, shared by both single- and multi-value result shapes.
896
1221
  const args = sig.params.map((p) => {
897
1222
  const get = ['local.get', `$${p.name}`]
898
- // jsstring: externref flows through unchanged — inner func also takes externref.
899
- if (p.jsstring) return get
900
- // ptrKind param: the f64 NaN-box carries the pointer — extract the i32 offset.
901
- if (p.ptrKind != null) return ['i32.wrap_i64', ['i64.reinterpret_f64', get]]
1223
+ if (p.jsstring) return get // externref flows through unchanged
1224
+ if (p.ptrKind != null) return ['i32.wrap_i64', get] // ptr param: inner takes the i32 offset
1225
+ if (p.boundaryI64) return ['f64.reinterpret_i64', get] // dynamic boxed param f64 NaN-box carrier
902
1226
  if (p.type === 'f64') return get
903
- // Numeric narrowing: f64 → i32 truncate
904
- return ['i32.trunc_sat_f64_s', get]
1227
+ return ['i32.trunc_sat_f64_s', get] // numeric narrowing f64 → i32
905
1228
  })
906
1229
  const callIR = ['call', `$${name}`, ...args]
1230
+ // Multi-value return: each lane is an f64 NaN-box carrier (every `return [a,b,…]` lane is
1231
+ // asF64; narrowing only touches single-result funcs). A boxed lane's NaN payload is erased
1232
+ // at the JS boundary, so cross EVERY lane as i64 — capture the inner call's N lanes into f64
1233
+ // locals (last result on top of the stack ⇒ pop in reverse) and re-push each reinterpreted.
1234
+ // interop reads the lane tuple via mem.read / decode (both map over an array result).
1235
+ if (sig.results.length > 1) {
1236
+ sig.results.forEach(() => wrapNode.push(['result', 'i64']))
1237
+ // Lane temporaries — guaranteed distinct from the wrapper's params (jz doesn't reserve
1238
+ // `__`, so a user param could be `__mlane0`): bump the prefix until no lane name collides.
1239
+ const pnames = new Set(sig.params.map((p) => p.name))
1240
+ let pfx = '__mlane'
1241
+ while (sig.results.some((_, i) => pnames.has(`${pfx}${i}`))) pfx = `_${pfx}`
1242
+ const lanes = sig.results.map((_, i) => `$${pfx}${i}`)
1243
+ lanes.forEach((n) => wrapNode.push(['local', n, 'f64']))
1244
+ const stmts = [callIR]
1245
+ for (let i = lanes.length - 1; i >= 0; i--) stmts.push(['local.set', lanes[i]])
1246
+ for (const n of lanes) stmts.push(['i64.reinterpret_f64', ['local.get', n]])
1247
+ wrapNode.push(...stmts)
1248
+ // `m` (lane count) marks a multi-value result so interop / the test adapter decode each
1249
+ // lane (vs `r`'s single reinterpret). Always recorded — even with no i64 params — so the
1250
+ // numeric-only `(a,b)=>[a+1,b+2]` tuple still gets its lanes turned back into numbers.
1251
+ func._exportI64 = { p: i64Params, m: sig.results.length }
1252
+ wrappers.push(wrapNode)
1253
+ continue
1254
+ }
1255
+ wrapNode.push(['result', resultI64 ? 'i64' : 'f64'])
1256
+ const toI64 = (n) => ['i64.reinterpret_f64', n]
907
1257
  let body
908
- if (sig.ptrKind != null) {
1258
+ if (resultPtr) {
909
1259
  const ptrType = valKindToPtr(sig.ptrKind)
910
- body = mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR)
1260
+ body = toI64(mkPtrIR(ptrType, sig.ptrAux ?? 0, callIR))
911
1261
  } else if (resultBool) {
912
1262
  // The inner func returns a clean 0/1 boolean carrier — never NaN. The i32
913
1263
  // carrier already takes truthyIR's identity path; the f64 carrier would
@@ -917,28 +1267,22 @@ function synthesizeBoundaryWrappers() {
917
1267
  const carrier = sig.results[0] === 'i32'
918
1268
  ? typed(callIR, 'i32')
919
1269
  : typed(['f64.ne', callIR, ['f64.const', 0]], 'i32')
920
- body = boolBoxIR(carrier)
921
- } else if (resultBigint) {
922
- // BigInt rides the i64-reinterpret-f64 carrier internally; expose the raw i64 at the JS
923
- // boundary so the host receives a real, lossless BigInt (wasm i64 <-> JS BigInt). Internal
924
- // callers use `$name` (the f64 carrier) untouched; only the `$exp` export result is i64.
925
- body = ['i64.reinterpret_f64', callIR]
1270
+ body = toI64(boolBoxIR(carrier))
1271
+ } else if (resultBigint || resultDynamic) {
1272
+ // BigInt rides the i64-reinterpret-f64 carrier internally; a dynamic result is already an
1273
+ // f64 NaN-box carrier. Either way expose the raw i64 at the JS boundary for a lossless
1274
+ // value. Internal callers use `$name` (the f64 carrier) untouched; only `$exp` is i64.
1275
+ body = toI64(callIR)
926
1276
  } else if (sig.results[0] === 'i32') {
927
1277
  body = [sig.unsignedResult ? 'f64.convert_i32_u' : 'f64.convert_i32_s', callIR]
928
1278
  } else {
929
1279
  body = callIR
930
1280
  }
931
1281
  wrapNode.push(body)
932
- // Track externref param positions so interop.js can pass JS values
933
- // raw (skipping `mem.wrapVal`) at those slots. Today this only fires
934
- // for `jsstring`-tagged params; future externref carriers wire here too.
935
- // `extParams` is per-slot: false (non-ext) | { def: '...' }-bearing object
936
- // for jsstring params with a JS-side default substitution.
937
- const extParams = sig.params.map(p => {
938
- if (!p.jsstring) return false
939
- return p.jsstringDefault != null ? { def: p.jsstringDefault } : true
940
- })
941
- if (extParams.some(Boolean)) func._exportExtParams = extParams
1282
+ // Record the i64 carrier map for interop.js (jz:i64exp). A pure-numeric export
1283
+ // (no i64 params, f64 result) records nothing zero footprint off the box path.
1284
+ if (i64Params.length || resultReinterpret)
1285
+ func._exportI64 = { p: i64Params, r: resultReinterpret ? 1 : 0 }
942
1286
  wrappers.push(wrapNode)
943
1287
  }
944
1288
  return wrappers
@@ -1025,6 +1369,32 @@ function emitClosureBody(cb) {
1025
1369
  if (ptRow[i] === true && !ctx.func.localReps?.get(cb.params[i])?.val)
1026
1370
  updateRep(cb.params[i], i < minArgc ? { val: VAL.NUMBER } : { val: VAL.NUMBER, nullable: true })
1027
1371
  }
1372
+ // A param passed the same typed-array ctor at every direct call site is TYPED:
1373
+ // register its element ctor so `buf[i]` reads use the typed load (it stays an f64
1374
+ // NaN-box in the closure ABI, but knowing the kind avoids the dynamic `__typed_idx`
1375
+ // /`__len` dispatch that pulls the string runtime). Numeric trust (above) wins if it
1376
+ // already classified the slot — they're disjoint anyway (NUMBER vs TYPED arg).
1377
+ const tcRow = ctx.closure.paramTypedCtors?.get(cb.name)
1378
+ if (tcRow) for (let i = 0; i < cb.params.length; i++) {
1379
+ const ctor = tcRow[i]
1380
+ if (ctor && !ctx.func.localReps?.get(cb.params[i])?.val) {
1381
+ updateRep(cb.params[i], { val: VAL.TYPED })
1382
+ ;(ctx.types.typedElem ||= new Map()).set(cb.params[i], ctor)
1383
+ }
1384
+ }
1385
+ // Body-usage numeric trust for closure params — the same proof the export path
1386
+ // applies (paramAllUsesNumeric). A nested helper like heapsort's `heapify(n)` whose
1387
+ // param is used only in arithmetic/relational positions is VAL.NUMBER, so `(n>>1)-1`
1388
+ // skips the `__to_num` coercion that would otherwise drag the ToNumber string-parse
1389
+ // tree into a pure-integer kernel. paramAllUsesNumeric walks any AST node, so this
1390
+ // also covers expression-bodied arrows (`(m) => m | 0`) — the common closure shape
1391
+ // whose dynamic param would otherwise emit a polymorphic add/coerce that pulls the
1392
+ // whole string runtime in. Call-site evidence (ptRow) already covers the monomorphic
1393
+ // case; this also catches params the lattice left unobserved.
1394
+ for (const p of cb.params) {
1395
+ if (!ctx.func.localReps?.get(p)?.val && !cb.defaults?.[p] && paramAllUsesNumeric(cb.body, p))
1396
+ updateRep(p, { val: VAL.NUMBER })
1397
+ }
1028
1398
 
1029
1399
  // Register captured variable locals: boxed = i32 cell pointer, otherwise f64 value
1030
1400
  for (let i = 0; i < cb.captures.length; i++) {
@@ -1308,6 +1678,11 @@ export default function compile(ast, profiler) {
1308
1678
  }
1309
1679
  }
1310
1680
 
1681
+ // Whole-program constant fold of module-scope aggregate literals — `var x=[1,2,3];
1682
+ // y=x[0]` → `y=1`, dropping the array (no data segment, no __arr_idx_known) when
1683
+ // every reference is a static read. The scalar analog of the constInts fold above.
1684
+ timePhase(profiler, 'foldAggregates', () => foldStaticConstAggregates(ast))
1685
+
1311
1686
  const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
1312
1687
 
1313
1688
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
@@ -1448,13 +1823,35 @@ export default function compile(ast, profiler) {
1448
1823
  ensureThrowRuntime(sec)
1449
1824
 
1450
1825
  timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1826
+ if (ctx.transform.helperCallsites) instrumentHelperCallsites([...sec.funcs, ...sec.stdlib, ...sec.start])
1827
+
1828
+ // Fold constant `__start` global inits into immutable inline decls (drops the
1829
+ // store, and `__start` with it when that empties it). Runs HERE — after
1830
+ // stripStaticDataPrefix and optimizeModule — so any data-segment offset a hoisted
1831
+ // pointer carries is already in its final, shifted form (hoisting earlier would
1832
+ // freeze a pre-strip offset the shift pass never revisits in the global decl).
1833
+ hoistConstGlobalInits(sec)
1451
1834
 
1452
1835
  // Populate globals (after __start — const folding may update declarations).
1453
1836
  // Records build IR directly — no WAT-text parse-back.
1454
- sec.globals.push(...[...ctx.scope.globals].filter(([, g]) => g).map(([n, g]) => ['global', `$${n}`,
1455
- ...(g.export ? [['export', `"${g.export}"`]] : []),
1456
- g.mut ? ['mut', g.type] : g.type,
1457
- [`${g.type}.const`, g.init]]))
1837
+ // The wasm type comes from globalTypes (the canonical name→type map declGlobal
1838
+ // maintains alongside the entry), falling back to the entry's own `.type`. They
1839
+ // are normally identical, but a global whose entry object is later rebuilt (e.g.
1840
+ // hoistConstGlobalInits' `{...g, …}` spread) must not depend on that rebuild
1841
+ // preserving `.type` — globalTypes is the stable source, so an entry that lost
1842
+ // its `.type` still emits a well-typed `(global …)` rather than `(undefined.const)`.
1843
+ sec.globals.push(...[...ctx.scope.globals].filter(([, g]) => g).map(([n, g]) => {
1844
+ const ty = ctx.scope.globalTypes.get(n) ?? g.type
1845
+ return ['global', `$${n}`,
1846
+ ...(g.export ? [['export', `"${g.export}"`]] : []),
1847
+ g.mut ? ['mut', ty] : ty,
1848
+ [`${ty}.const`, g.init]]
1849
+ }))
1850
+
1851
+ // Drop the Eisel-Lemire decimal table if no live code parses decimals at runtime — must
1852
+ // run after sec.globals/funcs are final (exact reachability) and before the data segment
1853
+ // below serializes ctx.runtime.data. See stripDeadElTable.
1854
+ stripDeadElTable(sec)
1458
1855
 
1459
1856
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1460
1857
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1540,6 +1937,23 @@ export default function compile(ast, profiler) {
1540
1937
  if (extExports.length)
1541
1938
  sec.customs.push(['@custom', '"jz:extparam"', `"${JSON.stringify(extExports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1542
1939
 
1940
+ // jz:i64exp — per-export i64 carrier map (NaN-canonicalization dodging). Each entry
1941
+ // `{name, p:[i64 param indices], r:1? | m:N?}`: `p` lists params interop must pass as BigInt
1942
+ // (f64ToI64); `r` marks a single result to reinterpret (i64ToF64) before mem.read; `m` marks
1943
+ // an N-lane multi-value result whose lanes interop/the adapter decode element-wise. Pure-
1944
+ // numeric single-result exports emit no entry. A bigint result is i64 but unmarked (the BigInt
1945
+ // is the value). Written under every JS-visible alias, like jz:extparam. Each shape is built as
1946
+ // a direct literal (no spread) — the self-host kernel's fixed schemas don't enumerate post-hoc keys.
1947
+ const i64Exports = []
1948
+ for (const f of ctx.func.list) {
1949
+ if (!isExported(f) || !isBoundaryWrapped(f) || !f._exportI64) continue
1950
+ const { p, r, m } = f._exportI64
1951
+ for (const exportName of exportNamesOf(f.name))
1952
+ i64Exports.push(m ? { name: exportName, p, m } : r ? { name: exportName, p, r } : { name: exportName, p })
1953
+ }
1954
+ if (i64Exports.length)
1955
+ sec.customs.push(['@custom', '"jz:i64exp"', `"${JSON.stringify(i64Exports).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`])
1956
+
1543
1957
  // Named export aliases: export { name } or export { source as alias }
1544
1958
  for (const [name, val] of Object.entries(ctx.func.exports)) {
1545
1959
  if (wasiCommandExports.has(name)) continue
@@ -1564,7 +1978,8 @@ export default function compile(ast, profiler) {
1564
1978
  const { callCount } = treeshake(
1565
1979
  [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
1566
1980
  [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports, ...sec.tags],
1567
- { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals }
1981
+ { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals, userGlobals: ctx.scope.userGlobals,
1982
+ userFuncs: new Set(ctx.func.list.map(f => `$${f.name}`)) }
1568
1983
  )
1569
1984
 
1570
1985
  pruneUnusedThrowRuntime(sec)