jz 0.8.0 → 0.9.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 (73) hide show
  1. package/README.md +56 -9
  2. package/bench/README.md +121 -50
  3. package/bench/bench.svg +27 -27
  4. package/cli.js +3 -1
  5. package/dist/interop.js +1 -1
  6. package/dist/jz.js +7541 -6178
  7. package/index.js +165 -74
  8. package/interop.js +189 -17
  9. package/jzify/arguments.js +32 -1
  10. package/jzify/async.js +409 -0
  11. package/jzify/classes.js +136 -18
  12. package/jzify/generators.js +639 -0
  13. package/jzify/hoist-vars.js +73 -1
  14. package/jzify/index.js +123 -4
  15. package/jzify/names.js +1 -0
  16. package/jzify/transform.js +239 -1
  17. package/layout.js +48 -3
  18. package/module/array.js +318 -43
  19. package/module/atomics.js +144 -0
  20. package/module/collection.js +1429 -155
  21. package/module/core.js +431 -40
  22. package/module/date.js +142 -120
  23. package/module/fs.js +144 -0
  24. package/module/function.js +6 -3
  25. package/module/index.js +4 -1
  26. package/module/json.js +270 -49
  27. package/module/math.js +1032 -145
  28. package/module/number.js +532 -163
  29. package/module/object.js +353 -95
  30. package/module/regex.js +157 -7
  31. package/module/schema.js +100 -2
  32. package/module/string.js +428 -93
  33. package/module/typedarray.js +264 -72
  34. package/module/web.js +36 -0
  35. package/package.json +5 -5
  36. package/src/abi/string.js +82 -11
  37. package/src/ast.js +11 -5
  38. package/src/autoload.js +28 -1
  39. package/src/bridge.js +7 -2
  40. package/src/compile/analyze-scans.js +22 -7
  41. package/src/compile/analyze.js +136 -30
  42. package/src/compile/dyn-closure-tables.js +277 -0
  43. package/src/compile/emit-assign.js +281 -15
  44. package/src/compile/emit.js +1055 -70
  45. package/src/compile/index.js +277 -29
  46. package/src/compile/infer.js +42 -4
  47. package/src/compile/inplace-store.js +329 -0
  48. package/src/compile/loop-recurrence.js +167 -0
  49. package/src/compile/narrow.js +555 -18
  50. package/src/compile/peel-stencil.js +5 -0
  51. package/src/compile/plan/index.js +45 -3
  52. package/src/compile/plan/inline.js +8 -1
  53. package/src/compile/plan/literals.js +39 -0
  54. package/src/compile/plan/scope.js +430 -23
  55. package/src/compile/program-facts.js +732 -38
  56. package/src/ctx.js +64 -9
  57. package/src/helper-counters.js +7 -1
  58. package/src/ir.js +113 -5
  59. package/src/kind-traits.js +66 -3
  60. package/src/kind.js +84 -12
  61. package/src/op-policy.js +7 -4
  62. package/src/optimize/index.js +1179 -704
  63. package/src/optimize/recurse.js +2 -2
  64. package/src/optimize/vectorize.js +982 -67
  65. package/src/prepare/index.js +809 -67
  66. package/src/prepare/math-kernel.js +331 -0
  67. package/src/prepare/pre-eval.js +714 -0
  68. package/src/snapshot.js +194 -0
  69. package/src/type.js +1170 -56
  70. package/src/wat/assemble.js +403 -65
  71. package/src/wat/codegen.js +74 -14
  72. package/transform.js +113 -4
  73. package/wasi.js +3 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * In-place replace-store eligibility sweep — the immutable-update idiom
3
+ * (`arr[i] = { x, y, … }` in a loop) allocates a fresh object per element per
4
+ * step. When no alias can observe the difference, overwriting the OLD
5
+ * element's payload slots is bit-identical and allocation-free.
6
+ *
7
+ * Identity change is the only observable: after `arr[i] = fresh`, reading
8
+ * `arr[i].f` yields the new value either way; only a SAVED alias to the old
9
+ * element — field-read after the store, identity-compared, or stored
10
+ * elsewhere — distinguishes fresh-object from in-place. So a store site is
11
+ * eligible iff the whole program provably creates no such alias for that
12
+ * schema:
13
+ *
14
+ * 1. Schema-S values may appear in a VALUE position (call arg, return,
15
+ * container/slot store, comparison, bare use) ONLY as fresh `{}` literals
16
+ * — an OBJECT-typed NAME in value position aliases its object → poison S
17
+ * (unknown sid → poison all). `.field` receivers are safe (atomic read).
18
+ * 2. Element reads of maybe-object arrays may only be (a) an immediate
19
+ * `.field` receiver, or (b) the whole init RHS of a single-decl binding
20
+ * (a tracked ALIAS). Anything else → poison the array's elem sid.
21
+ * 3. `for…of` over a maybe-object array binds untracked element aliases →
22
+ * poison its elem sid.
23
+ * 4. A candidate store `arr[i] = {lit}` (elem sid of arr == sid of lit, all
24
+ * lit slot values NUMBER — no ephemeral pointers stored into a possibly
25
+ * durable receiver) is valid iff its sid survives 1-3, every tracked
26
+ * alias of that sid lives in the SAME function, same statement block,
27
+ * declared before the store, and never mentioned in a later statement of
28
+ * that block (block-scoped per iteration, so next-iteration uses re-read).
29
+ * Mentions inside the store statement itself are fine — the emit spills
30
+ * the literal's values before overwriting.
31
+ *
32
+ * Result: `ctx.schema.inplaceStores = WeakSet<literalNode>` — consulted by
33
+ * emitElementAssign, which emits the guarded in-place fast path (old box has
34
+ * OBJECT tag + sid S → slot stores; anything else → the generic fresh-alloc
35
+ * path, so runtime aliens stay bit-exact).
36
+ */
37
+ import { ctx } from '../ctx.js'
38
+ import { analyzeBody } from './analyze.js'
39
+ import { staticObjectProps } from '../static.js'
40
+ import { VAL } from '../reps.js'
41
+
42
+ /** Canonical content key for a store site — the ','-wrapper around literal
43
+ * props is normalized away between plan and emit, so flatten before
44
+ * serializing. Shared by the sweep and emitElementAssign. */
45
+ export const inplaceKey = (arrName, lit) => {
46
+ const props = lit.slice(1)
47
+ const flat = props.length === 1 && Array.isArray(props[0]) && props[0][0] === ',' ? props[0].slice(1) : props
48
+ return `${arrName}|${JSON.stringify(flat)}`
49
+ }
50
+
51
+ // env-gated debug — dist/jz.js runs in browsers where `process` doesn't exist
52
+ const DBG = typeof process !== 'undefined' && process.env.JZ_DBG_INPLACE
53
+
54
+ const CONTAINER_METHODS = new Set(['push', 'unshift', 'splice', 'fill', 'set', 'add'])
55
+
56
+ export function scanInplaceStores(programFacts) {
57
+ const poisoned = new Set() // sids with a possible surviving alias
58
+ let poisonAll = false
59
+ const aliases = [] // { fn, sid, name, block, declIdx }
60
+ const candidates = [] // { fn, sid, lit, block, stmtIdx, arrName }
61
+
62
+ const paramReps = programFacts.paramReps
63
+
64
+ // Element facts for params narrow.js leaves untracked (exported functions):
65
+ // meet over INTERNAL call sites. Zero internal call sites → only the host
66
+ // can call, and host-marshaled containers are fresh deep copies (interop
67
+ // wrapVal) that cannot alias any compile-internal object → non-aliasing.
68
+ // Any unprovable internal arg → maybe-object with unknown sid.
69
+ const _csCache = new Map()
70
+ const callSiteElemInfo = (fnName, k) => {
71
+ const key = fnName + ' ' + k
72
+ if (_csCache.has(key)) return _csCache.get(key)
73
+ let mayBeObject = false, sid = null, seen = 0
74
+ for (const cs of programFacts.callSites || []) {
75
+ if (cs.callee !== fnName) continue
76
+ seen++
77
+ const arg = cs.argList?.[k]
78
+ const cf = cs.callerFunc
79
+ const cfacts = cf?.body && !cf.raw ? analyzeBody(cf.body) : null
80
+ let proven = false
81
+ if (typeof arg === 'string' && cfacts) {
82
+ if (cfacts.typedElems?.has(arg) || cfacts.locals?.get(arg) === 'typed') proven = true
83
+ else {
84
+ const ev = cfacts.arrElemValTypes?.get(arg)
85
+ if (ev != null && ev !== VAL.OBJECT) proven = true
86
+ const s = cfacts.arrElemSchemas?.get(arg)
87
+ if (s != null) { mayBeObject = true; sid = sid == null || sid === s ? s : -1; proven = true }
88
+ }
89
+ }
90
+ if (!proven) { mayBeObject = true; sid = -1 }
91
+ }
92
+ const r = { sid: sid === -1 ? null : sid, mayBeObject: seen === 0 ? false : mayBeObject }
93
+ _csCache.set(key, r)
94
+ return r
95
+ }
96
+
97
+ for (const fn of ctx.func.list) {
98
+ if (!fn.body || fn.raw) continue
99
+ const facts = analyzeBody(fn.body)
100
+ const reps = paramReps?.get(fn.name)
101
+ const paramIdx = new Map(fn.sig.params.map((p, k) => [p.name, k]))
102
+
103
+ // Element info for an array-valued name: elem sid (if known) and whether
104
+ // elements could be schema objects at all (typed/NUMBER/STRING elems can't).
105
+ const elemInfo = (name) => {
106
+ if (typeof name !== 'string') return { sid: null, mayBeObject: true }
107
+ if (facts.typedElems?.has(name) || facts.locals?.get(name) === 'typed') return { sid: null, mayBeObject: false }
108
+ let sid = facts.arrElemSchemas?.get(name)
109
+ let ev = facts.arrElemValTypes?.get(name)
110
+ const k = paramIdx.get(name)
111
+ if (k != null) {
112
+ const r = reps?.get(k)
113
+ if (r) {
114
+ if (r.typedElem != null || r.val === VAL.TYPED) return { sid: null, mayBeObject: false }
115
+ if (sid == null) sid = r.arrayElemSchema
116
+ if (ev == null) ev = r.arrayElemValType
117
+ }
118
+ if (sid == null && (ev == null || ev === VAL.OBJECT)) {
119
+ // untracked param (exported fn) — derive from internal call sites
120
+ const cs = callSiteElemInfo(fn.name, k)
121
+ if (!cs.mayBeObject) return { sid: null, mayBeObject: false }
122
+ if (sid == null) sid = cs.sid
123
+ }
124
+ }
125
+ if (sid == null && ev != null && ev !== VAL.OBJECT) return { sid: null, mayBeObject: false }
126
+ return { sid: sid ?? null, mayBeObject: true }
127
+ }
128
+ const poisonElem = (name) => {
129
+ const { sid, mayBeObject } = elemInfo(name)
130
+ if (!mayBeObject) return
131
+ if (sid != null) poisoned.add(sid)
132
+ else { poisonAll = true; if (DBG) console.error('[inplace-poisonAll] elem read of', name, 'in', fn.name) }
133
+ }
134
+ // A NAME in value position: aliases its object if OBJECT-typed. An S value
135
+ // can only reach an unknown-typed name through a path poisoned elsewhere,
136
+ // so unknown types are ignored (conservatism lives at the leak sites).
137
+ const poisonName = (name) => {
138
+ const vt = facts.valTypes?.get(name) ?? (paramIdx.has(name) ? reps?.get(paramIdx.get(name))?.val : null)
139
+ if (vt !== VAL.OBJECT) return
140
+ const sid = ctx.schema.vars?.get(name) ?? (paramIdx.has(name) ? reps?.get(paramIdx.get(name))?.schemaId : null)
141
+ if (sid != null) poisoned.add(sid)
142
+ else { poisonAll = true; if (DBG) console.error('[inplace-poisonAll] OBJECT name', name, 'in', fn.name) }
143
+ }
144
+
145
+ const litSid = (lit) => {
146
+ const parsed = staticObjectProps(lit.slice(1))
147
+ return parsed ? ctx.schema.register(parsed.names) : null
148
+ }
149
+
150
+ // value-position walk; `stmts`/`stmtIdx` track the innermost statement
151
+ // block so alias decls and stores get comparable positions
152
+ const walkVal = (n, stmts, stmtIdx) => {
153
+ if (!Array.isArray(n)) { if (typeof n === 'string') poisonName(n); return }
154
+ const op = n[0]
155
+ if (typeof op !== 'string') return
156
+ if (op === 'str') return
157
+ if (op === '{}') { for (let i = 1; i < n.length; i++) walkSlotValues(n[i], stmts, stmtIdx); return }
158
+ if (op === '.' || op === '?.') {
159
+ // receiver is a safe atomic read — an element-read receiver is fine,
160
+ // a NAME receiver doesn't alias
161
+ const r = n[1]
162
+ if (Array.isArray(r) && r[0] === '[]') { if (typeof r[1] === 'string') { /* arr[i].f — safe */ } else walkVal(r[1], stmts, stmtIdx); if (r[2] != null) walkVal(r[2], stmts, stmtIdx) }
163
+ else if (Array.isArray(r)) walkVal(r, stmts, stmtIdx)
164
+ return
165
+ }
166
+ if (op === '[]' && n.length === 3) {
167
+ // element read in value position — a leak unless provably non-object
168
+ if (typeof n[1] === 'string') poisonElem(n[1])
169
+ else walkVal(n[1], stmts, stmtIdx)
170
+ if (n[2] != null) walkVal(n[2], stmts, stmtIdx)
171
+ return
172
+ }
173
+ for (let i = 1; i < n.length; i++) walkVal(n[i], stmts, stmtIdx)
174
+ }
175
+ // `{}` literal slot values: `[':' key value]` pairs (or `,`-joined) —
176
+ // nested fresh literals are fine, everything else is a value position
177
+ const walkSlotValues = (n, stmts, stmtIdx) => {
178
+ if (!Array.isArray(n)) { if (typeof n === 'string') poisonName(n); return }
179
+ if (n[0] === ',') { for (let i = 1; i < n.length; i++) walkSlotValues(n[i], stmts, stmtIdx); return }
180
+ if (n[0] === ':') { walkVal(n[2], stmts, stmtIdx); return }
181
+ walkVal(n, stmts, stmtIdx)
182
+ }
183
+
184
+ const walkStmt = (n, stmts, stmtIdx) => {
185
+ if (!Array.isArray(n)) { if (typeof n === 'string') poisonName(n); return }
186
+ const op = n[0]
187
+ // statement-position '{}' is a BLOCK (post-prepare bodies are
188
+ // ['{}', [';', ...stmts]]), not an object literal
189
+ if (op === ';' || op === '{' || op === '{}') {
190
+ const list = n.slice(1)
191
+ for (let i = 0; i < list.length; i++) walkStmt(list[i], list, i)
192
+ return
193
+ }
194
+ if (op === 'let' || op === 'const') {
195
+ for (let i = 1; i < n.length; i++) {
196
+ const d = n[i]
197
+ if (!Array.isArray(d) || d[0] !== '=') { walkVal(d, stmts, stmtIdx); continue }
198
+ const [, lhs, rhs] = d
199
+ if (typeof lhs === 'string' && Array.isArray(rhs) && rhs[0] === '[]' && rhs.length === 3 && typeof rhs[1] === 'string') {
200
+ // tracked alias: whole-RHS element read into a single binding
201
+ const { sid, mayBeObject } = elemInfo(rhs[1])
202
+ if (mayBeObject) {
203
+ if (sid != null) aliases.push({ fn, sid, name: lhs, block: stmts, declIdx: stmtIdx, arr: rhs[1], idx: typeof rhs[2] === 'string' ? rhs[2] : null })
204
+ else { poisonAll = true; if (DBG) console.error('[inplace-poisonAll] alias unknown-sid', lhs, 'of', rhs[1], 'in', fn.name) }
205
+ }
206
+ if (rhs[2] != null) walkVal(rhs[2], stmts, stmtIdx)
207
+ continue
208
+ }
209
+ walkVal(rhs, stmts, stmtIdx)
210
+ }
211
+ return
212
+ }
213
+ if (op === '=' && Array.isArray(n[1]) && n[1][0] === '[]' && n[1].length === 3 && typeof n[1][1] === 'string') {
214
+ const [, lhs, rhs] = n
215
+ if (lhs[2] != null) walkVal(lhs[2], stmts, stmtIdx)
216
+ if (Array.isArray(rhs) && rhs[0] === '{}') {
217
+ const { sid, mayBeObject } = elemInfo(lhs[1])
218
+ const s = mayBeObject ? litSid(rhs) : null
219
+ if (s != null && s === sid) candidates.push({ fn, sid: s, lit: rhs, block: stmts, stmtIdx, arrName: lhs[1], idxName: typeof lhs[2] === 'string' ? lhs[2] : null })
220
+ // literal is a fresh value — walk only its slot values
221
+ for (let i = 1; i < rhs.length; i++) walkSlotValues(rhs[i], stmts, stmtIdx)
222
+ } else walkVal(rhs, stmts, stmtIdx)
223
+ return
224
+ }
225
+ if (op === 'for-of') {
226
+ // `for (const p of arr)` binds untracked element aliases
227
+ const src = n[2]
228
+ if (typeof src === 'string') poisonElem(src)
229
+ else walkVal(src, stmts, stmtIdx)
230
+ for (let i = 3; i < n.length; i++) walkStmt(n[i], stmts, stmtIdx)
231
+ return
232
+ }
233
+ if (op === '=>') {
234
+ // closure body: value-walk everything (captured aliases poison via names)
235
+ for (let i = 1; i < n.length; i++) walkVal(n[i], stmts, stmtIdx)
236
+ return
237
+ }
238
+ if (op === 'for' || op === 'while' || op === 'do' || op === 'if' || op === 'for-in') {
239
+ // for is flat post-prepare: ['for', init, cond, step, body]
240
+ for (let i = 1; i < n.length; i++) walkStmt(n[i], stmts, stmtIdx)
241
+ return
242
+ }
243
+ // container-method calls: literal args are fresh single-home values
244
+ if (op === '()' && Array.isArray(n[1]) && (n[1][0] === '.') && CONTAINER_METHODS.has(n[1][2])) {
245
+ if (typeof n[1][1] !== 'string') walkVal(n[1][1], stmts, stmtIdx)
246
+ const argNode = n[2]
247
+ const args = argNode == null ? [] : (Array.isArray(argNode) && argNode[0] === ',') ? argNode.slice(1) : [argNode]
248
+ for (const a of args) {
249
+ if (Array.isArray(a) && a[0] === '{}') { for (let i = 1; i < a.length; i++) walkSlotValues(a[i], stmts, stmtIdx) }
250
+ else walkVal(a, stmts, stmtIdx)
251
+ }
252
+ return
253
+ }
254
+ walkVal(n, stmts, stmtIdx)
255
+ }
256
+
257
+ walkStmt(fn.body, null, 0)
258
+ }
259
+
260
+ const containsName = (n, name) => {
261
+ if (n === name) return true
262
+ if (!Array.isArray(n)) return false
263
+ for (let i = (n[0] === 'str' ? n.length : 1); i < n.length; i++) if (containsName(n[i], name)) return true
264
+ return false
265
+ }
266
+
267
+ // Content-keyed result: per-function body transforms and emit-time inlining
268
+ // between plan and emit rebuild the tree (and splice bodies across function
269
+ // frames), so neither node identity nor the enclosing function name survives
270
+ // to emitElementAssign. A `receiver|literal` content key is sound only if
271
+ // EVERY same-content candidate program-wide validates — group and meet.
272
+ // (An inliner that RENAMES the receiver makes the key miss → no transform —
273
+ // safe in the conservative direction.)
274
+ // Target-binding reuse: when THE tracked alias of a store site reads the
275
+ // SAME array at the SAME plain-name index, and every statement between its
276
+ // decl and the store is a pure local decl (no calls, no writes to the array
277
+ // or the index — nothing that could rebind arr[i]), the emit can overwrite
278
+ // through the alias binding directly instead of re-reading the element
279
+ // (`__arr_idx` + forwarding + bounds, per iteration). Field writes to the
280
+ // alias itself between are fine — the store overwrites every slot anyway.
281
+ const impureBetween = (n, idxName, arrName) => {
282
+ if (!Array.isArray(n)) return false
283
+ const op = n[0]
284
+ if (op === '()' || op === 'new' || op === 'await' || op === 'yield') return true
285
+ if (op === '=' || op === '++' || op === '--' || (typeof op === 'string' && op.length > 1 && op.endsWith('=') && !op.endsWith('==') && op !== '>=' && op !== '<=')) {
286
+ const t = n[1]
287
+ if (t === idxName || t === arrName) return true
288
+ if (Array.isArray(t) && t[0] === '[]' && t[1] === arrName) return true
289
+ }
290
+ for (let i = op === 'str' ? n.length : 1; i < n.length; i++) if (impureBetween(n[i], idxName, arrName)) return true
291
+ return false
292
+ }
293
+
294
+ const verdict = new Map() // key → false | { alias, idx } | { alias: null }
295
+ for (const c of candidates) {
296
+ const key = inplaceKey(c.arrName, c.lit)
297
+ let ok = !poisonAll && !poisoned.has(c.sid)
298
+ for (const a of aliases) {
299
+ if (!ok) break
300
+ if (a.sid !== c.sid) continue
301
+ // an alias in another function (or another block) could live across the
302
+ // call/iteration into this store — reject
303
+ if (a.fn !== c.fn || a.block !== c.block || a.declIdx >= c.stmtIdx) { ok = false; break }
304
+ // any mention after the store in the same block observes the overwrite
305
+ for (let i = c.stmtIdx + 1; i < c.block.length && ok; i++)
306
+ if (containsName(c.block[i], a.name)) ok = false
307
+ }
308
+ let reuse = null
309
+ if (ok && c.idxName) for (const a of aliases) {
310
+ if (a.fn !== c.fn || a.block !== c.block || a.sid !== c.sid) continue
311
+ if (a.declIdx >= c.stmtIdx || a.arr !== c.arrName || a.idx !== c.idxName) continue
312
+ let pure = true
313
+ for (let i = a.declIdx + 1; i < c.stmtIdx && pure; i++)
314
+ pure = !impureBetween(c.block[i], c.idxName, c.arrName)
315
+ if (pure) { reuse = { alias: a.name, idx: a.idx }; break }
316
+ }
317
+ // meet across same-content sites: validity ANDs; reuse must agree exactly
318
+ const prev = verdict.get(key)
319
+ if (!ok || prev === false) verdict.set(key, false)
320
+ else if (prev === undefined) verdict.set(key, reuse ?? { alias: null })
321
+ else if (prev.alias !== (reuse?.alias ?? null) || (prev.alias && prev.idx !== reuse.idx)) verdict.set(key, { alias: null })
322
+ if (ok && DBG) console.error('[inplace-ok]', c.fn.name, c.arrName, 'sid', c.sid, reuse ? 'reuse ' + reuse.alias : '')
323
+ }
324
+ const out = new Map()
325
+ for (const [key, v] of verdict) if (v) out.set(key, v)
326
+ ctx.schema.inplaceStores = out
327
+ if (DBG) console.error('[inplace]', 'candidates:', candidates.length, 'aliases:', aliases.length, 'poisoned:', [...poisoned], 'poisonAll:', poisonAll, 'eligible:', candidates.filter(c => out.has(c.lit)).length)
328
+ return out
329
+ }
@@ -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
+ }