jz 0.8.0 → 0.8.1

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.
package/src/abi/string.js CHANGED
@@ -373,15 +373,20 @@ export const sso = {
373
373
  * dispatcher's string/array runtime guess (emit.js) would hijack it into a
374
374
  * bogus array concat. A non-builtin name routes through dynamic property
375
375
  * dispatch (load the closure slot, call it) correctly. */
376
- cat: (aF64, bF64, ctx) => {
377
- ctx.core.includes.add('__str_concat')
378
- return ['call', '$__str_concat', ssoI64(aF64), ssoI64(bF64)]
376
+ // `ext` (default false) opts into the bump-EXTEND fast path — sound only when emit
377
+ // proves `a` is dead-after (a self-accumulation `x = x + …`). Otherwise the _fresh twin
378
+ // alloc+copies, never mutating the live `a` operand. (See __str_concat in module/string.js.)
379
+ cat: (aF64, bF64, ctx, ext = false) => {
380
+ const fn = ext ? '__str_concat' : '__str_concat_fresh'
381
+ ctx.core.includes.add(fn)
382
+ return ['call', '$' + fn, ssoI64(aF64), ssoI64(bF64)]
379
383
  },
380
384
 
381
385
  /** Concat assuming both sides are already strings (skip ToString). */
382
- concatRaw: (aF64, bF64, ctx) => {
383
- ctx.core.includes.add('__str_concat_raw')
384
- return ['call', '$__str_concat_raw', ssoI64(aF64), ssoI64(bF64)]
386
+ concatRaw: (aF64, bF64, ctx, ext = false) => {
387
+ const fn = ext ? '__str_concat_raw' : '__str_concat_raw_fresh'
388
+ ctx.core.includes.add(fn)
389
+ return ['call', '$' + fn, ssoI64(aF64), ssoI64(bF64)]
385
390
  },
386
391
  },
387
392
  }
@@ -1553,6 +1553,41 @@ const isCheapPureVal = (n) => {
1553
1553
  return false
1554
1554
  }
1555
1555
 
1556
+ // Side-effect-free: no writes (assignment / ++ / --), no calls, no closures, no throw. UNLIKE
1557
+ // `isCheapPureVal` this ALLOWS loads, member reads, and `/` `%` — a side-effect-free expr may read
1558
+ // memory or trap. It is the right gate for an `if` CONDITION promoted to a `select` condition: the
1559
+ // condition is evaluated exactly once whether the lowering branches or selects (any trap fires the
1560
+ // same in both, the read order vs the pure value arm is immaterial), so it need only avoid MUTATING
1561
+ // state the value arm could read — i.e. be side-effect-free, not unconditionally-evaluable.
1562
+ const SIDE_EFFECT_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '>>=', '<<=',
1563
+ '>>>=', '||=', '&&=', '??=', '++', '--', '()', '=>', 'throw', 'new', 'await', 'yield'])
1564
+ const isSideEffectFree = (n) => {
1565
+ if (!Array.isArray(n)) return true
1566
+ if (typeof n[0] === 'string' && SIDE_EFFECT_OPS.has(n[0])) return false
1567
+ for (let i = 1; i < n.length; i++) if (!isSideEffectFree(n[i])) return false
1568
+ return true
1569
+ }
1570
+
1571
+ const isLit1 = (n) => Array.isArray(n) && n[0] == null && n[1] === 1
1572
+ // A void statement whose whole effect is `x = <cheap pure value>` for a simple local `x` — the
1573
+ // shape if→select can lower to `x = cond ? value : x`. Recognizes the plain assignment plus the
1574
+ // increment forms `++x`/`--x` and their postfix lowerings `(++x) - 1` / `(--x) + 1` (prepare turns
1575
+ // `x++` in statement position into the latter; the discarded ∓1 is dead in void context, so the
1576
+ // net effect is the increment). Returns `{ lhs, val }` or null.
1577
+ function matchVoidLocalStore(s) {
1578
+ if (!Array.isArray(s)) return null
1579
+ if (s[0] === '=' && typeof s[1] === 'string' && isCheapPureVal(s[2])) return { lhs: s[1], val: s[2] }
1580
+ if ((s[0] === '++' || s[0] === '--') && typeof s[1] === 'string')
1581
+ return { lhs: s[1], val: [s[0] === '++' ? '+' : '-', s[1], [, 1]] }
1582
+ // postfix: `x++` → `(++x) - 1`, `x--` → `(--x) + 1`
1583
+ if ((s[0] === '-' || s[0] === '+') && isLit1(s[2]) && Array.isArray(s[1])
1584
+ && (s[1][0] === '++' || s[1][0] === '--') && typeof s[1][1] === 'string') {
1585
+ const inc = s[1][0] === '++'
1586
+ if ((inc && s[0] === '-') || (!inc && s[0] === '+')) return { lhs: s[1][1], val: [inc ? '+' : '-', s[1][1], [, 1]] }
1587
+ }
1588
+ return null
1589
+ }
1590
+
1556
1591
  function emitLooseEq(a, b, negate) {
1557
1592
  const eqOp = negate ? 'ne' : 'eq'
1558
1593
  const sentinel = emitNum(negate ? 1 : 0)
@@ -2713,7 +2748,15 @@ export const emitter = {
2713
2748
  inc('__to_num')
2714
2749
  return writeVar(name, typed(['call', '$__to_num', asI64(emit(name))], 'f64'), void_)
2715
2750
  }
2716
- return writeVar(name, emit(val), void_)
2751
+ // Self-accumulation `x = x + …` (incl. desugared `x += …`): the new value REPLACES x, so x's
2752
+ // old buffer is dead — the one context where a string concat may bump-EXTEND it in place. The
2753
+ // `+` handler reads this flag for its immediate concat; nested operands clear it (not the target).
2754
+ const selfAccum = Array.isArray(val) && val[0] === '+' && val[1] === name
2755
+ const prevSA = ctx.func._selfAccumConcat
2756
+ ctx.func._selfAccumConcat = selfAccum ? name : null
2757
+ const ev = emit(val)
2758
+ ctx.func._selfAccumConcat = prevSA
2759
+ return writeVar(name, ev, void_)
2717
2760
  },
2718
2761
 
2719
2762
  // Compound assignments: read-modify-write with type coercion
@@ -2803,15 +2846,18 @@ export const emitter = {
2803
2846
  // Postfix in void: (++i)-1 / (--i)+1 → just ++i / --i
2804
2847
  '+': (a, b) => {
2805
2848
  if (ctx.func._expect === 'void' && isPostfix(a, '--', b)) return emit(a, 'void')
2849
+ // A self-accumulation `a = a + …` lets the concat bump-EXTEND `a` in place (a is dead-after).
2850
+ // Read it for THIS concat, then clear so nested operands (not the accumulation target) stay fresh.
2851
+ const selfAccum = typeof a === 'string' && a === ctx.func._selfAccumConcat
2852
+ ctx.func._selfAccumConcat = null
2806
2853
  // String concatenation: pure string operands skip generic ToString coercion.
2807
2854
  const vtA = valTypeOf(a)
2808
2855
  const vtB = valTypeOf(b)
2809
2856
  if (vtA === VAL.STRING && vtB === VAL.STRING) {
2810
- // Fused append-byte: `buf += s[i]` skips 1-char SSO construction +
2811
- // generic concat dispatch when rhs is a string-index. The byte flows
2812
- // straight from __char_at into memory, and the bump-extend path elides
2813
- // the alloc+copy when lhs is the heap-top STRING.
2814
- if (Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2857
+ // Fused append-byte: `buf += s[i]` skips 1-char SSO construction + generic concat dispatch
2858
+ // when rhs is a string-index. The byte flows straight from __char_at into memory and bump-
2859
+ // EXTENDS the heap-top lhs so only when proven self-accumulating (else it mutates a live s).
2860
+ if (selfAccum && Array.isArray(b) && b[0] === '[]' && ctx.core.stdlib['__str_append_byte'] && ctx.core.stdlib['__char_at']) {
2815
2861
  if (valTypeOf(b[1]) === VAL.STRING) {
2816
2862
  inc('__str_append_byte', '__char_at')
2817
2863
  return typed(['call', '$__str_append_byte',
@@ -2820,7 +2866,7 @@ export const emitter = {
2820
2866
  ], 'f64')
2821
2867
  }
2822
2868
  }
2823
- return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx), 'f64')
2869
+ return typed(ctx.abi.string.ops.concatRaw(asF64(emit(a)), asF64(emit(b)), ctx, selfAccum), 'f64')
2824
2870
  }
2825
2871
  if (vtA === VAL.STRING || vtB === VAL.STRING) {
2826
2872
  // An OBJECT operand coerces via ToPrimitive(string) at compile time —
@@ -2840,10 +2886,10 @@ export const emitter = {
2840
2886
  const coercionFree = (vt) => vt === VAL.STRING || vt === VAL.OBJECT || vt === VAL.BOOL
2841
2887
  const cfA = coercionFree(vtA), cfB = coercionFree(vtB)
2842
2888
  const strI64 = (n) => typed(['f64.reinterpret_i64', toStrI64(n, emit(n))], 'f64')
2843
- if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2844
- if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx), 'f64')
2845
- if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx), 'f64')
2846
- return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx), 'f64')
2889
+ if (cfA && cfB) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2890
+ if (cfA) return typed(ctx.abi.string.ops.concatRaw(strOperand(vtA, a), strI64(b), ctx, selfAccum), 'f64')
2891
+ if (cfB) return typed(ctx.abi.string.ops.concatRaw(strI64(a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2892
+ return typed(ctx.abi.string.ops.cat(strOperand(vtA, a), strOperand(vtB, b), ctx, selfAccum), 'f64')
2847
2893
  }
2848
2894
  if (vtA === VAL.BIGINT || vtB === VAL.BIGINT)
2849
2895
  return fromI64(['i64.add', asI64(emit(a)), asI64(emit(b))])
@@ -2853,6 +2899,12 @@ export const emitter = {
2853
2899
  // operand needs the runtime check.
2854
2900
  if ((vtA == null || vtB == null) && ctx.core.stdlib['__str_concat']) {
2855
2901
  const tA = temp('add'), tB = temp('add')
2902
+ // Fully-untyped `+`: the string arm is a runtime-guarded cold path that the engine reaches
2903
+ // only if BOTH operands are strings at runtime, so it keeps the bump-extend `__str_concat`
2904
+ // (its body stays out-of-line — folding it to the smaller _fresh twin would inline this
2905
+ // never-numeric branch into every hot integer loop). The demonstrated `t = s + "lit"` mutation
2906
+ // is a TYPED concat (handled by concatRaw above); a both-untyped self-mutation stays the
2907
+ // documented rare-aliasing tradeoff. Self-accumulation is still safe to extend.
2856
2908
  inc('__str_concat', '__is_str_key')
2857
2909
  const checkA = vtA == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tA}`, asF64(emit(a))]]] : null
2858
2910
  const checkB = vtB == null ? ['call', '$__is_str_key', ['i64.reinterpret_f64', ['local.tee', `$${tB}`, asF64(emit(b))]]] : null
@@ -3297,13 +3349,17 @@ export const emitter = {
3297
3349
  // If-conversion (speed tier): `if (cond) x = <cheap pure value>` (no else) → `x = cond ? value
3298
3350
  // : x`, which lowers to a branchless `select`. Removes the data-dependent branch (and its
3299
3351
  // misprediction) from min/max/clamp reductions — e.g. levenshtein's `if (ins < m) m = ins`,
3300
- // ~27% faster. Gated to the same speed tier as boolConvertToSelect (the select latency/size
3301
- // trade). Restricted to a plain assignment of a memory-/trap-free expr to a simple local, so
3302
- // the unconditional false-case eval is free and identical in effect.
3303
- if (els == null && ctx.transform.optimize?.boolConvertToSelect && isCheapPureVal(cond)) {
3352
+ // ~27% faster and from heapsort's child pick `if (a[c] < a[c+1]) c++`, the canonical
3353
+ // unpredictable compare that costs jz on x86 (Cranelift/V8-x64 keep the branch; Binaryen, which
3354
+ // AS uses, selects it). The condition is evaluated exactly once whether we branch or select, so
3355
+ // it need only be SIDE-EFFECT-FREE (loads allowed sort's `a[c] < a[c+1]`); only the assigned
3356
+ // VALUE is evaluated unconditionally, hence must be a cheap, trap-free pure expr. `x++`/`x--`
3357
+ // are admitted as `x = x ± 1`. The already-emitted condition `ce` is reused (`__emitted`), so a
3358
+ // load-bearing condition is not emitted twice.
3359
+ if (els == null && ctx.transform.optimize?.boolConvertToSelect && isSideEffectFree(cond)) {
3304
3360
  const asg = Array.isArray(then) && then[0] === ';' && then.length === 2 ? then[1] : then
3305
- if (Array.isArray(asg) && asg[0] === '=' && typeof asg[1] === 'string' && isCheapPureVal(asg[2]))
3306
- return emitVoid(['=', asg[1], ['?:', cond, asg[2], asg[1]]]) // cond cheap-pure → re-emit is free
3361
+ const sel = matchVoidLocalStore(asg)
3362
+ if (sel) return emitVoid(['=', sel.lhs, ['?:', ['__emitted', ce], sel.val, sel.lhs]])
3307
3363
  }
3308
3364
  const c = ce.type === 'i32' ? ce : toBoolFromEmitted(ce)
3309
3365
  // Flow-sensitive type refinement: narrow types within each branch based on the guard.
@@ -3566,6 +3622,10 @@ export function emit(node, expect) {
3566
3622
  if (node.loc != null) ctx.error.loc = node.loc
3567
3623
  }
3568
3624
  if (node == null) return null
3625
+ // Pre-emitted IR passthrough: `['__emitted', ir]` returns `ir` untouched. Lets a caller that
3626
+ // already emitted a subtree (e.g. the `if` handler's condition) splice it into an AST-shaped
3627
+ // re-emit (a `?:` for if→select conversion) without emitting it a second time.
3628
+ if (Array.isArray(node) && node[0] === '__emitted') return node[1]
3569
3629
  // Boolean literals carry VAL.BOOL for type observation (valTypeOf reads the
3570
3630
  // AST), but their working representation is the plain number 0/1 — identical
3571
3631
  // codegen to the pre-carrier `[, 1]`/`[, 0]` folding, so no perf is paid.
@@ -40,6 +40,7 @@ import { inferLocals } from './infer.js'
40
40
  import { optimizeFunc, treeshake } from '../optimize/index.js'
41
41
  import { strengthReduceLoopDivMod } from './loop-divmod.js'
42
42
  import { narrowBoundedSquare } from './loop-square.js'
43
+ import { unrollRecurrence } from './loop-recurrence.js'
43
44
  import { peelClampedStencil } from './peel-stencil.js'
44
45
  import { cseLoads } from './cse-load.js'
45
46
 
@@ -426,6 +427,11 @@ function analyzeFuncForEmit(func, programFacts) {
426
427
  // so the sieve's product/counter chain carries i32 instead of f64. Before analyze so the
427
428
  // Math.imul typed/narrows like any i32. Off at L0 / `loopSquare:false`.
428
429
  if (_o && _o.loopSquare !== false && isBlockBody(func.body)) func.body = narrowBoundedSquare(func.body)
430
+ // Array-recurrence unroll: a unit-stride DP/scan that reads arr[j-1] and writes arr[j] carries
431
+ // its value through memory (store→load) and re-pays loop overhead per cell — both of which V8
432
+ // hides but Cranelift/baseline don't. Scalar-replace the recurrence + unroll ×2 (clang's fix).
433
+ // Off at L0 / `unrollRecurrence:false`.
434
+ if (_o && _o.unrollRecurrence !== false && isBlockBody(func.body)) func.body = unrollRecurrence(func.body)
429
435
  // Edge-clamp peeling: split a clamped stencil loop into clamp-free interior + edges
430
436
  // (the interior then lifts to SIMD). Before analyze so the new loops are analyzed.
431
437
  if (_o && _o.clampPeel !== false && isBlockBody(func.body)) func.body = peelClampedStencil(func.body)
@@ -130,11 +130,18 @@ export const inferParams = (body, candidates) => {
130
130
  // regardless of prior evidence — a later method call can't re-induce a shape
131
131
  // already contradicted by an earlier scalar assignment.
132
132
 
133
+ // Methods that exist ONLY on String.prototype — seeing one on a bare binding proves
134
+ // it is a string. `indexOf`/`includes`/`lastIndexOf`/`concat`/`slice`/`at` are NOT here:
135
+ // Array.prototype has them too, so the receiver is genuinely ambiguous (and the argument
136
+ // can't disambiguate — String coerces it, Arrays hold strings). Those keep the runtime
137
+ // __ptr_type fork, which is correct for both; forcing `lastIndexOf` to string here used to
138
+ // miscompile `arr.lastIndexOf(x)` to -1. A string-using param still narrows via any of the
139
+ // real discriminators below (charCodeAt, a string assignment, a string-passing call site).
133
140
  const STRING_ONLY_METHODS = new Set([
134
141
  'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith',
135
142
  'toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'normalize', 'localeCompare',
136
143
  'padStart', 'padEnd', 'repeat', 'trimStart', 'trimEnd', 'trim',
137
- 'matchAll', 'match', 'replace', 'replaceAll', 'split', 'lastIndexOf',
144
+ 'matchAll', 'match', 'replace', 'replaceAll', 'split',
138
145
  ])
139
146
  const ARRAY_ONLY_POISON = new Set([
140
147
  'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'fill', 'reverse',
@@ -0,0 +1,167 @@
1
+ // Partial unroll (×2) + scalar replacement of a unit-stride ARRAY RECURRENCE.
2
+ //
3
+ // A DP/scan loop that reads `arr[j-1]` and writes `arr[j]` (unit stride) carries the
4
+ // just-written value to the next iteration THROUGH MEMORY: it stores `arr[j]`, then the next
5
+ // iteration loads `arr[j-1]` — the very cell it just wrote. V8/TurboFan forwards that store→load
6
+ // and unrolls the loop internally; Cranelift/wasmtime and the baseline tiers do neither, so the
7
+ // loop pays a store→load round trip plus full per-iteration overhead on every cell. clang/gcc
8
+ // fix exactly this with this transform — measured 2.15× on wasmtime for the Levenshtein DP
9
+ // (V8-neutral, bit-exact).
10
+ //
11
+ // Recognized (post-prepare AST): a unit-stride `for (let j = LO; j </<= HI; j++)` whose body, for
12
+ // ONE array `arr`, has a single store `arr[j] = <var>` and ≥1 read `arr[j-1]`, accesses `arr` at
13
+ // no other index, never aliases `arr` elsewhere, and contains no call / nested loop / break /
14
+ // continue / return / closure. The `arr[j-1]` read becomes a scalar `left` seeded from `arr[LO-1]`
15
+ // and refreshed after each store; the body is then unrolled ×2 (with a 1-cell tail) so the carry
16
+ // between the paired cells lives in a register and the loop overhead is halved. A `LO <= HI` guard
17
+ // keeps the seed load in step with the original (which reads `arr[LO-1]` only when it iterates),
18
+ // and falls back to the untouched loop on the empty range — sound for any trip count.
19
+
20
+ import { findMutations } from './analyze-scans.js'
21
+ import { litVal, litN, unitIncVar, normalizeLoop, freshLoopId } from './loop-model.js'
22
+ import { rewriteBlocks, closureMutatedVars } from './loop-model.js'
23
+
24
+ const isArr = (n) => Array.isArray(n) // wrap (not alias): the self-host kernel rejects a builtin used as a first-class value
25
+ const clone = (n) => isArr(n) ? n.map(clone) : n
26
+ const isIvMinus1 = (n, iv) => isArr(n) && n[0] === '-' && n[1] === iv && litN(n[2], 1) // (iv - 1)
27
+
28
+ // Ops whose presence makes duplicating the body in place unsound (control that escapes the cell,
29
+ // or a call that could alias/mutate `arr` or reorder side effects).
30
+ const REJECT = new Set(['for', 'while', 'do', 'for-in', 'for-of', 'break', 'continue', 'return',
31
+ 'throw', 'switch', 'try', 'catch', 'finally', '=>', 'label'])
32
+ const hasUnsafe = (n) => {
33
+ if (!isArr(n)) return false
34
+ if (REJECT.has(n[0])) return true
35
+ if (n[0] === '()' && typeof n[1] === 'string') return true // function call `f(args)`
36
+ return n.some(hasUnsafe)
37
+ }
38
+
39
+ // Substitute every value-reference of `iv` with (iv + 1); leave the op slot and property keys.
40
+ const subPlus1 = (n, iv) => {
41
+ if (n === iv) return ['+', iv, 1]
42
+ if (!isArr(n)) return n
43
+ if (n[0] === '.' && n.length === 3) return ['.', subPlus1(n[1], iv), n[2]]
44
+ return [n[0], ...n.slice(1).map(c => subPlus1(c, iv))]
45
+ }
46
+
47
+ // Rename every let/const-DECLARED var in `stmts` with a suffix, throughout — so the 2nd unrolled
48
+ // cell's locals don't collide with the 1st. Loop-carried outer vars (assigned, not declared here)
49
+ // are untouched, so the recurrence still threads through them.
50
+ function renameDecls(stmts, suf) {
51
+ const declared = new Set()
52
+ const collect = (n) => {
53
+ if (!isArr(n)) return
54
+ if (n[0] === 'let' || n[0] === 'const')
55
+ for (let k = 1; k < n.length; k++) if (isArr(n[k]) && n[k][0] === '=' && typeof n[k][1] === 'string') declared.add(n[k][1])
56
+ n.forEach(collect)
57
+ }
58
+ stmts.forEach(collect)
59
+ if (!declared.size) return stmts
60
+ const ren = (n) => {
61
+ if (typeof n === 'string') return declared.has(n) ? n + suf : n
62
+ if (!isArr(n)) return n
63
+ if (n[0] === '.' && n.length === 3) return ['.', ren(n[1]), n[2]]
64
+ return [n[0], ...n.slice(1).map(ren)]
65
+ }
66
+ return stmts.map(ren)
67
+ }
68
+
69
+ // Replace `arr[iv-1]` reads with `left`; keep the store; emit `left = storeVal` after each store.
70
+ function scalarReplace(stmts, arr, iv, left, storeVal) {
71
+ const repl = (n) => {
72
+ if (!isArr(n)) return n
73
+ if (n[0] === '[]' && n[1] === arr && isIvMinus1(n[2], iv)) return left
74
+ if (n[0] === '.' && n.length === 3) return ['.', repl(n[1]), n[2]]
75
+ return [n[0], ...n.slice(1).map(repl)]
76
+ }
77
+ const out = []
78
+ for (const s of stmts) {
79
+ out.push(repl(s))
80
+ if (isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][1] === arr && s[1][2] === iv)
81
+ out.push(['=', left, storeVal])
82
+ }
83
+ return out
84
+ }
85
+
86
+ function tryUnroll(stmt, cm) {
87
+ const L = normalizeLoop(stmt)
88
+ if (!L || L.kind !== 'for') return null
89
+ const body = L.body
90
+ if (!isArr(body) || body[0] !== ';') return null
91
+ const iv = unitIncVar(L.step)
92
+ if (!iv) return null
93
+
94
+ // init `let iv = LO`, LO a literal ≥ 1 (so arr[LO-1] is a valid in-bounds index)
95
+ if (!(isArr(L.init) && L.init[0] === 'let' && isArr(L.init[1]) && L.init[1][0] === '=' && L.init[1][1] === iv)) return null
96
+ const LO = L.init[1][2], loVal = litVal(LO)
97
+ if (loVal == null || loVal < 1) return null
98
+
99
+ // cond `iv <= HI` / `iv < HI`, HI loop-invariant
100
+ if (!(isArr(L.cond) && (L.cond[0] === '<=' || L.cond[0] === '<') && L.cond[1] === iv)) return null
101
+ const cmpOp = L.cond[0], HI = L.cond[2]
102
+ if (!(typeof HI === 'string' || litVal(HI) != null)) return null
103
+
104
+ if (hasUnsafe(body)) return null
105
+ const stmts = body.slice(1)
106
+
107
+ // exactly one store `arr[iv] = <var>` — the recurrence array + carried value
108
+ let arr = null, storeVal = null, nStore = 0
109
+ for (const s of stmts)
110
+ if (isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][2] === iv) {
111
+ if (typeof s[2] !== 'string') return null
112
+ arr = s[1][1]; storeVal = s[2]; nStore++
113
+ }
114
+ if (nStore !== 1 || typeof arr !== 'string') return null
115
+ if (storeVal === iv || storeVal === arr) return null
116
+
117
+ // every `arr[...]` is `arr[iv]` or `arr[iv-1]`, the only write is the store, ≥1 recurrence read,
118
+ // and `arr` never appears bare (passed/aliased)
119
+ let hasRec = false, bad = false
120
+ const scan = (n) => {
121
+ if (!isArr(n)) return
122
+ if (n[0] === '[]' && n[1] === arr) {
123
+ if (n[2] === iv) {} else if (isIvMinus1(n[2], iv)) hasRec = true; else bad = true
124
+ }
125
+ if (n[0] === '=' && isArr(n[1]) && n[1][0] === '[]' && n[1][1] === arr && n[1][2] !== iv) bad = true
126
+ if (!(n[0] === '[]' || n[0] === '.')) for (let k = 1; k < n.length; k++) if (n[k] === arr) bad = true
127
+ n.forEach(scan)
128
+ }
129
+ scan(body)
130
+ if (bad || !hasRec) return null
131
+
132
+ // The carry `left = storeVal` is emitted right after the store, so a recurrence read AFTER the
133
+ // store would see this cell's value, not arr[iv-1]. Require every arr[iv-1] read to precede it.
134
+ const storeIdx = stmts.findIndex(s => isArr(s) && s[0] === '=' && isArr(s[1]) && s[1][0] === '[]' && s[1][1] === arr && s[1][2] === iv)
135
+ const readsRec = (n) => isArr(n) && ((n[0] === '[]' && n[1] === arr && isIvMinus1(n[2], iv)) || n.some(readsRec))
136
+ for (let k = storeIdx + 1; k < stmts.length; k++) if (readsRec(stmts[k])) return null
137
+
138
+ // iv assigned only by the step; iv/arr/HI loop-invariant (not mutated, incl. via a closure call)
139
+ const ivMut = new Set(); findMutations(body, new Set([iv]), ivMut)
140
+ if (ivMut.has(iv)) return null
141
+ if (cm.has(iv) || cm.has(arr)) return null
142
+ if (typeof HI === 'string') { const hiMut = new Set(); findMutations(body, new Set([HI]), hiMut); if (hiMut.has(HI) || cm.has(HI)) return null }
143
+
144
+ // --- transform ---
145
+ const id = freshLoopId()
146
+ const left = `__rec${id}`
147
+ const bodyS = scalarReplace(stmts, arr, iv, left, storeVal)
148
+ const cellJ = () => bodyS.map(clone)
149
+ const cellJ1 = renameDecls(bodyS.map(s => subPlus1(clone(s), iv)), `$r${id}`)
150
+
151
+ const seed = ['let', ['=', left, ['[]', arr, loVal - 1]]] // left = arr[LO-1]
152
+ const letIv = ['let', ['=', iv, clone(LO)]] // let iv = LO
153
+ const twoFit = cmpOp === '<=' ? ['<', iv, clone(HI)] : ['<', iv, ['-', clone(HI), 1]]
154
+ const main = ['while', twoFit,
155
+ [';', ['{}', [';', ...cellJ()]], ['{}', [';', ...cellJ1]], ['=', iv, ['+', iv, 2]]]]
156
+ const tail = ['if', [cmpOp, iv, clone(HI)],
157
+ ['{}', [';', ...cellJ(), ['=', iv, ['+', iv, 1]]]]]
158
+ const block = ['{}', [';', letIv, seed, main, tail]]
159
+ // Run the unrolled form only on a non-empty range (so the seed's arr[LO-1] load matches the
160
+ // original, which reads it only when it iterates); otherwise the untouched loop.
161
+ return [['if', [cmpOp, clone(LO), clone(HI)], block, stmt]]
162
+ }
163
+
164
+ export function unrollRecurrence(body) {
165
+ const cm = closureMutatedVars(body)
166
+ return rewriteBlocks(body, stmt => tryUnroll(stmt, cm))
167
+ }
@@ -62,12 +62,17 @@ function filterLiveCallSites(callSites, valueUsed) {
62
62
 
63
63
  function buildCallerCtx() {
64
64
  const callerCtx = new Map()
65
- callerCtx.set(null, { callerLocals: ctx.scope.globalTypes, callerValTypes: ctx.scope.globalValTypes })
65
+ const globalTE = ctx.scope.globalTypedElem || new Map()
66
+ callerCtx.set(null, { callerLocals: ctx.scope.globalTypes, callerValTypes: ctx.scope.globalValTypes, callerTypedElems: globalTE })
66
67
  for (const func of ctx.func.list) {
67
68
  if (!func.body || func.raw) continue
68
69
  const facts = analyzeBody(func.body)
69
70
  for (const p of func.sig.params) if (!facts.locals.has(p.name)) facts.locals.set(p.name, p.type)
70
- callerCtx.set(func, { callerLocals: facts.locals, callerValTypes: facts.valTypes })
71
+ // Shadow-aware local+global typed-array map: a `const buf = new Int32Array(…)`
72
+ // local makes `buf[i]` arg reads type i32 at this caller's sites, so a callee
73
+ // param fed only such elements narrows (else it stays f64 and `1 << p` drags in
74
+ // __to_num → the whole string↔number stdlib). Mirrors callerTypedElemsFor.
75
+ callerCtx.set(func, { callerLocals: facts.locals, callerValTypes: facts.valTypes, callerTypedElems: callerTypedElemsFor(func, globalTE) })
71
76
  }
72
77
  return callerCtx
73
78
  }
@@ -725,6 +730,7 @@ export default function narrowSignatures(programFacts, ast) {
725
730
  callee, callerFunc, argList, func, restIdx,
726
731
  callerLocals: ctxEntry.callerLocals,
727
732
  callerValTypes: ctxEntry.callerValTypes,
733
+ callerTypedElems: ctxEntry.callerTypedElems,
728
734
  callerParamFacts(key) {
729
735
  if (!paramFacts.has(key)) paramFacts.set(key, paramFactsOf(paramReps, callerFunc, key))
730
736
  return paramFacts.get(key)
@@ -847,7 +853,10 @@ export default function narrowSignatures(programFacts, ast) {
847
853
  if (state.callee === state.callerFunc?.name &&
848
854
  isRecurIntExpr(arg, new Set(state.func.sig.params.map(p => p.name)), state.callerLocals)) return 'i32'
849
855
  if (!state._teOverlay) {
850
- const m = new Map(ctx.scope.globalTypedElem || [])
856
+ // Caller's typed arrays (locals + non-shadowed globals, precomputed shadow-aware
857
+ // in buildCallerCtx) so a LOCAL `const buf = new Int32Array(…)` makes `buf[i]`
858
+ // type i32 here — not just module globals / typedCtor-narrowed params.
859
+ const m = new Map(state.callerTypedElems || ctx.scope.globalTypedElem || [])
851
860
  const pf = state.callerParamFacts('typedCtor')
852
861
  if (pf) for (const [name, ctor] of pf) if (ctor != null) m.set(name, ctor)
853
862
  state._teOverlay = m