jz 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
@@ -37,6 +37,8 @@ import { typedElemAux } from '../../layout.js'
37
37
  import { VAL, updateRep, REP_FIELDS } from '../reps.js'
38
38
  import { inferLocals } from './infer.js'
39
39
  import { optimizeFunc, treeshake } from '../optimize/index.js'
40
+ import { strengthReduceLoopDivMod } from './loop-divmod.js'
41
+ import { peelClampedStencil } from './peel-stencil.js'
40
42
  import { emit, emitter, emitVoid, emitBlockBody } from './emit.js'
41
43
  import { emitCharDecompPrologue, JSS_IMPORT_SIGS } from '../abi/string.js'
42
44
  import {
@@ -54,9 +56,10 @@ import {
54
56
  I32_MIN, I32_MAX, dollar,
55
57
  } from '../ir.js'
56
58
  import plan from './plan/index.js'
59
+ import { foldStaticConstAggregates } from './plan/literals.js'
57
60
  import {
58
61
  buildStartFn, dedupClosureBodies, finalizeClosureTable,
59
- pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix,
62
+ pullStdlib, syncImports, optimizeModule, stripStaticDataPrefix, hoistConstGlobalInits,
60
63
  } from '../wat/assemble.js'
61
64
 
62
65
  // =============================================================================
@@ -346,6 +349,7 @@ function scanAndTagNonEscapingClosures(body) {
346
349
  // their synthetic labels can't collide with the parent frame's.
347
350
  function enterFunc(sig, body, { uniq = 0, directClosures = null } = {}) {
348
351
  ctx.func.stack = []
352
+ 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
353
  ctx.func.maybeNullish = new Set() // bindings assigned a nullish literal → coerce in arithmetic (null-flow)
350
354
  ctx.func.pendingLabel = null // label awaiting its loop, for `continue <label>`
351
355
  ctx.func.uniq = uniq
@@ -379,6 +383,15 @@ function analyzeFuncForEmit(func, programFacts) {
379
383
  const { paramReps } = programFacts
380
384
  if (func.raw) return null
381
385
 
386
+ // Strength-reduce per-iteration `i % w` / `(i/w)|0` to incremental i32 counters
387
+ // (idempotent: a reduced loop has no modulo left to match). Before analyze so the
388
+ // counters are typed/narrowed like any i32 local. Off at L0 / `loopIVDivMod:false`.
389
+ const _o = ctx.transform.optimize
390
+ if (_o && _o.loopIVDivMod !== false && isBlockBody(func.body)) func.body = strengthReduceLoopDivMod(func.body)
391
+ // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
392
+ // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
393
+ if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
394
+
382
395
  const { name, body, sig } = func
383
396
  enterFunc(sig, body)
384
397
 
@@ -417,7 +430,11 @@ function analyzeFuncForEmit(func, programFacts) {
417
430
  if (p.type === 'f64' && p.ptrKind == null && !p.jsstring
418
431
  && !func.defaults?.[p.name] && !ctx.func.boxed?.has(p.name)
419
432
  && !ctx.func.localReps?.get(p.name)?.val
420
- && paramAllUsesNumeric(body, p.name))
433
+ // Numeric either by PROOF (ToNumber-forcing uses) or by the export
434
+ // boundary contract (never used as a string → wrapVal guarantees a
435
+ // number). The latter catches `acc + cre` float kernels whose `+` would
436
+ // otherwise pull a per-iteration string-concat fork (julia, floatbeats).
437
+ && (paramAllUsesNumeric(body, p.name) || paramNeverString(body, p.name)))
421
438
  updateRep(p.name, { val: VAL.NUMBER })
422
439
  }
423
440
  }
@@ -434,6 +451,8 @@ function analyzeFuncForEmit(func, programFacts) {
434
451
  if (bodyFacts?.valTypes) {
435
452
  for (const [name, vt] of bodyFacts.valTypes) updateRep(name, { val: vt })
436
453
  }
454
+ // Never-relocated array bindings — the `[]` reader skips the forwarding follow.
455
+ if (bodyFacts?.neverGrown) for (const name of bodyFacts.neverGrown) updateRep(name, { neverGrown: true })
437
456
  // Proven uint32 accumulator locals — readVar tags reads `.unsigned` so the
438
457
  // f64 round-trip widens with convert_i32_u (not _s).
439
458
  if (bodyFacts?.unsignedLocals) for (const n of bodyFacts.unsignedLocals) updateRep(n, { unsigned: true })
@@ -526,21 +545,54 @@ function analyzeFuncForEmit(func, programFacts) {
526
545
  }
527
546
 
528
547
  function seedLocalIntConsts(body) {
548
+ // Fold each never-reassigned local `const`/`let NAME = EXPR` to a known i32, so a
549
+ // divisor / bound / size built from earlier consts (`rr = R|0; win = 2*rr+1`) becomes
550
+ // a compile-time literal — which lets the int-divide lowering hand the wasm backend a
551
+ // constant divisor to magic-multiply (no runtime sdiv), array bounds resolve, etc.
552
+ // Mirrors the module-scope fold (evalConst above); a string ref resolves through the
553
+ // intConst already recorded on its rep, and the fixpoint lets a later const see an
554
+ // earlier one regardless of declaration order. Skips nested functions (own scope).
555
+ const evalC = (n) => {
556
+ if (typeof n === 'number') return Number.isInteger(n) ? n : null
557
+ if (Array.isArray(n) && n[0] == null && typeof n[1] === 'number') return Number.isInteger(n[1]) ? n[1] : null
558
+ if (typeof n === 'string') return intLiteralValue(n) // a seeded intConst / literal local
559
+ if (!Array.isArray(n)) return null
560
+ const [op, a, b] = n
561
+ const va = evalC(a); if (va == null) return null
562
+ if (op === 'u-' || (op === '-' && b === undefined)) return -va
563
+ const vb = evalC(b); if (vb == null) return null
564
+ switch (op) {
565
+ case '+': return va + vb; case '-': return va - vb; case '*': return va * vb
566
+ case '&': return va & vb; case '|': return va | vb; case '^': return va ^ vb
567
+ case '<<': return va << vb; case '>>': return va >> vb; case '>>>': return va >>> vb
568
+ default: return null
569
+ }
570
+ }
571
+ const decls = []
529
572
  const walk = (node) => {
530
573
  if (!Array.isArray(node)) return
531
574
  const [op, ...args] = node
532
575
  if (op === '=>') return
533
576
  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
- }
577
+ for (const decl of args)
578
+ if (Array.isArray(decl) && decl[0] === '=' && typeof decl[1] === 'string' && !isReassigned(body, decl[1])) decls.push(decl)
539
579
  return
540
580
  }
541
581
  for (const arg of args) walk(arg)
542
582
  }
543
583
  walk(body)
584
+ const seeded = new Set()
585
+ let changed = true
586
+ while (changed) {
587
+ changed = false
588
+ for (const decl of decls) {
589
+ if (seeded.has(decl[1])) continue
590
+ const value = evalC(decl[2])
591
+ if (value != null && Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX) {
592
+ updateRep(decl[1], { intConst: value }); seeded.add(decl[1]); changed = true
593
+ }
594
+ }
595
+ }
544
596
  }
545
597
 
546
598
  // ── Loop-invariant exported-param coercion hoist ────────────────────────────
@@ -562,21 +614,47 @@ function seedLocalIntConsts(body) {
562
614
  const PARAM_REASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=',
563
615
  '^=', '>>=', '<<=', '>>>=', '||=', '&&=', '??=', '++', '--'])
564
616
  // 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).
617
+ // is a pure numeric use. `+` is excluded (may concatenate); `===`/`==` are excluded
618
+ // (they branch on type, never coerce a string operand to number).
567
619
  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:
620
+ // Relational ops: jz has no lexicographic compare for an untyped operand — `<`
621
+ // lowers to `f64.lt`, taking the string path only when a *known-string* operand
622
+ // is present (emit.js cmpOp). So a bare param compared against a non-string is a
623
+ // pure numeric use, same as NUM_BIN_OPS. A string-literal counterpart (`x < "m"`)
624
+ // signals string intent and is rejected (handled in the walk below).
625
+ const REL_OPS = new Set(['<', '<=', '>', '>='])
626
+ // A string literal/template operand poisons relational numeric inference.
627
+ const isStrLiteral = (n) => Array.isArray(n) && (n[0] === 'str' || n[0] === 'template')
628
+
629
+ /** True iff every use of param `name` in `body` is numeric-COMPATIBLE *and* at
630
+ * least one use is numeric-PROVING — so coercing it to a number once at entry is
631
+ * observationally exact. Two verdict levels guard against a polymorphic slot
632
+ * passing on absence of evidence:
633
+ * - PROVING (`proven=true`): arithmetic / relational / bitwise / unary operand —
634
+ * JS ToNumbers these, and a string/array value would have shown a disqualifying
635
+ * use elsewhere.
636
+ * - COMPATIBLE-ONLY: the length slot of `new TypedArray(x)` / `new ArrayBuffer(x)`.
637
+ * A number sizes the buffer, but an array is COPIED and a buffer VIEWED — so a
638
+ * bare param here proves nothing. A param used *solely* as `new Float64Array(arr)`
639
+ * stays unproven and keeps the polymorphic ctor dispatch (else array-copy is lost).
640
+ * Any other appearance (member/call-arg/return/concat/`===`/reassignment) rejects.
641
+ * Two transparencies:
573
642
  * - copy aliases: `let x = name` makes `x` carry the same value, so `x`'s uses
574
643
  * must be numeric too (fixpoint-collected). Catches `let T = t` then `…T…`.
575
644
  * - captured closures: a non-shadowing inner arrow captures the binding by
576
645
  * reference, so its body's uses count — we recurse instead of rejecting.
577
646
  * Catches floatbeat helpers `let s=(f)=>…t…` that read the param numerically. */
578
- function paramAllUsesNumeric(body, name) {
647
+ // requireProof=true (default): the param has a ToNumber-FORCING use (PROVES numeric).
648
+ // requireProof=false: the param merely has NO string-requiring use (numeric-COMPATIBLE).
649
+ // Forwarding recursions use the latter — a callee receiving the param need only be
650
+ // string-free (e.g. fbm's `ph`, used additively inside Math.sin), since the OUTER
651
+ // param earns its own proof from its own uses; requiring the callee be self-proven
652
+ // wrongly rejected forwards into additive-only params.
653
+ function paramAllUsesNumeric(body, name, _seen = new Set(), requireProof = true) {
579
654
  if (body == null) return false
655
+ // Local closure defs (`let f = (p,…) => …`) so a call `f(name)` can be judged by
656
+ // f's own param numericity (see the call-arg handler in the walk).
657
+ const closures = new Map() // name → { params:[string], body }
580
658
  // Fixpoint-collect copy aliases: `let/const x = <name-or-alias>`.
581
659
  const names = new Set([name])
582
660
  for (let grew = true; grew;) {
@@ -584,15 +662,25 @@ function paramAllUsesNumeric(body, name) {
584
662
  const collect = (node) => {
585
663
  if (!Array.isArray(node)) return
586
664
  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
665
+ && Array.isArray(node[1]) && node[1][0] === '=' && typeof node[1][1] === 'string') {
666
+ const init = node[1][2]
667
+ if (typeof init === 'string' && names.has(init) && !names.has(node[1][1])) { names.add(node[1][1]); grew = true }
668
+ else if (Array.isArray(init) && init[0] === '=>' && !closures.has(node[1][1])) {
669
+ const ps = Array.isArray(init[1]) ? init[1].slice(1) : [init[1]] // ['()', p0, p1] → [p0,p1]
670
+ if (ps.every(p => typeof p === 'string')) closures.set(node[1][1], { params: ps, body: init[2] })
671
+ }
590
672
  }
591
673
  for (let i = 1; i < node.length; i++) collect(node[i])
592
674
  }
593
675
  collect(body)
594
676
  }
595
- let ok = true
677
+ let ok = true, proven = false
678
+ // A param in a numeric-operand slot is a PROVING use; recurse into a non-param sub-expr.
679
+ const numOperand = (n) => { if (names.has(n)) proven = true; else walk(n) }
680
+ // Positional call args, flattening the `(, a b c)` node multi-arg calls parse to —
681
+ // without this a forward like `fbm(x, y, t, …)` never matched its param positions.
682
+ const flat1 = (a) => Array.isArray(a) && a[0] === ',' ? a.slice(1).flatMap(flat1) : [a]
683
+ const callArgList = (n) => n.slice(2).flatMap(flat1)
596
684
  const walk = (node) => {
597
685
  if (!ok) return
598
686
  if (typeof node === 'string') { if (names.has(node)) ok = false; return } // bare use → reject
@@ -617,19 +705,161 @@ function paramAllUsesNumeric(body, name) {
617
705
  }
618
706
  if (PARAM_REASSIGN_OPS.has(op) && names.has(node[1])) { ok = false; return }
619
707
  if (NUM_BIN_OPS.has(op) && node.length === 3) { // numeric binary: operands are ToNumber'd
708
+ numOperand(node[1]); numOperand(node[2])
709
+ return
710
+ }
711
+ if (REL_OPS.has(op) && node.length === 3) { // relational: numeric unless a known string is present
712
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
713
+ numOperand(node[1]); numOperand(node[2])
714
+ return
715
+ }
716
+ // `new TypedArray(x)` / `new ArrayBuffer(x)`: the length argument is ToNumber'd
717
+ // on the alloc path, but a pointer arg is copied (array) or viewed (buffer).
718
+ // A bare param in the length slot is numeric-COMPATIBLE but not PROVING — skip it
719
+ // (no reject, no proof); other args walk normally. A param used *solely* as
720
+ // `new Float64Array(param)` thus stays unproven → keeps the polymorphic ctor (so
721
+ // `f(arr)` copies the array instead of mis-sizing a zero buffer).
722
+ if (op === '()' && typeof node[1] === 'string' && node[1].startsWith('new.')
723
+ && (node[1].endsWith('Array') || node[1] === 'new.ArrayBuffer')) {
724
+ for (let i = 2; i < node.length; i++) if (!names.has(node[i])) walk(node[i])
725
+ return
726
+ }
727
+ // Call of a LOCAL closure `f(…name…)`: forwarding the param flows its value into
728
+ // f's positional param. If that param is itself all-numeric (recursively, with a
729
+ // cycle guard), `name` in that slot is numeric-COMPATIBLE — neither rejected nor
730
+ // proving (so a param used *only* as a forwarded arg stays unproven, like the ctor
731
+ // length slot). Unknown / non-numeric callees fall through and reject (a string
732
+ // could flow in). Covers heapsort's `heapify(n)` and crc32's `crc32(buf)`.
733
+ if (op === '()' && typeof node[1] === 'string' && closures.has(node[1]) && !_seen.has(node[1])) {
734
+ const cl = closures.get(node[1])
735
+ const args = callArgList(node)
736
+ for (let i = 0; i < args.length; i++) {
737
+ if (!names.has(args[i])) { walk(args[i]); continue }
738
+ const param = cl.params[i]
739
+ if (param == null || !paramAllUsesNumeric(cl.body, param, new Set([..._seen, node[1]]), false)) { ok = false; return }
740
+ }
741
+ return
742
+ }
743
+ // Same forwarding judgement for a call to a MODULE-LEVEL user function (sibling,
744
+ // not a body-local closure): `frame` passing its param into a helper `fbm(x,y,t,…)`.
745
+ // Without this the bare arg fell through and rejected, leaving an exported numeric
746
+ // param (plasma/raymarcher's `t`) unproven → per-pixel `__to_num` + polymorphic-`+`
747
+ // string forks. Judge by the callee param's own numericity (recursive, cycle-guarded).
748
+ if (op === '()' && typeof node[1] === 'string' && !_seen.has(node[1])) {
749
+ const fn = ctx.func.map?.get(node[1])
750
+ if (fn && fn.body && !fn.raw && Array.isArray(fn.sig?.params) && !fn.rest) {
751
+ const args = callArgList(node)
752
+ for (let i = 0; i < args.length; i++) {
753
+ if (!names.has(args[i])) { walk(args[i]); continue }
754
+ const p = fn.sig.params[i]
755
+ if (!p || !paramAllUsesNumeric(fn.body, p.name, new Set([..._seen, node[1]]), false)) { ok = false; return }
756
+ }
757
+ return
758
+ }
759
+ }
760
+ // `Math.f(...)` ToNumbers every argument (Math operates on numbers), so a bare
761
+ // param in any arg slot is a PROVING numeric use — same contract as `*`/`-`.
762
+ // Without this, `Math.sin(t)` rejected the param via the generic-call fallthrough,
763
+ // so a numeric kernel like `Math.sin(tick) + …` lost its NUMBER proof and paid a
764
+ // per-use `__to_num` + a polymorphic-`+` string-concat fork (interference example).
765
+ // The callee is the lowered `math.sin` string at emit time (post-autoload), or the
766
+ // raw `(. Math sin)` member pre-lowering — match both.
767
+ const isMathCall = op === '()' && (
768
+ (typeof node[1] === 'string' && node[1].startsWith('math.')) ||
769
+ (Array.isArray(node[1]) && node[1][0] === '.' && node[1][1] === 'Math'))
770
+ if (isMathCall) {
771
+ const numArg = (a) => { if (Array.isArray(a) && a[0] === ',') { numArg(a[1]); numArg(a[2]) } else numOperand(a) }
772
+ for (let i = 2; i < node.length; i++) numArg(node[i])
773
+ return
774
+ }
775
+ // Binary `+` is overloaded (numeric add | string concat). A string-literal
776
+ // operand means concat intent → reject. Otherwise it is numeric-COMPATIBLE but
777
+ // not self-PROVING (a string param would concat) — recurse the non-param operand
778
+ // and treat a bare param as compatible (neither prove nor reject), exactly like
779
+ // paramNeverString. The numeric proof must still come from a ToNumber-forcing use
780
+ // (`*`, `Math.*`, …); a param used ONLY in `+` stays unproven (sound).
781
+ if (op === '+' && node.length === 3) {
782
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
620
783
  if (!names.has(node[1])) walk(node[1])
621
784
  if (!names.has(node[2])) walk(node[2])
622
785
  return
623
786
  }
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 }
787
+ if (op === '-' && node.length === 2) { numOperand(node[1]); return } // unary negate
788
+ if (op === '-' && node.length === 3) { numOperand(node[1]); numOperand(node[2]); return }
626
789
  // `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 }
790
+ if ((op === 'u-' || op === 'u+') && node.length === 2) { numOperand(node[1]); return }
791
+ if (op === '+' && node.length === 2) { numOperand(node[1]); return } // unary + = ToNumber
792
+ if (op === '~' && node.length === 2) { numOperand(node[1]); return }
630
793
  for (let i = 1; i < node.length; i++) walk(node[i]) // bare param reaching here → rejected above
631
794
  }
632
795
  walk(body)
796
+ return requireProof ? (ok && proven) : ok
797
+ }
798
+
799
+ // String methods whose receiver MUST be a string — their presence proves the
800
+ // param is (sometimes) string and disqualifies the boundary-numeric trust.
801
+ const STRING_RECV_METHODS = new Set([
802
+ 'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith', 'toUpperCase',
803
+ 'toLowerCase', 'normalize', 'localeCompare', 'padStart', 'padEnd', 'repeat',
804
+ 'trim', 'trimStart', 'trimEnd', 'split', 'match', 'matchAll', 'replace',
805
+ 'replaceAll', 'substring', 'substr', 'concat', 'indexOf', 'lastIndexOf',
806
+ 'includes', 'slice',
807
+ ])
808
+
809
+ /** True iff no use of exported f64 param `name` REQUIRES it to be a string — so
810
+ * the interop boundary contract (`wrapVal` passes a JS number straight to an f64
811
+ * param; a string arg is a type misuse already unsupported, returning NaN) makes
812
+ * it provably numeric. Weaker than `paramAllUsesNumeric`: that PROVES numericity
813
+ * from ToNumber-forcing ops, this DISPROVES stringness so binary `+` (the common
814
+ * `accumulator + cre` shape) no longer pessimistically pulls the string-concat
815
+ * fork into a pure float kernel. Only sound under the export boundary — never use
816
+ * for locals/closures, whose values can genuinely be strings.
817
+ *
818
+ * Disqualifying (string-requiring) uses:
819
+ * - `+` with a string-literal/template operand (`"px" + name`) — concat intent
820
+ * - a string-receiver method call (`name.charCodeAt(…)`, `name.split(…)`)
821
+ * - `name[k]` / `name.length` is NOT disqualifying (works on arrays/typed too,
822
+ * but an f64 param is neither — so a member access means the caller passed a
823
+ * pointer, out of the f64-number contract; conservatively we reject it)
824
+ * - passing `name` to a call / returning it / storing into an aggregate: the
825
+ * value escapes where it could be ToString'd; reject conservatively. */
826
+ function paramNeverString(body, name) {
827
+ if (body == null) return false
828
+ let ok = true
829
+ const walk = (node) => {
830
+ if (!ok || node == null) return
831
+ if (typeof node === 'string') { if (node === name) ok = false; return } // bare escape → reject
832
+ if (!Array.isArray(node)) return
833
+ const op = node[0]
834
+ if (op === '=>') return // shadowing-safe: closure handled conservatively (escape)
835
+ // `+` (binary): a string-literal/template operand makes it concat → reject.
836
+ // Otherwise the param is in an arithmetic add; recurse the non-name operand.
837
+ if (op === '+' && node.length === 3) {
838
+ if (isStrLiteral(node[1]) || isStrLiteral(node[2])) { ok = false; return }
839
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
840
+ return
841
+ }
842
+ // Numeric/relational/bitwise binary + unary: param operand is fine, recurse rest.
843
+ if ((NUM_BIN_OPS.has(op) || REL_OPS.has(op)) && node.length === 3) {
844
+ for (let i = 1; i <= 2; i++) if (node[i] !== name) walk(node[i])
845
+ return
846
+ }
847
+ if ((op === 'u-' || op === 'u+' || op === '~') && node.length === 2) {
848
+ if (node[1] !== name) walk(node[1]); return
849
+ }
850
+ if (op === '-' && (node.length === 2 || node.length === 3)) {
851
+ for (let i = 1; i < node.length; i++) if (node[i] !== name) walk(node[i])
852
+ return
853
+ }
854
+ // Member access / method call on the param → it's a pointer, not an f64 number:
855
+ // reject (out of contract). `.`/`?.`/`[]` with the name as receiver.
856
+ if ((op === '.' || op === '?.' || op === '[]') && node[1] === name) { ok = false; return }
857
+ // `=`/compound reassignment of the param to a non-numeric value: reject if it
858
+ // could become a string. A reassignment makes the param mutable — conservatively
859
+ // require the RHS to be string-free too (recurse), and the target isn't a use.
860
+ for (let i = 1; i < node.length; i++) walk(node[i])
861
+ }
862
+ walk(body)
633
863
  return ok
634
864
  }
635
865
 
@@ -1025,6 +1255,32 @@ function emitClosureBody(cb) {
1025
1255
  if (ptRow[i] === true && !ctx.func.localReps?.get(cb.params[i])?.val)
1026
1256
  updateRep(cb.params[i], i < minArgc ? { val: VAL.NUMBER } : { val: VAL.NUMBER, nullable: true })
1027
1257
  }
1258
+ // A param passed the same typed-array ctor at every direct call site is TYPED:
1259
+ // register its element ctor so `buf[i]` reads use the typed load (it stays an f64
1260
+ // NaN-box in the closure ABI, but knowing the kind avoids the dynamic `__typed_idx`
1261
+ // /`__len` dispatch that pulls the string runtime). Numeric trust (above) wins if it
1262
+ // already classified the slot — they're disjoint anyway (NUMBER vs TYPED arg).
1263
+ const tcRow = ctx.closure.paramTypedCtors?.get(cb.name)
1264
+ if (tcRow) for (let i = 0; i < cb.params.length; i++) {
1265
+ const ctor = tcRow[i]
1266
+ if (ctor && !ctx.func.localReps?.get(cb.params[i])?.val) {
1267
+ updateRep(cb.params[i], { val: VAL.TYPED })
1268
+ ;(ctx.types.typedElem ||= new Map()).set(cb.params[i], ctor)
1269
+ }
1270
+ }
1271
+ // Body-usage numeric trust for closure params — the same proof the export path
1272
+ // applies (paramAllUsesNumeric). A nested helper like heapsort's `heapify(n)` whose
1273
+ // param is used only in arithmetic/relational positions is VAL.NUMBER, so `(n>>1)-1`
1274
+ // skips the `__to_num` coercion that would otherwise drag the ToNumber string-parse
1275
+ // tree into a pure-integer kernel. paramAllUsesNumeric walks any AST node, so this
1276
+ // also covers expression-bodied arrows (`(m) => m | 0`) — the common closure shape
1277
+ // whose dynamic param would otherwise emit a polymorphic add/coerce that pulls the
1278
+ // whole string runtime in. Call-site evidence (ptRow) already covers the monomorphic
1279
+ // case; this also catches params the lattice left unobserved.
1280
+ for (const p of cb.params) {
1281
+ if (!ctx.func.localReps?.get(p)?.val && !cb.defaults?.[p] && paramAllUsesNumeric(cb.body, p))
1282
+ updateRep(p, { val: VAL.NUMBER })
1283
+ }
1028
1284
 
1029
1285
  // Register captured variable locals: boxed = i32 cell pointer, otherwise f64 value
1030
1286
  for (let i = 0; i < cb.captures.length; i++) {
@@ -1308,6 +1564,11 @@ export default function compile(ast, profiler) {
1308
1564
  }
1309
1565
  }
1310
1566
 
1567
+ // Whole-program constant fold of module-scope aggregate literals — `var x=[1,2,3];
1568
+ // y=x[0]` → `y=1`, dropping the array (no data segment, no __arr_idx_known) when
1569
+ // every reference is a static read. The scalar analog of the constInts fold above.
1570
+ timePhase(profiler, 'foldAggregates', () => foldStaticConstAggregates(ast))
1571
+
1311
1572
  const programFacts = timePhase(profiler, 'plan', () => plan(ast, profiler))
1312
1573
 
1313
1574
  // Inspect sink: editor hosts opt in via { inspect: true } to read inferred shapes.
@@ -1449,12 +1710,28 @@ export default function compile(ast, profiler) {
1449
1710
 
1450
1711
  timePhase(profiler, 'optimizeModule', () => optimizeModule(sec, profiler))
1451
1712
 
1713
+ // Fold constant `__start` global inits into immutable inline decls (drops the
1714
+ // store, and `__start` with it when that empties it). Runs HERE — after
1715
+ // stripStaticDataPrefix and optimizeModule — so any data-segment offset a hoisted
1716
+ // pointer carries is already in its final, shifted form (hoisting earlier would
1717
+ // freeze a pre-strip offset the shift pass never revisits in the global decl).
1718
+ hoistConstGlobalInits(sec)
1719
+
1452
1720
  // Populate globals (after __start — const folding may update declarations).
1453
1721
  // 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]]))
1722
+ // The wasm type comes from globalTypes (the canonical name→type map declGlobal
1723
+ // maintains alongside the entry), falling back to the entry's own `.type`. They
1724
+ // are normally identical, but a global whose entry object is later rebuilt (e.g.
1725
+ // hoistConstGlobalInits' `{...g, …}` spread) must not depend on that rebuild
1726
+ // preserving `.type` — globalTypes is the stable source, so an entry that lost
1727
+ // its `.type` still emits a well-typed `(global …)` rather than `(undefined.const)`.
1728
+ sec.globals.push(...[...ctx.scope.globals].filter(([, g]) => g).map(([n, g]) => {
1729
+ const ty = ctx.scope.globalTypes.get(n) ?? g.type
1730
+ return ['global', `$${n}`,
1731
+ ...(g.export ? [['export', `"${g.export}"`]] : []),
1732
+ g.mut ? ['mut', ty] : ty,
1733
+ [`${ty}.const`, g.init]]
1734
+ }))
1458
1735
 
1459
1736
  // Data segments (after emit — string literals append to ctx.runtime.data / strPool during emit)
1460
1737
  // Active segment at address 0 — skipped for shared memory (would collide across modules)
@@ -1564,7 +1841,8 @@ export default function compile(ast, profiler) {
1564
1841
  const { callCount } = treeshake(
1565
1842
  [{ arr: sec.stdlib }, { arr: sec.funcs }, { arr: sec.start }],
1566
1843
  [...sec.start, ...sec.elem, ...sec.customs, ...sec.extStdlib, ...sec.imports, ...sec.tags],
1567
- { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals }
1844
+ { removeDead: !optCfg || optCfg.treeshake !== false, globals: sec.globals, userGlobals: ctx.scope.userGlobals,
1845
+ userFuncs: new Set(ctx.func.list.map(f => `$${f.name}`)) }
1568
1846
  )
1569
1847
 
1570
1848
  pruneUnusedThrowRuntime(sec)
@@ -46,7 +46,7 @@
46
46
  import { ctx } from '../ctx.js'
47
47
  import { collectParamNames, ASSIGN_OPS } from '../ast.js'
48
48
  import { analyzeValTypes, analyzeIntCertain } from './analyze.js'
49
- import { staticObjectProps } from '../static.js'
49
+ import { staticObjectProps, staticArrayElems } from '../static.js'
50
50
  import { typedElemCtor } from '../type.js'
51
51
  import { ctorFromElemAux } from '../../layout.js'
52
52
  import { shapeOfObjectLiteralAst, valTypeOf } from '../kind.js'
@@ -341,6 +341,41 @@ export function recordGlobalRep(name, expr) {
341
341
  }
342
342
  const ctor = typedElemCtor(expr)
343
343
  if (ctor) (ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
344
+ // Module-level const array literal with a uniform element val-type (e.g. a numeric
345
+ // table `const FREQS = [261.63, …]`): record it so `FREQS[i]` reads in any using
346
+ // function are typed (NUMBER) rather than untyped. Without this an untyped element
347
+ // read makes `s += FREQS[i]` take the polymorphic +/ToString path — dragging the
348
+ // entire string runtime (~5 kB) into a kernel that uses no strings. A function-local
349
+ // array gets this from analyzeValTypes; a module-level one is invisible to the using
350
+ // function's body walk, so capture it here. Soundness for a later `FREQS[i]=…` is the
351
+ // read-site dynWriteVars guard in valTypeOf (kind.js) — this is just the literal fact.
352
+ if (vt === VAL.ARRAY) {
353
+ const elems = staticArrayElems(expr)
354
+ if (elems && elems.length && elems.every(e => e != null)) {
355
+ let common = valTypeOf(elems[0])
356
+ for (let k = 1; k < elems.length && common != null; k++)
357
+ if (valTypeOf(elems[k]) !== common) common = null
358
+ if (common != null) updateGlobalRep(name, { arrayElemValType: common })
359
+ // Array-of-arrays numeric table (`const C = [[0,4,7], …]`): also record the
360
+ // nested element kind so `C[i][j]` (and `ch = C[i]; ch[j]`) reads stay typed —
361
+ // the same string-runtime drop as the flat case, one level down. Single-level
362
+ // (mirrors analyzeValTypes' local arrElemElemValTypes); deeper nesting falls back.
363
+ if (common === VAL.ARRAY) {
364
+ let nested = null, seen = false, ok = true
365
+ for (const el of elems) {
366
+ const inner = staticArrayElems(el)
367
+ if (!inner || !inner.length || !inner.every(e => e != null)) { ok = false; break }
368
+ for (const ie of inner) {
369
+ const ivt = valTypeOf(ie)
370
+ if (!seen) { nested = ivt; seen = true }
371
+ else if (ivt !== nested) { ok = false; break }
372
+ }
373
+ if (!ok) break
374
+ }
375
+ if (ok && nested != null) updateGlobalRep(name, { arrayElemElemValType: nested })
376
+ }
377
+ }
378
+ }
344
379
  // Static-shape capture for module-level object literals — lets `{ ...G.path }`
345
380
  // resolve its source schema by walking the global rep's shape tree at the
346
381
  // spread site (see shape walk in analyze.js / resolveSchema in object.js).