@asmlift/core 0.4.0 → 0.6.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 (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -1,12 +1,36 @@
1
+ // THE OTHER COALESCER is `structure/namecoalesce.ts`, and the dividing line is worth stating: it
2
+ // merges the SOURCE AND DESTINATION OF A COPY, which is copy coalescing; this one merges two
3
+ // UNRELATED locals whose spans are disjoint, which is register reuse. Neither subsumes the other —
4
+ // a copy pair overlaps by construction, so `overlap` below rejects every candidate that one takes.
5
+ //
6
+ // TWO admission paths live here, each with its own gate table and its own reading of loops. The
7
+ // SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
8
+ // loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
9
+ // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one `if` picks
10
+ // between them, so it asks only whether ANY loop encloses that `if` — a second entry breaks the
11
+ // argument however the arms' own loops relate. `coalesceCandidates` offers both.
1
12
  import { typeToString } from '../ir/types';
2
13
  import type { Expr, SFn, Stmt } from './ast';
3
14
  import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
15
+ import { type Gate, firstRejection } from './gates';
16
+
17
+ /** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
18
+ * walk and the arm path's `visit`. */
19
+ const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
4
20
 
5
21
  function namesIn(e: Expr, out: Set<string>): void {
6
- // `addr` names a GLOBAL, never a localcollected anyway. A name reaching BOTH forms would
7
- // otherwise get a span that ignores its `addr` mentions, and a SHORT span is a clobber while a
8
- // long one is only a missed merge. `structure.ts` keeps locals to /^[vt]\d+$/ and excludes global
9
- // names, so this cannot fire today; collecting is the direction that stays safe if that changes.
22
+ // `addr` names a GLOBAL (`&gSym`) or a LOCALthe structurer renders an `laddr` frame object
23
+ // as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
24
+ // name mentioned only through `&` still has a live range: a span that ignored its `addr`
25
+ // mentions would be SHORT, and a short span is a clobber where a long one is only a missed
26
+ // merge. Collecting a global name costs nothing — it is not in `sfn.locals`, so no pair is
27
+ // ever built for it.
28
+ //
29
+ // `rename` DISAGREES WITH THIS, and knowingly: it rewrites only `var` leaves, so a local
30
+ // absorbed while mentioned through `&` leaves that mention standing against a declaration the
31
+ // merge deleted. Reconciling the two changes which candidates compile — a measured change, not a
32
+ // cleanup — so today's behaviour is pinned exactly in coalesce.test.ts ('rename') rather than
33
+ // repaired here.
10
34
  if (e.k === 'var' || e.k === 'addr') out.add(e.name);
11
35
  for (const c of exprChildren(e)) namesIn(c, out);
12
36
  }
@@ -17,42 +41,119 @@ function mentions(e: Expr, n: string): boolean {
17
41
  namesIn(e, seen);
18
42
  return seen.has(n);
19
43
  }
20
- interface Span {
44
+ export interface Span {
21
45
  first: number;
22
46
  last: number;
23
- inLoop: boolean;
47
+ /** every loop that RE-RUNS a mention of the local — the whole ancestor chain, not just the
48
+ * innermost, because an outer loop re-runs an inner one's statements too. A `for`'s init is
49
+ * not re-run by its own loop, so it contributes only the enclosing ones. */
50
+ loops: Set<Stmt>;
24
51
  constFed: boolean;
25
52
  /** the local's FIRST mention is a write, not a read */
26
53
  firstIsWrite: boolean;
27
54
  }
55
+ /** The INDUCTION VARIABLE a `for` drives: the local its init writes and whose step computes the
56
+ * next value from the CURRENT one, arithmetically. Those two writes are the variable's own
57
+ * definition and its own history — not a feed from somewhere the compiler had a reason to
58
+ * respect, which is what `const-fed` reads every non-const assign as.
59
+ *
60
+ * Both halves of the step test are load-bearing, and neither is what the pipeline's `for`
61
+ * producers check — `recognizeForLoops` (structure/structure.ts) admits ANY self-referencing step,
62
+ * and l3/reindex.ts's two walk rewrites mint `iv = iv + 1`, so this predicate is implied by all
63
+ * three rather than trusting any of them; a `for` reaching here may be a walk, not a count. `a = *p` (no self-read) and `p = p->next` / `a = tab[a]` (a self-read
64
+ * that is still a MEMORY read every iteration) are both locals `const-fed` exists to refuse; only
65
+ * arithmetic over the variable is its own history. The read must be a `var`: `mentions` counts an
66
+ * `addr` too, and `&a` is not a read of `a`. */
67
+ const readsVarArithmetically = (e: Expr, n: string): boolean => {
68
+ if (e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || e.k === 'addr') {
69
+ return false;
70
+ }
71
+ return (e.k === 'var' && e.name === n) || exprChildren(e).some((c) => readsVarArithmetically(c, n));
72
+ };
73
+ const stepIsArithmetic = (e: Expr, n: string): boolean => readsVarArithmetically(e, n) && !hasMemoryRead(e);
74
+ const hasMemoryRead = (e: Expr): boolean =>
75
+ e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || exprChildren(e).some(hasMemoryRead);
76
+ const forInductionVar = (s: Extract<Stmt, { k: 'for' }>): string | null =>
77
+ s.init.k === 'assign' &&
78
+ s.inc.k === 'assign' &&
79
+ s.init.name === s.inc.name &&
80
+ stepIsArithmetic(s.inc.value, s.inc.name)
81
+ ? s.init.name
82
+ : null;
83
+
84
+ /** Is this local's declaration carrying a `volatile` qualifier of either kind — the object itself,
85
+ * or its pointee? THE one spelling of the question both gate tables' `volatile` rule asks, kept
86
+ * shared while the two rules stay separate objects: `typeToString` spells neither qualifier, so a
87
+ * path that asked only about one would let a qualified local absorb into a plain one. */
88
+ const isVolatileLocal = (l: SFn['locals'][number]): boolean => l.volatile === true || l.pointeeVolatile === true;
89
+
28
90
  function spans(body: Stmt[]): Map<string, Span> {
29
91
  const out = new Map<string, Span>();
30
92
  let at = 0;
31
- const walk = (list: Stmt[], inLoop: boolean): void => {
93
+ /** the statement's OWN mentions — an assign target, a condition, a scrutinee — at its own
94
+ * position, inside the loops it runs under */
95
+ const record = (s: Stmt, loops: readonly Stmt[], inductionVar: string | null = null): void => {
96
+ at++;
97
+ const here = new Set<string>();
98
+ if (s.k === 'assign') here.add(s.name);
99
+ for (const e of stmtExprs(s)) namesIn(e, here);
100
+ for (const n of here) {
101
+ const sp = out.get(n) ?? {
102
+ first: at,
103
+ last: at,
104
+ loops: new Set<Stmt>(),
105
+ constFed: true,
106
+ // an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
107
+ // let `g` receive the absorbed value
108
+ firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
109
+ };
110
+ sp.last = at;
111
+ for (const l of loops) {
112
+ sp.loops.add(l);
113
+ }
114
+ if (s.k === 'assign' && s.name === n && s.value.k !== 'const' && n !== inductionVar) sp.constFed = false;
115
+ out.set(n, sp);
116
+ }
117
+ };
118
+ /** the loop set a while/do-while's own condition and children run under — its own. A `for` is
119
+ * routed separately: `stmtChildren` hands back its init too, and the init is the one child its
120
+ * loop does not re-run. */
121
+ const under = (s: Stmt, loops: readonly Stmt[]): readonly Stmt[] => (isLoop(s) ? [...loops, s] : loops);
122
+ const walk = (list: Stmt[], loops: readonly Stmt[]): void => {
32
123
  for (const s of list) {
33
- at++;
34
- const here = new Set<string>();
35
- if (s.k === 'assign') here.add(s.name);
36
- for (const e of stmtExprs(s)) namesIn(e, here);
37
- for (const n of here) {
38
- const sp = out.get(n) ?? {
39
- first: at,
40
- last: at,
41
- inLoop,
42
- constFed: true,
43
- // an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
44
- // let `g` receive the absorbed value
45
- firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
46
- };
47
- sp.last = at;
48
- sp.inLoop ||= inLoop;
49
- if (s.k === 'assign' && s.name === n && s.value.k !== 'const') sp.constFed = false;
50
- out.set(n, sp);
124
+ // A `for`'s INIT runs ONCE, ahead of the condition and outside the loop; its cond, inc and
125
+ // body run per iteration. Walking the init first is what makes `for (i = *p; )` read as a
126
+ // local whose first mention is a WRITE, which is what it is.
127
+ if (s.k === 'for') {
128
+ const inductionVar = forInductionVar(s);
129
+ // The INIT runs once, so the `for` does not re-run it — but only an ASSIGN there is a
130
+ // shape this case places directly. Anything else (a loop, an `if`) goes through the generic
131
+ // walk, which places it and its children by its own kind; `forInductionVar` requires an
132
+ // assign init, so `counter` is null on that path anyway. The INC mirrors it, where the
133
+ // routing is uniformity rather than coverage: an inc's statements sit inside the `for`,
134
+ // which is already in every span the pair could share.
135
+ if (s.init.k === 'assign') {
136
+ record(s.init, loops, inductionVar);
137
+ } else {
138
+ walk([s.init], loops);
139
+ }
140
+ const inner = [...loops, s];
141
+ record(s, inner);
142
+ walk(s.body, inner);
143
+ if (s.inc.k === 'assign') {
144
+ record(s.inc, inner, inductionVar);
145
+ } else {
146
+ walk([s.inc], inner);
147
+ }
148
+ continue;
51
149
  }
52
- walk(stmtChildren(s), inLoop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for');
150
+ // a while/do-while CONDITION re-runs with the body, so it counts as inside its own loop
151
+ const inner = under(s, loops);
152
+ record(s, inner);
153
+ walk(stmtChildren(s), inner);
53
154
  }
54
155
  };
55
- walk(body, false);
156
+ walk(body, []);
56
157
  return out;
57
158
  }
58
159
  function rename(body: Stmt[], from: string, to: string): Stmt[] {
@@ -78,69 +179,423 @@ function rename(body: Stmt[], from: string, to: string): Stmt[] {
78
179
  };
79
180
  return body.map(inStmt);
80
181
  }
182
+ /** One candidate merge under consideration: absorb `a` into `b`. */
183
+ export interface MergePair {
184
+ a: string;
185
+ b: string;
186
+ /** `a`'s span */
187
+ x: Span;
188
+ /** `b`'s span — the SURVIVOR's, which is why the asymmetric gates read `y` */
189
+ y: Span;
190
+ sameType: boolean;
191
+ eitherIsParam: boolean;
192
+ /** either local is object-volatile or carries a pointee-volatile qualifier — typeToString
193
+ * spells neither, so `sameType` alone lets a qualified local absorb into a plain one */
194
+ eitherIsVolatile: boolean;
195
+ /** some loop holds a mention of BOTH locals */
196
+ sharesLoop: boolean;
197
+ }
198
+
199
+ /** The admission rules, in evaluation order. Two arguments the `why` fields have no room for:
200
+ *
201
+ * WHAT `shared-loop` BUYS is the right to read preorder statement order as liveness. Preorder is a
202
+ * topological order of the CFG except where a back edge runs a later-indexed statement before an
203
+ * earlier one — and a back edge returns only to the head of its OWN loop, whose body is one
204
+ * contiguous preorder range. So the reordering can reach a PAIR only when some loop holds a
205
+ * mention of both locals: there the survivor's write can be followed, on the next iteration, by
206
+ * the absorbed local's read. Where no loop holds both — two sibling loops, or one local living
207
+ * before the loop the other lives in — no back edge connects the two ranges and preorder IS
208
+ * execution order. The ancestor chain is what the span records, not the innermost loop: an outer
209
+ * loop re-runs an inner one's statements, so it can reorder a pair that no inner loop shares.
210
+ *
211
+ * `first-is-write` AND `const-fed` ARE INDEPENDENT, and neither masks the other: a survivor whose
212
+ * every feed is a constant can still be first MENTIONED by a read, and ablating `first-is-write`
213
+ * alone then offers the merge (coalesce.test.ts pins exactly that program). What `const-fed`
214
+ * bounds is candidate GROWTH: merges go as `L(L-1)/2` in the local count, each a distinct compile.
215
+ * On a loop-heavy function it is what bounds them, because `shared-loop` refuses only pairs a back
216
+ * edge can reorder: klonoa's LoadBGTilemapData declares 43 locals — 1806 ordered pairs — and
217
+ * `const-fed` is what keeps three of them (ablate it and the span path offers 273). A rule
218
+ * refusing every in-loop local would make that bound redundant; this one does not, so any further
219
+ * relaxation of `const-fed` is a multiplier, and two call sites pay it (`/coalesce` and
220
+ * `/scopebase-coalesce`; the `/livebase` pairings enumerate the ARM path and pay
221
+ * ARM_DISJOINT_GATES' `arm-init` instead). */
222
+ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
223
+ {
224
+ id: 'param',
225
+ why: 'a param is the function’s own signature, not a recovered local',
226
+ sound: false,
227
+ rejects: (c) => c.eitherIsParam,
228
+ },
229
+ {
230
+ id: 'type',
231
+ why: 'the survivor keeps its own declared type, so the two must agree',
232
+ sound: false,
233
+ rejects: (c) => !c.sameType,
234
+ },
235
+ {
236
+ id: 'volatile',
237
+ why: 'a volatile qualifier (object or pointee) is observable and typeToString does not spell it — merging strips or adds it',
238
+ sound: true,
239
+ guardedBy: 'coalesce.test.ts: a volatile pair never merges',
240
+ rejects: (c) => c.eitherIsVolatile,
241
+ },
242
+ {
243
+ id: 'shared-loop',
244
+ why: 'a back edge of a loop holding both locals re-runs the absorbed read after the survivor is written',
245
+ sound: true,
246
+ // The ablation sweep proves this gate is load-bearing WHILE it is in the table; it is blind to
247
+ // the two ways the rule can go wrong from here — a relaxation, and an outright deletion, which
248
+ // would simply drop the gate from the table the sweep iterates. Both land on the arm named
249
+ // below, which checks what the pass still emits.
250
+ guardedBy: 'coalesce-fuzz.test.ts: no candidate the pass emits changes a DEFINED read',
251
+ rejects: (c) => c.sharesLoop,
252
+ },
253
+ {
254
+ id: 'const-fed',
255
+ why: 'a load-fed local — other than a for induction variable, whose feeds are its own — is one the compiler had a reason to keep where it was',
256
+ sound: false,
257
+ rejects: (c) => !c.x.constFed || !c.y.constFed,
258
+ },
259
+ {
260
+ id: 'overlap',
261
+ why: 'the ranges must not overlap — the survivor would absorb a value still live',
262
+ sound: true,
263
+ guardedBy: 'coalesce.test.ts: OVERLAPPING ranges never merge',
264
+ rejects: (c) => c.x.last >= c.y.first,
265
+ },
266
+ {
267
+ id: 'first-is-write',
268
+ why: 'a survivor first MENTIONED by a read would see the absorbed value there',
269
+ sound: false,
270
+ rejects: (c) => !c.y.firstIsWrite,
271
+ },
272
+ ];
273
+
81
274
  /** Every legal single merge, each as its own tree — NOT one committed choice.
82
275
  *
83
276
  * Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
84
- * it wrong: on kleod:UpdateHUDCounterDisplay the two legal merges score 18 and 40 against a
85
- * no-merge baseline of 21, and declaration order picks the 40. `rank.ts` already has the idiom for
86
- * exactly this `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" so
87
- * every candidate is emitted and the differ referees.
88
- *
89
- * GATES:
90
- * - a local mentioned inside a loop BODY is excluded. SOUND-critical: it is what makes preorder
91
- * statement order a sufficient approximation of liveness. Preorder is a topological order of the
92
- * CFG except where a later-indexed statement can run before an earlier one, and the positions
93
- * that do that — a `for`'s `init`/`inc`, and everything in any loop body — are inside a loop, so
94
- * the gate covers them. A loop's own CONDITION is NOT covered: it is visited at the loop
95
- * statement's own index with the ENCLOSING loop flag. That is safe only because a condition
96
- * cannot WRITE, so it can extend a read range but never reorder a definition — an earlier
97
- * version of this comment claimed the gate covered conditions too, which it does not.
98
- * Differential fuzzing supports this: removing the gate produces clobbers immediately, leaving
99
- * it on produces none. No such harness is committed, so nothing here re-checks it.
100
- * - both must be CONSTANT-fed. A codegen heuristic, not soundness — removing it stayed
101
- * clobber-free under the same (uncommitted) fuzz and simply scored worse, because a load-fed
102
- * local is one the compiler had a reason to keep where it was. It is also what currently BOUNDS
103
- * candidate growth: merges are `L(L-1)/2` in the local count, each a distinct source and so a
104
- * distinct compile, and nothing else caps that. Corpus-wide today: 2 rows, 13 kept sources.
105
- * - the survivor's first mention must be an ASSIGN THAT DOES NOT ALSO READ IT. `b = g(b)` is a
106
- * write and a read in one statement; counting it as a pure write let `g` receive the absorbed
107
- * value. These two gates are NOT independent: `constFed` also rejects a self-reading assign
108
- * (its value is not a literal), so it masks this one. No committed test isolates it — this is
109
- * defence-in-depth for the day `constFed` is relaxed, which the note above makes plausible.
277
+ * it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
278
+ * and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
279
+ * and declaration order is the one that picks it. Emitting no merges at all costs that row its
280
+ * match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
281
+ * `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so every candidate is
282
+ * emitted and the differ referees.
110
283
  *
111
284
  * ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
112
- * the paths that skip it. The original read an uninitialized local there, so both spellings are
113
- * ill-defined rather than one being wrong — but this is a real difference and the differ, not this
114
- * gate, is what keeps it from faking a match. */
285
+ * the paths that skip it — a loop that runs zero iterations is one such path. The original read an uninitialized local there, so both spellings are
286
+ * ill-defined rather than one being wrong — but this is a real difference and the differ, not any
287
+ * gate, is what keeps it from faking a match. The fuzz asserts it stays reachable, so the carve-out
288
+ * that excuses it cannot quietly become dead. */
115
289
  export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
290
+ const { candidates } = coalesceUnder(COALESCE_GATES, sfn);
291
+ const seen = new Set(candidates.map((c) => c.merged));
292
+ for (const c of armDisjointCandidates(sfn)) {
293
+ if (!seen.has(c.merged)) {
294
+ candidates.push(c);
295
+ }
296
+ }
297
+ return candidates;
298
+ }
299
+
300
+ /** The survivor's declaration list after `gone` is absorbed into `kept`.
301
+ *
302
+ * The merged local's declaration is dropped, so any attribute on it would be lost — and one of
303
+ * them is load-bearing: `slots`, the `[sp,#k]`s the machine homed it at (ir/core.ts `SlotHomes`).
304
+ * The survivor takes the UNION and chooses nothing: a merged pair can reproduce at most one slot,
305
+ * but WHICH of the two is the earlier declaration rank depends on the frame's direction, and this
306
+ * function is handed a locals list with no target in it. `l3/slotorder.ts` reduces, once, where
307
+ * the direction is in hand. Neither homed ⇒ no slots: the merge invents nothing. */
308
+ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SFn['locals'] {
309
+ const goneSlots = locals.find((l) => l.name === gone)?.slots;
310
+ return locals
311
+ .filter((l) => l.name !== gone)
312
+ .map((l) => {
313
+ if (l.name !== kept || goneSlots === undefined) {
314
+ return l;
315
+ }
316
+ return { ...l, slots: [...new Set([...(l.slots ?? []), ...goneSlots])].sort((x, y) => x - y) };
317
+ });
318
+ }
319
+
320
+ /** One candidate ARM-DISJOINT merge: every mention of `a` inside one arm of a single `if`, every
321
+ * mention of `b` inside the other. There is no param gate to mirror from the span table because
322
+ * the enumeration refuses params structurally: pair members come from `sfn.locals` only, and a
323
+ * local that SHADOWS a param name is excluded too (see `confined`) — rename() must never touch
324
+ * a param's mentions. */
325
+ export interface ArmPair {
326
+ a: string;
327
+ b: string;
328
+ /** the confining `if` has a loop ancestor, so it can run more than once */
329
+ ifInLoop: boolean;
330
+ sameType: boolean;
331
+ /** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
332
+ eitherIsVolatile: boolean;
333
+ /** each local's FIRST preorder mention inside its arm is a pure const write */
334
+ bothArmConstInit: boolean;
335
+ }
336
+
337
+ /** The arm-disjoint admission — the SECOND way a pair can merge, and what it uniquely buys is the
338
+ * DIRECTION: the span path's survivor is always the later RANGE, because `overlap` orders the pair
339
+ * by position, while this path's is the earlier DECLARATION — the two disagree exactly when
340
+ * declaration order disagrees with range order. It also admits a pair the span gates refuse for a
341
+ * different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
342
+ * so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
343
+ * there. Two locals confined to
344
+ * OPPOSITE arms of one `if` never coexist at runtime: the `if` picks one arm, so no read of either
345
+ * can observe the other's write — no liveness reasoning needed. That argument is exactly what the
346
+ * `loop` gate here protects: a loop ancestor re-enters the `if`, later entries can take the other
347
+ * arm, and a value written on one visit becomes readable on the next. Note this gate wants ANY
348
+ * enclosing loop, not the span model's shared-loop rule: never-coexisting is a claim about one
349
+ * entry, so a second entry breaks it however the two arms' loops relate. */
350
+ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
351
+ {
352
+ id: 'type',
353
+ why: 'the survivor keeps its own declared type, so the two must agree',
354
+ sound: false,
355
+ rejects: (c) => !c.sameType,
356
+ },
357
+ {
358
+ id: 'volatile',
359
+ why: 'a volatile qualifier (object or pointee) is observable — merging strips or adds it',
360
+ sound: true,
361
+ guardedBy: 'coalesce.test.ts: a volatile pair never merges',
362
+ rejects: (c) => c.eitherIsVolatile,
363
+ },
364
+ {
365
+ id: 'loop',
366
+ why: 'a loop ancestor re-enters the if, so opposite arms both run and a value could cross',
367
+ sound: true,
368
+ guardedBy: 'coalesce.test.ts: an in-loop if never admits its arm pair',
369
+ rejects: (c) => c.ifInLoop,
370
+ },
371
+ {
372
+ id: 'arm-init',
373
+ why: 'a local not const-initialized at its arm’s first mention is one the compiler had a reason to keep — the growth bound const-fed gives the span table',
374
+ sound: false,
375
+ rejects: (c) => !c.bothArmConstInit,
376
+ },
377
+ ];
378
+
379
+ /** The arm-disjoint merges alone — the class the livebase pairings enumerate (rank.ts): the
380
+ * demanding row's shared counter is arm-disjoint, and the span-model merges already ride the
381
+ * plain /coalesce label, so pairing them too would multiply candidates with no row behind it. */
382
+ export function armDisjointCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
383
+ return armDisjointUnder(ARM_DISJOINT_GATES, sfn).candidates;
384
+ }
385
+
386
+ /** Every name each statement of `list` MENTIONS, and how many statements mention it — an assign
387
+ * target counts, and so does every name in the statement's own expressions. Nested statements are
388
+ * counted too, so this is the whole subtree's census. */
389
+ function countMentions(list: Stmt[]): Map<string, number> {
390
+ const out = new Map<string, number>();
391
+ const walk = (stmts: Stmt[]): void => {
392
+ for (const st of stmts) {
393
+ const here = new Set<string>();
394
+ if (st.k === 'assign') here.add(st.name);
395
+ for (const e of stmtExprs(st)) namesIn(e, here);
396
+ for (const n of here) out.set(n, (out.get(n) ?? 0) + 1);
397
+ walk(stmtChildren(st));
398
+ }
399
+ };
400
+ walk(list);
401
+ return out;
402
+ }
403
+
404
+ /** The three mention queries the arm-disjoint path asks, sharing ONE set of memos.
405
+ *
406
+ * MEMOISED ON NODE IDENTITY, so the index belongs to one tree and one call: build it per
407
+ * `armDisjointUnder` invocation and never hold it across a rewrite. Sound because nothing on this
408
+ * path mutates the tree — the only rewrite is `rename`, which rebuilds every statement it touches
409
+ * and leaves the input alone (structure-purity.test.ts pins the same promise one level up). The
410
+ * memos earn their keep because `firstMention` walks a statement's whole subtree once per
411
+ * statement it scans, and the a×b loop's two calls each depend on only ONE of a and b.
412
+ *
413
+ * `firstMentionIn` and `firstMention` are mutually recursive and stay inside for that reason —
414
+ * the recursion runs back through the memo, not around it. */
415
+ function mentionIndex(): {
416
+ mentionsOf: (list: Stmt[]) => Map<string, number>;
417
+ mentionsUnder: (st: Stmt) => Map<string, number>;
418
+ firstMention: (list: Stmt[], n: string) => 'const-write' | 'other' | null;
419
+ } {
420
+ const listMentions = new Map<Stmt[], Map<string, number>>();
421
+ const mentionsOf = (list: Stmt[]): Map<string, number> => {
422
+ let m = listMentions.get(list);
423
+ if (m === undefined) {
424
+ m = countMentions(list);
425
+ listMentions.set(list, m);
426
+ }
427
+ return m;
428
+ };
429
+ // `stmtChildren` builds a FRESH array every call, so a statement's subtree counts are keyed on
430
+ // the statement rather than on the list `mentionsOf` would see.
431
+ const childMentions = new Map<Stmt, Map<string, number>>();
432
+ const mentionsUnder = (st: Stmt): Map<string, number> => {
433
+ let m = childMentions.get(st);
434
+ if (m === undefined) {
435
+ m = countMentions(stmtChildren(st));
436
+ childMentions.set(st, m);
437
+ }
438
+ return m;
439
+ };
440
+ // The first PREORDER mention of `n` in an arm, looked for through if statements whose own
441
+ // condition does not read it (an if's cond evaluates before either arm). 'const-write' is a
442
+ // pure `n = K`; anything else mentioning n first — a read, a computed assign, a loop — refuses.
443
+ const firstMentionIn = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
444
+ for (const st of list) {
445
+ const here = new Set<string>();
446
+ if (st.k === 'assign') here.add(st.name);
447
+ for (const e of stmtExprs(st)) namesIn(e, here);
448
+ const inChildren = mentionsUnder(st).has(n);
449
+ if (!here.has(n) && !inChildren) {
450
+ continue;
451
+ }
452
+ if (st.k === 'assign' && st.name === n && st.value.k === 'const' && !mentions(st.value, n)) {
453
+ return 'const-write';
454
+ }
455
+ if (st.k === 'if' && !here.has(n)) {
456
+ const arm = mentionsOf(st.then).has(n) ? st.then : st.else;
457
+ return firstMention(arm, n);
458
+ }
459
+ return 'other';
460
+ }
461
+ return null;
462
+ };
463
+ // Per (arm, NAME): the answer depends on both, and the a×b loop asks for each `a` once per `b`
464
+ // and each `b` once per `a`.
465
+ const firstMentions = new Map<Stmt[], Map<string, 'const-write' | 'other' | null>>();
466
+ const firstMention = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
467
+ let per = firstMentions.get(list);
468
+ if (per === undefined) {
469
+ per = new Map();
470
+ firstMentions.set(list, per);
471
+ }
472
+ if (!per.has(n)) {
473
+ per.set(n, firstMentionIn(list, n));
474
+ }
475
+ return per.get(n)!;
476
+ };
477
+ return { mentionsOf, mentionsUnder, firstMention };
478
+ }
479
+
480
+ /** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
481
+ * same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
482
+ export function armDisjointUnder(
483
+ gates: readonly Gate<ArmPair>[],
484
+ sfn: SFn,
485
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
486
+ const refusals = new Map<string, number>();
116
487
  if (sfn.locals.length < 2) {
117
- return [];
488
+ return { candidates: [], refusals };
118
489
  }
490
+ const { mentionsOf, firstMention } = mentionIndex();
491
+ const total = mentionsOf(sfn.body);
119
492
  const params = new Set(sfn.params.map((p) => p.name));
493
+ const locals = new Map(sfn.locals.map((l) => [l.name, l]));
120
494
  const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
121
- const sp = spans(sfn.body);
122
495
  const out: { merged: string; sfn: SFn }[] = [];
496
+ const declIdx = new Map(sfn.locals.map((l, i) => [l.name, i]));
497
+ const isVolatile = (n: string): boolean => {
498
+ const l = locals.get(n);
499
+ return l !== undefined && isVolatileLocal(l);
500
+ };
501
+ const visit = (stmts: Stmt[], inLoop: boolean): void => {
502
+ for (const st of stmts) {
503
+ if (st.k === 'if' && st.then.length && st.else.length) {
504
+ const thenM = mentionsOf(st.then);
505
+ const elseM = mentionsOf(st.else);
506
+ // locals only, and never a name that is ALSO a param — the span path holds the same
507
+ // belief as a gate, and a local shadowing a param would let rename() rewrite the param's
508
+ // own mentions
509
+ const confined = (m: Map<string, number>): string[] =>
510
+ [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
511
+ for (const a of confined(thenM)) {
512
+ for (const b of confined(elseM)) {
513
+ // the survivor is the earlier declaration, matching how a shared source local reads.
514
+ //
515
+ // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into the
516
+ // target's frame order at EMIT time (l3/slotorder.ts), after this pass, so `declIdx`
517
+ // is the naming walk's order and the choice means "the earlier declaration in the
518
+ // source asmlift recovered". Ordering the list any earlier would silently change which
519
+ // local survives every arm-disjoint merge on a function whose frame order disagrees
520
+ // with its declaration order — exactly the population the ordering exists for.
521
+ const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
522
+ const refused = firstRejection(gates, {
523
+ a: gone,
524
+ b: kept,
525
+ ifInLoop: inLoop,
526
+ sameType: typeOf.get(a) === typeOf.get(b),
527
+ eitherIsVolatile: isVolatile(a) || isVolatile(b),
528
+ bothArmConstInit:
529
+ firstMention(st.then, a) === 'const-write' && firstMention(st.else, b) === 'const-write',
530
+ });
531
+ if (refused !== null) {
532
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
533
+ continue;
534
+ }
535
+ out.push({
536
+ merged: `${gone}-${kept}`,
537
+ sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
538
+ });
539
+ }
540
+ }
541
+ }
542
+ visit(stmtChildren(st), inLoop || isLoop(st));
543
+ }
544
+ };
545
+ visit(sfn.body, false);
546
+ return { candidates: out, refusals };
547
+ }
548
+
549
+ /** `coalesceCandidates` with the gate table supplied, plus which gate refused each pair.
550
+ *
551
+ * The parameter exists so a test can run the pass with one gate DROPPED — the ablation as a value,
552
+ * rather than as a flag compiled into the shipped path or an input rewritten to dodge a predicate.
553
+ * `refusals` is what makes a gate's reachability checkable: a rule nothing ever reaches is a rule
554
+ * no test can be failing on purpose. */
555
+ export function coalesceUnder(
556
+ gates: readonly Gate<MergePair>[],
557
+ sfn: SFn,
558
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
559
+ const refusals = new Map<string, number>();
560
+ if (sfn.locals.length < 2) {
561
+ return { candidates: [], refusals };
562
+ }
563
+ const params = new Set(sfn.params.map((p) => p.name));
564
+ const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
565
+ const volatiles = new Set(sfn.locals.filter(isVolatileLocal).map((l) => l.name));
566
+ const sp = spans(sfn.body);
567
+ const candidates: { merged: string; sfn: SFn }[] = [];
123
568
  for (const a of sfn.locals.map((l) => l.name)) {
124
569
  for (const b of sfn.locals.map((l) => l.name)) {
125
570
  const x = sp.get(a);
126
571
  const y = sp.get(b);
127
- if (a === b || !x || !y || params.has(a) || params.has(b)) {
572
+ // Not a gate: this is what makes the pair a pair at all. A name with no span is one the body
573
+ // never mentions, so there is no range to reason about.
574
+ if (a === b || !x || !y) {
128
575
  continue;
129
576
  }
130
- if (typeOf.get(a) !== typeOf.get(b) || x.inLoop || y.inLoop || !x.constFed || !y.constFed) {
131
- continue;
132
- }
133
- if (x.last >= y.first || !y.firstIsWrite) {
577
+ const refused = firstRejection(gates, {
578
+ a,
579
+ b,
580
+ x,
581
+ y,
582
+ sameType: typeOf.get(a) === typeOf.get(b),
583
+ eitherIsParam: params.has(a) || params.has(b),
584
+ eitherIsVolatile: volatiles.has(a) || volatiles.has(b),
585
+ sharesLoop: [...x.loops].some((l) => y.loops.has(l)),
586
+ });
587
+ if (refused !== null) {
588
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
134
589
  continue;
135
590
  }
136
591
  // Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
137
592
  // at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
138
593
  // that is wrong but plausible.
139
- out.push({
594
+ candidates.push({
140
595
  merged: `${a}-${b}`,
141
- sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
596
+ sfn: { ...sfn, body: rename(sfn.body, a, b), locals: localsAfterMerge(sfn.locals, a, b) },
142
597
  });
143
598
  }
144
599
  }
145
- return out;
600
+ return { candidates, refusals };
146
601
  }