@asmlift/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) 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 +270 -171
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +243 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +78 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +345 -2
  16. package/src/ir/opcodes.ts +176 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/advance.ts +373 -0
  24. package/src/l3/argbase.ts +6 -5
  25. package/src/l3/ast.ts +510 -59
  26. package/src/l3/basecse.ts +686 -78
  27. package/src/l3/coalesce.ts +432 -46
  28. package/src/l3/dce.ts +31 -9
  29. package/src/l3/gates.ts +96 -1
  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 +176 -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 +114 -89
  42. package/src/l3/reindex.ts +722 -80
  43. package/src/l3/scopebase.ts +649 -220
  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 +16 -1
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +687 -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 +239 -16
  57. package/src/pipeline.ts +173 -60
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/const.ts +203 -3
  61. package/src/raise/divpow2.ts +4 -4
  62. package/src/raise/extscale.ts +342 -0
  63. package/src/raise/globalshape.ts +1058 -0
  64. package/src/raise/gvn.ts +33 -18
  65. package/src/raise/latch.ts +126 -0
  66. package/src/raise/magicdiv.ts +2 -2
  67. package/src/raise/memberarrays.ts +594 -0
  68. package/src/raise/narrow.ts +124 -0
  69. package/src/raise/narrowlocal.ts +572 -0
  70. package/src/raise/paramwidth.ts +201 -0
  71. package/src/raise/pre-recovery.ts +169 -21
  72. package/src/raise/recover.ts +56 -23
  73. package/src/raise/retsink.ts +585 -19
  74. package/src/raise/shortcircuit.ts +1050 -89
  75. package/src/raise/struct-arrays.ts +19 -2
  76. package/src/raise/structs.ts +34 -4
  77. package/src/raise/tailsink.ts +126 -0
  78. package/src/rank-declare.ts +256 -0
  79. package/src/rank-variations.ts +760 -0
  80. package/src/rank.ts +2122 -326
  81. package/src/structure/analysis.ts +1398 -150
  82. package/src/structure/bitfields.ts +432 -0
  83. package/src/structure/globalaccess.ts +300 -0
  84. package/src/structure/hazards.ts +411 -20
  85. package/src/structure/loops.ts +2 -49
  86. package/src/structure/namecoalesce.ts +454 -0
  87. package/src/structure/structure.ts +3979 -612
  88. package/src/structure/switch-recover.ts +710 -145
  89. package/src/symbols.ts +188 -6
  90. package/src/target.ts +495 -32
  91. package/src/trace.ts +112 -33
  92. package/src/variation-definitions.ts +1540 -0
  93. package/src/variation-gates.ts +89 -0
  94. package/src/variation-tokens.ts +355 -0
@@ -0,0 +1,687 @@
1
+ // L3 respell variation: duplicate a join statement back into the arms the compiler merged it out
2
+ // of — the dual of l3/tailmerge.ts, and a VARIATION where that one is unconditional.
3
+ //
4
+ // agbcc cross-jumps a store the source wrote in both arms into the join block, so the lifted CFG
5
+ // carries the address and the value on merge parameters and SSA destruction mints a temp per
6
+ // parameter:
7
+ //
8
+ // if (c) { … v16 = (u16 *)A1; v17 = B1; } else { … v16 = (u16 *)A2; v17 = B2; }
9
+ // *v16 = v17;
10
+ //
11
+ // The source that produced those bytes wrote `*(u16 *)A1 = B1;` inside one arm and
12
+ // `*(u16 *)A2 = B2;` inside the other — two whole statements, no temps. Substituting each arm's
13
+ // own definitions into the join statement and duplicating it back recovers that spelling.
14
+ //
15
+ // A VARIATION, NOT A DEFAULT, and the reason is the tower's: the LIFTED TREE underdetermines the
16
+ // source. This is a compiler claim, so it was compiled — agbcc `gcc 2.9-arm-000512` at
17
+ // `TOOLCHAIN.agbccFlags`, both spellings, `diff` on the `.s`:
18
+ //
19
+ // - the example above (an ADDRESS temp and a VALUE temp, against `*gA1 = gB1;` in one arm and
20
+ // `*gA2 = gB2;` in the other, `v17` typed as the store's own type) is BYTE-IDENTICAL. There
21
+ // the merge really is the compiler's own, and the variation costs one candidate to say so.
22
+ // - `synthetic:armcb2`'s shape is NOT. Only the VALUE merges there — the store's address,
23
+ // `gSlot[1]`, is common to both arms — and the per-arm spelling keeps TWO literal-pool
24
+ // islands and a `b` to the join where the merged one has a single island and no `b`.
25
+ //
26
+ // So the mapping from this tree back to a source is not a function, and it is not uniformly
27
+ // many-to-one either: which way it goes is a property of the SHAPE, which no gate here can read
28
+ // off the tree. That is exactly the tower's test for a variation rather than a default. Both
29
+ // spellings are emitted and the differ referees.
30
+ //
31
+ // Re-compile before restating either half: the VALUE TEMP'S TYPE alone changes the answer. Give
32
+ // `v17` a type wider than the store and the byte-identical case above grows an `ldr`/`ldrh`
33
+ // divergence that has nothing to do with the merge.
34
+ //
35
+ // SOUND BY ITS DUAL'S ARGUMENT, READ BACKWARDS. The join runs on every path out of the `if`,
36
+ // immediately after that arm's own tail, with nothing between; a copy at the end of each arm runs
37
+ // it exactly once per path, in the same order relative to everything else. What tailmerge needs
38
+ // (the two statements are identical) this does not — the copies legitimately differ, because each
39
+ // arm substitutes its own definitions.
40
+ //
41
+ // REFUSES (leaving the merged spelling, which is what the structurer produced) when:
42
+ // - the `if` has an empty arm, or the statement after it is not an `assign`/`store`/`exprstmt`
43
+ // (tailmerge's scope, for its reason: control flow duplicated into an arm changes what the arm
44
+ // still reaches);
45
+ // - the join statement reads no local the arms define — there is no merge to undo;
46
+ // - a local it reads is neither a merge temp (assigned EXACTLY ONCE IN EACH TERMINAL ARM and
47
+ // nowhere else, read only by the join statement, never address-taken — the counts are
48
+ // function-wide, so a second reader anywhere refuses) nor untouched by both arms (a name the
49
+ // arms DO write and this cannot substitute would read a different value at the arm's end);
50
+ // - anything but an EFFECT-FREE assignment TO A DECLARED LOCAL stands between the first
51
+ // definition and the arm's end: the substituted values are evaluated where the copy lands, so
52
+ // an intervening store or call could answer a load inside one of them differently. All three
53
+ // halves of that are tested. The second is why the statement KIND is not enough — `q = Foo();`
54
+ // is an `assign` whose value is a call, and a load moved past it is a load answered after the
55
+ // call instead of before it. The third is why the assignment's TARGET is not either: an
56
+ // `assign` names a variable, and structure.ts spells a write to a scalar GLOBAL as one, so
57
+ // `gBlendValue = v;` is an `assign` with an effect-free value that writes MEMORY. Not a
58
+ // corner of the corpus. Measured over a 957-row artifact (#140): 22 winning sources emit a
59
+ // statement-level assignment to a name they declare nowhere — 71 occurrences, 7 of them in
60
+ // `kleod:ProcessInputAndUpdateEntities` alone.
61
+ // RE-DERIVE THIS RATHER THAN QUOTING IT: one pass over the artifact does it — collect each
62
+ // winning source's declared locals and parameters, then count its statement-level `name = `
63
+ // lines whose name is not among them. `exprHasEffect` answers "a call, or a marker" and cannot
64
+ // see one, the same way it could not see a qualifier;
65
+ // - an intervening assignment writes a name one of those values reads — same reason, one level
66
+ // more precise;
67
+ // - a definition's value reads another of the merge names (the substitutions would need an order
68
+ // between them that the join statement does not fix);
69
+ // - a definition's value carries an EFFECT — a call or a gap marker. One statement's operands
70
+ // have no evaluation order in C, so folding two effectful expressions into it would let the
71
+ // backend choose an order the asm did not;
72
+ // - a definition's value performs a VOLATILE access. `exprHasEffect` above answers "a call, or a
73
+ // marker" and says nothing about a qualifier, so it is not the test for this: the refusal is
74
+ // asked of the qualifier's own model (`exprReadsVolatile`), which knows all three spellings —
75
+ // the cast, the pointee-volatile pointer local, and the volatile local object;
76
+ // - an arm is neither TERMINAL (a run of assignments defining the names) nor a RUNG (its last
77
+ // statement is an `if`, whose arms are asked the same question one level down — any nested
78
+ // `if` TREE qualifies, not only a right-nested else-if chain);
79
+ // - the terminal arms are not ALL of the name's definitions, or a merge name is still mentioned
80
+ // in the rewritten statement — the two halves of totality, below.
81
+ //
82
+ // THESE REFUSALS ARE AN `l3/gates.ts` TABLE — five of them, at four contexts, because a `Gate<Ctx>`
83
+ // is per-context and this pass judges four: the SITE (an `if` and the statement after it), one
84
+ // ARM's trailing run, one moved VALUE, and an arm as a RUNG. The fifth judges the REWRITTEN site,
85
+ // which exists only once the other four have admitted. What licensed the conversion is the
86
+ // instrument episode recorded at `pushJoin`'s tail refusal below (56 firings, split 40/16), which
87
+ // is the trigger `docs/level-tower.md` states — `grep -n "THE UNIT OF THAT DECISION" docs/level-tower.md`.
88
+ // The 40/16 split is now two gates, so that census is `pnpm bench gates --pass unmerge` rather than
89
+ // a patch — 291 agbcc synthetic rows in ~11 s, and it prints all five tables at once.
90
+ //
91
+ // TWO COLUMNS, AND THEY DISAGREE HERE. A census counts FIRST REJECTIONS; what an ablation of the
92
+ // same rule MOVES is a different number, and `raise/globalshape.ts` ships the convention for
93
+ // reporting both (`grep -n "ON ITS OWN" packages/core/src/raise/globalshape.ts`). Measured
94
+ // 2026-09-09, every `sound: false` gate in these five tables ablated ONE AT A TIME against the
95
+ // shipped table:
96
+ //
97
+ // population rules moved
98
+ // 40,000 generated trees (unmerge-fuzz's `gen`/`gen2`, 20,000 seeds each, 13,099 firings) 0 of 6
99
+ // 291 agbcc synthetic rows, through `enumerateRanked`, whole fan compared variations+source 1 of 6
100
+ //
101
+ // The one is `site/no-merge-name`, and it moves exactly one row: `synthetic:mergeloop:agbcc`, whose
102
+ // fan grows 16 -> 24 candidates when it is ablated, and which is also where it fires 40 of its 80
103
+ // times (`pnpm bench gates --pass unmerge --only synthetic:mergeloop:agbcc` — the full id, because a
104
+ // bare symbol censuses every toolchain that spec targets and pools the counts). The other five
105
+ // change NOTHING, in two different ways. Three of them refuse and are overruled, because a later
106
+ // rule or `pushJoin`'s own narrowing refuses the same sites: `empty-arm` 168, `tail-is-not-an-if`
107
+ // 40, `empty-arm-has-no-tail` 16. The two VALUE gates never fire at all — the census prints
108
+ // `value: (never fired)`, and each of them carries the proof below that it cannot. So
109
+ // `ablateHeuristic` has reach into exactly one rule here, and a variation built on any of the other five
110
+ // would measure 0 rows moved for a reason that has nothing to do with the variation. RE-RUN BOTH before
111
+ // quoting either: the generated population says 0 of 6 and the corpus says 1 of 6, which is itself
112
+ // the lesson — a fuzz generator's shapes are not the corpus's.
113
+ //
114
+ // THE RESIDUE, NAMED. `list`'s `s.k === 'if' && isJoinable(join)` is the ENUMERATOR — which pairs
115
+ // are judged at all — and enumeration is not residue. Nor are the `null`s `unmergeAt` and
116
+ // `pushJoin` propagate out of a nested call: that refusal was already delivered, and counting it
117
+ // twice would make the census read as if a second rule fired.
118
+ //
119
+ // THE ARMS ARE THE PATHS, WHICH IS WHY THE LADDER IS THE SAME REWRITE. agbcc cross-jumps the shared
120
+ // tail of an else-if CHAIN exactly as it cross-jumps a two-armed `if`'s, and the lifted tree then
121
+ // hands this pass an outer `if` whose `else` is another `if`. Every path out of that ladder leaves
122
+ // through exactly one TERMINAL arm, so a copy at the end of each runs exactly once per path — the
123
+ // two-arm argument above, read inductively. `pushJoin`'s own note carries which shapes that admits
124
+ // (any nested `if` tree, not only a chain) and why it needs no cap.
125
+ //
126
+ // TOTALITY, NOT AN ARM COUNT, is what pins "and nowhere else" — a ladder assigns its merge name
127
+ // once per arm, so no number belongs in the gate. Two checks say it without one: every assignment
128
+ // in the function is one of the terminal arms this rewrite consumed, AND no merge name is still
129
+ // mentioned in the result. The second is not redundant — `localMentions` is read once, before any
130
+ // rewriting, and this pass duplicates statements, so an earlier site can leave the map short by
131
+ // exactly the number the ladder consumes and make the first check agree by coincidence. Only
132
+ // re-reading the result catches that, and `test/unmerge.test.ts` builds the tree where it must.
133
+ //
134
+ // AND THE SCOPE OF THE VOLATILE ONE IS WHAT MOVES, which is exactly one thing. A kept statement
135
+ // holds its position, and the join runs where it already ran (immediately after that arm), so the
136
+ // only access whose point in the sequence changes is a DEFINITION's value — moved down to the
137
+ // arm's end, past every kept statement. A plain read may make that trip, because the kept
138
+ // statements from the first definition on are effect-free assignments to DECLARED LOCALS and so
139
+ // none of them writes memory that could answer it differently — which is what the three gates
140
+ // above establish, the local-target one included. An observable read may not make the trip: a
141
+ // volatile access is one the source pinned so it would not be duplicated or moved, and its ORDER
142
+ // against the other device accesses beside it is observable — which is why THAT gate is stated on
143
+ // the moved value and needs no clause for the statements that stay put.
144
+ //
145
+ // WHAT THE LOCAL-TARGET GATE DOES NOT CLOSE, stated rather than implied: ALIASING. A moved value
146
+ // reading `*p` and a kept assignment to an address-taken local can name the same object under two
147
+ // spellings, and the name-keyed refusal below (an intervening assignment writes a name one of
148
+ // those values READS) cannot see it. That is one question further out than this pass models —
149
+ // every other respell variation here defers it to the same name-keyed model — and the sweep behind this note
150
+ // found no inhabitant: 0 arms this pass ACCEPTS hold a kept assignment to an address-taken local
151
+ // after the first definition, the same sweep that found 0 holding one to a global. That sweep's
152
+ // population was the agbcc rows whose BASE TREE the rig could build, which is not the corpus's
153
+ // agbcc row count, so re-run it before quoting a count off it.
154
+ import {
155
+ type Expr,
156
+ type SFn,
157
+ type Stmt,
158
+ exprHasEffect,
159
+ exprReadsVolatile,
160
+ mapExprChildren,
161
+ mapStmtExprs,
162
+ stmtChildren,
163
+ walkExprs,
164
+ } from './ast';
165
+ import { type Gate, firstRejection } from './gates';
166
+ import { localMentions, mentionsAnyLocal, readsOf } from './mentions';
167
+
168
+ /** every statement under `body`, itself included */
169
+ function* walkStmts(body: Stmt[]): Generator<Stmt> {
170
+ for (const s of body) {
171
+ yield s;
172
+ yield* walkStmts(stmtChildren(s));
173
+ }
174
+ }
175
+
176
+ /** The statements this pass moves — see the scope refusal above. */
177
+ type Joinable = Extract<Stmt, { k: 'assign' } | { k: 'store' } | { k: 'exprstmt' }>;
178
+ const isJoinable = (s: Stmt): s is Joinable => s.k === 'assign' || s.k === 'store' || s.k === 'exprstmt';
179
+
180
+ /** The locals `s` READS. An `assign`'s target is a write and `stmtExprs` does not carry it, which
181
+ * is what makes this the set of names substitution has to supply. */
182
+ function namesRead(s: Stmt, locals: ReadonlySet<string>): Set<string> {
183
+ const out = new Set<string>();
184
+ for (const e of walkExprs([s])) {
185
+ if (e.k === 'var' && locals.has(e.name)) {
186
+ out.add(e.name);
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /** Every local `e` reads. */
193
+ function readsIn(e: Expr): Set<string> {
194
+ const out = new Set<string>();
195
+ for (const x of walkExprs([{ k: 'exprstmt', value: e }])) {
196
+ if (x.k === 'var') {
197
+ out.add(x.name);
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // ── THE REFUSALS, AS DATA ────────────────────────────────────────────────────────────────────
204
+
205
+ /** What the SITE table judges: one `if` and the statement that follows it.
206
+ *
207
+ * THE FIELDS ARE LAZY. Classifying the join's reads walks both arms and reads the function-wide
208
+ * mention counts, and a site with an empty arm is refused before either is asked. */
209
+ export interface UnmergeSite {
210
+ readonly iff: Extract<Stmt, { k: 'if' }>;
211
+ /** the merge temps a rewrite here would substitute */
212
+ readonly merge: ReadonlySet<string>;
213
+ /** names the arms write that this cannot substitute — a copy would read a different value */
214
+ readonly unsubstitutable: ReadonlySet<string>;
215
+ }
216
+
217
+ export const UNMERGE_SITE_GATES: readonly Gate<UnmergeSite>[] = [
218
+ {
219
+ id: 'empty-arm',
220
+ why: 'an `if` with an empty arm would leave that path with no copy of the join',
221
+ // Not sound only because it is not the LAST word: an empty arm defines nothing, so the rung
222
+ // table below refuses it a second time. This one is the scope statement, stated where a reader
223
+ // looks for it.
224
+ sound: false,
225
+ // THE GUARD IS THE CENSUS, not the decline. Deleted from this table, the whole core suite
226
+ // stays green — the rung table refuses an empty arm a second time — so the only test that can
227
+ // tell this gate apart from its shadow is the one that reads the ID back.
228
+ guardedBy: 'unmerge.test.ts: a census names the rule that refused each site, and counts it',
229
+ rejects: (c) => c.iff.then.length === 0 || c.iff.else.length === 0,
230
+ },
231
+ {
232
+ id: 'arm-writes-a-name-this-cannot-substitute',
233
+ why: 'a name an arm writes that the rewrite cannot substitute would be read by the copy at the arm’s end, where it holds a different value',
234
+ sound: true,
235
+ // THE GUARD IS NOT THE TEST THAT NAMES THIS REFUSAL. Ablated alone, `a join reading a local
236
+ // the arms WRITE but this cannot substitute refuses` stays GREEN — that fixture is refused a
237
+ // second time further down — while three others go red. The one named here is the informative
238
+ // one: it is the shape `moved-value-reads-another-merge-name` claims to catch, and this gate
239
+ // is what actually catches it (see that gate).
240
+ guardedBy: 'unmerge.test.ts: a definition whose value reads ANOTHER merge temp refuses',
241
+ rejects: (c) => c.unsubstitutable.size > 0,
242
+ },
243
+ {
244
+ id: 'no-merge-name',
245
+ why: 'the join reads nothing the arms define — there is no merge to undo',
246
+ sound: false,
247
+ // Same shadowing as `empty-arm` above, and the same guard: ablated alone, the named decline
248
+ // test stays green (a join reading no merge name defines nothing to move, so the arm table
249
+ // refuses it next), and only the census distinguishes which rule spoke.
250
+ guardedBy: 'unmerge.test.ts: a census names the rule that refused each site, and counts it',
251
+ rejects: (c) => c.merge.size === 0,
252
+ },
253
+ ];
254
+
255
+ /** What the ARM table judges: whether one arm is TERMINAL — a trailing run of assignments defining
256
+ * every merge name, with nothing beside them that could answer a moved read differently.
257
+ *
258
+ * THE FIELDS ARE LAZY for the same reason: `first` is `Math.min` over an empty map when the arm
259
+ * does not define them all, and `clobbersAMovedRead` reads every definition's value. */
260
+ export interface UnmergeArm {
261
+ /** where in the arm each merge name is defined */
262
+ readonly at: ReadonlyMap<string, number>;
263
+ readonly names: ReadonlySet<string>;
264
+ /** the tree's own locals and params — the objects a kept assignment may write */
265
+ readonly declared: ReadonlySet<string>;
266
+ /** the arm from its FIRST definition on: the run the copy is appended to */
267
+ readonly trailing: readonly Stmt[];
268
+ /** does a kept assignment after that point write a name one of the moved values reads? */
269
+ readonly clobbersAMovedRead: boolean;
270
+ }
271
+
272
+ export const UNMERGE_ARM_GATES: readonly Gate<UnmergeArm>[] = [
273
+ {
274
+ id: 'arm-does-not-define-them-all',
275
+ why: 'a merge name an arm does not define leaves that arm nothing to substitute',
276
+ sound: true,
277
+ // Ablated, `a merge temp assigned in only ONE arm (or three times) refuses` stays green —
278
+ // totality catches that one — and the LADDERS break instead, which is the real cost: this is
279
+ // the gate that says an arm is not terminal, so without it a rung is rewritten as though it
280
+ // were a leaf.
281
+ guardedBy: 'unmerge.test.ts: a THREE-arm ladder puts the join in all three',
282
+ rejects: (c) => c.at.size !== c.names.size,
283
+ },
284
+ // The three halves of "nothing but an EFFECT-FREE assignment TO A DECLARED LOCAL stands between
285
+ // the first definition and the arm's end", one gate each — the header says all three are tested,
286
+ // and separating them is what lets a census say WHICH one a corpus actually hits. A disjunction
287
+ // of existentials is the existential of the disjunction, so the split is exact.
288
+ {
289
+ id: 'trailing-run-holds-a-non-assignment',
290
+ why: 'a store or a call between a definition and the arm’s end would run before the value moved past it',
291
+ sound: true,
292
+ guardedBy: 'unmerge.test.ts: a definition that is NOT in the arm',
293
+ rejects: (c) => c.trailing.some((s) => s.k !== 'assign'),
294
+ },
295
+ {
296
+ id: 'trailing-run-writes-an-undeclared-name',
297
+ why: 'a write to a global scalar is spelled as an assignment, and it writes memory',
298
+ sound: true,
299
+ guardedBy: 'unmerge.test.ts: an intervening assignment to a GLOBAL refuses',
300
+ rejects: (c) => c.trailing.some((s) => s.k === 'assign' && !c.declared.has(s.name)),
301
+ },
302
+ {
303
+ id: 'trailing-run-holds-an-effectful-value',
304
+ why: 'a call assigned between a definition and the arm’s end would run before the load moved past it',
305
+ sound: true,
306
+ guardedBy: 'unmerge.test.ts: an intervening assignment whose VALUE is a CALL refuses',
307
+ rejects: (c) => c.trailing.some((s) => s.k === 'assign' && exprHasEffect(s.value)),
308
+ },
309
+ {
310
+ id: 'intervening-write-to-a-moved-read',
311
+ why: 'an assignment between a definition and the arm’s end that writes what the definition reads would change the moved value',
312
+ sound: true,
313
+ guardedBy: 'unmerge.test.ts: an intervening assignment that CLOBBERS what a definition reads refuses',
314
+ rejects: (c) => c.clobbersAMovedRead,
315
+ },
316
+ ];
317
+
318
+ /** What the VALUE table judges: ONE definition's value, asked whether the substitution may
319
+ * relocate it to the arm's end. Per value, not per arm — a table entry names the reason. */
320
+ export interface UnmergeMovedValue {
321
+ readonly value: Expr;
322
+ readonly names: ReadonlySet<string>;
323
+ readonly sfn: SFn;
324
+ }
325
+
326
+ export const UNMERGE_VALUE_GATES: readonly Gate<UnmergeMovedValue>[] = [
327
+ {
328
+ id: 'moved-value-has-an-effect',
329
+ why: 'C fixes no order between one statement’s operands, so the backend would choose one',
330
+ // SHADOWED, not sound — and PROVABLY, not just unwitnessed. Every definition sits at a
331
+ // position in `at`, all of which are `>= first`, so every definition is in the arm ctx's
332
+ // `trailing`; an effectful one is therefore already refused by
333
+ // `trailing-run-holds-an-effectful-value` one context up. Ablated on its own, no test in
334
+ // unmerge.test.ts or unmerge-fuzz.test.ts reddens — `a definition carrying an EFFECT
335
+ // refuses` included. It stays because the scope it states is the pass's, and deleting a rule
336
+ // that is correct-but-shadowed is how the shadowing rule silently becomes load-bearing.
337
+ sound: false,
338
+ rejects: (c) => exprHasEffect(c.value),
339
+ },
340
+ {
341
+ id: 'moved-value-reads-volatile',
342
+ why: 'a moved value that reads a `volatile` access moves a read the source pinned, and its order is observable',
343
+ sound: true,
344
+ guardedBy: 'unmerge.test.ts: a definition reading a DEVICE REGISTER refuses',
345
+ rejects: (c) => exprReadsVolatile(c.value, c.sfn),
346
+ },
347
+ {
348
+ id: 'moved-value-reads-another-merge-name',
349
+ why: 'the substitutions would need an order between them that the join statement does not fix',
350
+ // SHADOWED BY THE SITE TABLE, provably. A merge name is one the join reads and that
351
+ // `readsOf(m) === 1` says is read NOWHERE ELSE; a definition value reading it is a second
352
+ // read, so it was never in `merge` — it landed in `unsubstitutable` and
353
+ // `arm-writes-a-name-this-cannot-substitute` refused the whole site. Ablated on its own, no
354
+ // test reddens, its own namesake fixture included, which is why that fixture is the guard
355
+ // named on the site gate instead. Kept for the same reason as the gate above.
356
+ sound: false,
357
+ rejects: (c) => [...readsIn(c.value)].some((n) => c.names.has(n)),
358
+ },
359
+ ];
360
+
361
+ /** What the RUNG table judges: an arm that is not terminal, asked whether the copy belongs one
362
+ * level down instead. Two gates rather than one because the instrument episode the header cites
363
+ * measured them SEPARATELY — 40 firings on a non-`if` tail, 16 on an empty arm — and a table that
364
+ * fused them could not reproduce that split. */
365
+ export interface UnmergeRung {
366
+ readonly arm: readonly Stmt[];
367
+ }
368
+
369
+ export const UNMERGE_RUNG_GATES: readonly Gate<UnmergeRung>[] = [
370
+ {
371
+ id: 'empty-arm-has-no-tail',
372
+ why: 'an empty arm has no statement to pass the copy down into, and defines none of the names',
373
+ sound: false,
374
+ // REDUNDANT WITH `tail-is-not-an-if` below (`arm[len - 1]` of an empty arm is `undefined`, whose
375
+ // `?.k` is not `'if'`) — not SHADOWED in `gates.ts`'s sense, which is about an id being ABSENT
376
+ // from a census because an EARLIER rule refused first. This rule is the earlier one and it is
377
+ // rank 2 in the census (16 firings); what it shares with the next is its VERDICT, so ablating it
378
+ // moves nothing. The census test is therefore the guard — it is also what the SPLIT this table
379
+ // exists to reproduce is asserted by.
380
+ guardedBy: 'unmerge.test.ts: the RUNG census reproduces the split the instrumented run measured, without the patch',
381
+ rejects: (c) => c.arm.length === 0,
382
+ },
383
+ {
384
+ id: 'tail-is-not-an-if',
385
+ why: 'an arm that does not define every merge name can pass the copy down only into the `if` it ends with',
386
+ sound: false,
387
+ // REDUNDANT WITH the type narrowing in `pushJoin` — which has to stand there whatever this table
388
+ // says, because both gates here are ablatable. Not shadowed in `gates.ts`'s sense either: this
389
+ // rule fires 40 times in the census and is the table's top row; the narrowing outside the table
390
+ // is what makes ablating it move nothing. The census test is the guard that survives that.
391
+ guardedBy: 'unmerge.test.ts: the RUNG census reproduces the split the instrumented run measured, without the patch',
392
+ rejects: (c) => c.arm[c.arm.length - 1]?.k !== 'if',
393
+ },
394
+ ];
395
+
396
+ /** What the TOTALITY table judges: the REWRITTEN site. It exists only once every other table has
397
+ * admitted, which is why it is a table of its own rather than three more site gates.
398
+ *
399
+ * `out` IS LAZY: the first gate reads only counts, and building the rewritten `if` for a site
400
+ * that fails it would be a spread nothing reads. */
401
+ export interface UnmergeTotality {
402
+ readonly merge: ReadonlySet<string>;
403
+ readonly mentions: ReturnType<typeof localMentions>;
404
+ /** how many copies of the join the rewrite took — one per terminal arm */
405
+ readonly used: number;
406
+ readonly out: Stmt;
407
+ }
408
+
409
+ export const UNMERGE_TOTALITY_GATES: readonly Gate<UnmergeTotality>[] = [
410
+ {
411
+ id: 'a-definition-the-rewrite-did-not-consume',
412
+ why: 'a definition the rewrite did not turn into a copy would survive with nothing left to read it, and its local is deleted',
413
+ sound: true,
414
+ guardedBy: 'unmerge.test.ts: a definition OUTSIDE the terminal arms refuses',
415
+ rejects: (c) => [...c.merge].some((n) => c.mentions.get(n)?.assigns !== c.used),
416
+ },
417
+ {
418
+ id: 'merge-name-survives-the-rewrite',
419
+ why: 'the merge variables are deleted, so the rewritten code must mention none of them, whatever the counts taken before the rewrite said',
420
+ sound: true,
421
+ guardedBy: 'unmerge.test.ts: a definition an earlier rewrite duplicated leaves the count agreeing',
422
+ rejects: (c) => mentionsAnyLocal([c.out], c.merge),
423
+ },
424
+ ];
425
+
426
+ /** The tables `unmergeJoins` consults, each overridable — which is how a caller outside core takes
427
+ * a per-id refusal census (`tallying`) or an ablation (`without`) off this pass without editing
428
+ * it. Test and diagnostic seam; the shipped path passes nothing. */
429
+ export interface UnmergeGates {
430
+ readonly site?: readonly Gate<UnmergeSite>[];
431
+ readonly arm?: readonly Gate<UnmergeArm>[];
432
+ readonly value?: readonly Gate<UnmergeMovedValue>[];
433
+ readonly rung?: readonly Gate<UnmergeRung>[];
434
+ readonly totality?: readonly Gate<UnmergeTotality>[];
435
+ }
436
+
437
+ /** The arm's definitions of `names` and the statements that survive beside them, or null when the
438
+ * substituted copy would not evaluate to the same values at the arm's end — `UNMERGE_ARM_GATES`
439
+ * carries every reason. */
440
+ function armDefs(
441
+ arm: Stmt[],
442
+ names: ReadonlySet<string>,
443
+ declared: ReadonlySet<string>,
444
+ gates: readonly Gate<UnmergeArm>[],
445
+ ): { defs: Map<string, Expr>; keep: Stmt[] } | null {
446
+ const at = new Map<string, number>();
447
+ arm.forEach((s, i) => {
448
+ if (s.k === 'assign' && names.has(s.name)) {
449
+ at.set(s.name, i);
450
+ }
451
+ });
452
+ const valueAt = (i: number): Expr => (arm[i] as Extract<Stmt, { k: 'assign' }>).value;
453
+ let first: number | undefined;
454
+ const firstDef = (): number => (first ??= Math.min(...at.values()));
455
+ let clobbers: boolean | undefined;
456
+ const ctx: UnmergeArm = {
457
+ at,
458
+ names,
459
+ declared,
460
+ get trailing() {
461
+ return arm.slice(firstDef());
462
+ },
463
+ get clobbersAMovedRead() {
464
+ if (clobbers === undefined) {
465
+ // `declared` is the tree's own locals and params, so a kept assignment's target is an
466
+ // object no moved read can reach except by the name this keys on.
467
+ const read = new Set([...at.values()].flatMap((i) => [...readsIn(valueAt(i))]));
468
+ clobbers = arm.some((s, i) => i > firstDef() && s.k === 'assign' && !names.has(s.name) && read.has(s.name));
469
+ }
470
+ return clobbers;
471
+ },
472
+ };
473
+ if (firstRejection(gates, ctx) !== null) {
474
+ return null;
475
+ }
476
+ const defs = new Map([...at].map(([n, i]) => [n, valueAt(i)] as const));
477
+ // Everything but the definitions themselves, which the substitution consumed.
478
+ const keep = arm.filter((s, i) => !(i >= firstDef() && s.k === 'assign' && names.has(s.name)));
479
+ return { defs, keep };
480
+ }
481
+
482
+ /** `s` with every mention of a defined name replaced by that name's value. */
483
+ function substitute(s: Stmt, defs: ReadonlyMap<string, Expr>): Stmt {
484
+ const rec = (e: Expr): Expr => (e.k === 'var' ? (defs.get(e.name) ?? e) : mapExprChildren(e, rec));
485
+ return mapStmtExprs(s, rec);
486
+ }
487
+
488
+ /** One arm with `join` pushed into it, and how many COPIES of the join that took — the count the
489
+ * caller needs to prove every definition of every merge name was consumed. One arm, one copy.
490
+ *
491
+ * The value guard lives here rather than at the call site because the values it judges are the
492
+ * ones THIS arm moves: an effectful or volatile value, or one reading another merge name, is a
493
+ * value the substitution may not relocate to the arm's end. Same set of values as before, asked
494
+ * one arm earlier. */
495
+ function pushJoin(
496
+ arm: Stmt[],
497
+ names: ReadonlySet<string>,
498
+ declared: ReadonlySet<string>,
499
+ join: Joinable,
500
+ sfn: SFn,
501
+ gates: UnmergeGates,
502
+ ): { arm: Stmt[]; used: number } | null {
503
+ const here = armDefs(arm, names, declared, gates.arm ?? UNMERGE_ARM_GATES);
504
+ if (here !== null) {
505
+ for (const v of here.defs.values()) {
506
+ if (firstRejection(gates.value ?? UNMERGE_VALUE_GATES, { value: v, names, sfn }) !== null) {
507
+ return null;
508
+ }
509
+ }
510
+ return { arm: [...here.keep, substitute(join, here.defs)], used: 1 };
511
+ }
512
+ // NOT a terminal arm. It is a RUNG when its LAST statement is an `if` — then the copy belongs
513
+ // one level down, in that `if`'s own arms, and the statements before it are untouched: they run
514
+ // before the rung is entered, so no value this moves crosses them.
515
+ //
516
+ // THE SHAPE ADMITTED IS ANY NESTED `if` TREE, not only a right-nested else-if chain. Nothing
517
+ // here restricts the recursion to one side, and nothing should: the soundness argument is "every
518
+ // path out leaves through exactly one terminal arm", which a BALANCED tree satisfies as fully as
519
+ // a chain — `if (c) { if (d) A else B } else { if (e) C else D }` fires and takes four copies.
520
+ // Call it a ladder because that is what the corpus holds, not because the code tests for one.
521
+ //
522
+ // NO CAP, and none is wanted: the copy count is exactly the number of TERMINAL ARMS, so the work
523
+ // and the emitted source are LINEAR in the subtree the recursion walks — a depth-6 balanced tree
524
+ // is 64 leaves and 64 copies, the same one-copy-per-path the two-arm case makes. The corpus
525
+ // maximum is 5 (`synthetic:armcb`). A refusal above some arm count would be a gate with no
526
+ // inhabitant, which this file's own standard rejects.
527
+ //
528
+ // REFUSES when the tail is anything else, and THIS REFUSAL HAS INHABITANTS — do not read the
529
+ // success count as evidence about it. Instrumented at this return and re-run over the synthetic
530
+ // agbcc tier (281 rows, exit 0): 56 firings in 2 functions — 40 on an `assign` tail (32 in
531
+ // `maskchain`, 8 in `dmascope2`) and 16 on an EMPTY arm (`maskchain`). `dmascope2` is not one of
532
+ // the four rows that reach the ladder SUCCESSFULLY, so it appears in no success count at all.
533
+ // Only `while` and `switch` tails are unwitnessed; a plain statement is not. That episode is the
534
+ // instrument evidence this pass's tables were converted on, and the two counts it produced are
535
+ // now the two `UNMERGE_RUNG_GATES` ids.
536
+ //
537
+ // An EMPTY arm is delivered its refusal HERE, not by `armDefs`: `armDefs` declines it first
538
+ // (`names` is never empty, so no run of statements in an empty arm can supply it) and the tail
539
+ // check is what turns that decline into the site's. Same for an arm whose statements simply do
540
+ // not define the names.
541
+ if (firstRejection(gates.rung ?? UNMERGE_RUNG_GATES, { arm }) !== null) {
542
+ return null;
543
+ }
544
+ // The gate above owns the ATTRIBUTION; this owns the TYPE. They are not the same job: both rung
545
+ // gates are `sound: false`, so `ablateHeuristic` sanctions a shipped table without
546
+ // `tail-is-not-an-if`, and a cast here would then read `.then` off an `assign` — a TypeError
547
+ // inside `rank.ts`'s `try { pf.apply(sfn) } catch {}`, i.e. a silent zero-candidate decline.
548
+ const last = arm[arm.length - 1];
549
+ if (last === undefined || last.k !== 'if') {
550
+ return null;
551
+ }
552
+ const t = pushJoin(last.then, names, declared, join, sfn, gates);
553
+ const e = pushJoin(last.else, names, declared, join, sfn, gates);
554
+ if (t === null || e === null) {
555
+ return null;
556
+ }
557
+ return { arm: [...arm.slice(0, -1), { ...last, then: t.arm, else: e.arm }], used: t.used + e.used };
558
+ }
559
+
560
+ /** The tree with every eligible join statement pushed back into its arms, or null when no site
561
+ * qualified — the variation declines rather than re-emitting the default spelling. */
562
+ export function unmergeJoins(sfn: SFn, gates: UnmergeGates = {}): SFn | null {
563
+ const mentions = localMentions(sfn);
564
+ const localNames = new Set(sfn.locals.map((l) => l.name));
565
+ // Locals AND params — both name an automatic object, and an assignment to either is the write
566
+ // `armDefs` may keep. Separate from `localNames` above, which answers a different question
567
+ // (which of the join's reads a substitution could supply) and is deliberately locals-only.
568
+ const declaredNames = new Set([...localNames, ...sfn.params.map((p) => p.name)]);
569
+ const consumed = new Set<string>();
570
+
571
+ /** The join's reads, split into the merge temps a rewrite would substitute and the names the
572
+ * arms write that it could not. ONE walk, because both gates read the same classification and
573
+ * the site's first gate may refuse before either is asked. */
574
+ const classify = (iff: Extract<Stmt, { k: 'if' }>, join: Joinable) => {
575
+ const read = namesRead(join, localNames);
576
+ const written = new Set(
577
+ [...iff.then, ...iff.else].flatMap((s) => [...walkStmts([s])].filter((x) => x.k === 'assign').map((x) => x.name)),
578
+ );
579
+ const merge = new Set<string>();
580
+ const unsubstitutable = new Set<string>();
581
+ for (const n of read) {
582
+ const m = mentions.get(n);
583
+ // `written.has(n)` is a CANDIDATE condition, not only the bystander test, and it ADMITS
584
+ // sites an arity gate alone declines. A candidate the arms cannot define refuses the WHOLE
585
+ // SITE instead of being ignored, and `m.assigns` is `localMentions`'s FUNCTION-WIDE count,
586
+ // never arm-scoped — so without this conjunct a name assigned only OUTSIDE the arms reaches
587
+ // the candidate set and sinks the site. Measured, against the same pass with the conjunct
588
+ // removed:
589
+ //
590
+ // n = 1; n = 2; if (c) x = 1; else x = 2; n[0] = x; without: NULL · here: FIRES
591
+ // y = 1; y = 2; if (c) p = a; else p = b; *p = y; without: NULL · here: FIRES
592
+ //
593
+ // (`y` is a bystander read carried into both copies, `p` is the merge; nothing `y` reads
594
+ // moves, because the copy is always LAST in its arm.) Both admissions are pinned as RULE
595
+ // tests in unmerge.test.ts.
596
+ if (m && written.has(n) && m.assigns >= 2 && readsOf(m) === 1 && m.addrTaken === 0) {
597
+ merge.add(n);
598
+ } else if (written.has(n)) {
599
+ unsubstitutable.add(n);
600
+ }
601
+ }
602
+ return { merge, unsubstitutable };
603
+ };
604
+
605
+ const unmergeAt = (iff: Extract<Stmt, { k: 'if' }>, join: Joinable): Stmt | null => {
606
+ let split: { merge: Set<string>; unsubstitutable: Set<string> } | undefined;
607
+ const of = () => (split ??= classify(iff, join));
608
+ const site: UnmergeSite = {
609
+ iff,
610
+ get merge() {
611
+ return of().merge;
612
+ },
613
+ get unsubstitutable() {
614
+ return of().unsubstitutable;
615
+ },
616
+ };
617
+ if (firstRejection(gates.site ?? UNMERGE_SITE_GATES, site) !== null) {
618
+ return null;
619
+ }
620
+ const merge = of().merge;
621
+ const then = pushJoin(iff.then, merge, declaredNames, join, sfn, gates);
622
+ const els = pushJoin(iff.else, merge, declaredNames, join, sfn, gates);
623
+ if (then === null || els === null) {
624
+ return null;
625
+ }
626
+ // TOTALITY: every assignment to a merge name ANYWHERE in the function has to be one of the
627
+ // terminal arms just rewritten, AND the counts are STALE — an earlier site inside this same
628
+ // tree can turn one assignment into two, leaving `mentions` short by exactly the number of
629
+ // arms the ladder then consumes, so the two counts agree by coincidence. Only re-reading the
630
+ // RESULT catches that, which is why the second gate exists and why `out` is what it reads.
631
+ //
632
+ // What this does NOT cover, stated rather than implied: a mention OUTSIDE `out`, which
633
+ // `readsOf(m) === 1` answers from the same stale map. A sibling site duplicating a read is the
634
+ // shape that would reach it; no inhabitant is known. That is the pass's standing model, not
635
+ // this table's job.
636
+ let out: Stmt | undefined;
637
+ const totality: UnmergeTotality = {
638
+ merge,
639
+ mentions,
640
+ used: then.used + els.used,
641
+ get out() {
642
+ return (out ??= { ...iff, then: then.arm, else: els.arm });
643
+ },
644
+ };
645
+ if (firstRejection(gates.totality ?? UNMERGE_TOTALITY_GATES, totality) !== null) {
646
+ return null;
647
+ }
648
+ merge.forEach((n) => consumed.add(n));
649
+ return totality.out;
650
+ };
651
+
652
+ const list = (xs: Stmt[]): Stmt[] => {
653
+ const out: Stmt[] = [];
654
+ for (let i = 0; i < xs.length; i++) {
655
+ const s = rewrite(xs[i]);
656
+ const join = xs[i + 1];
657
+ const done = s.k === 'if' && join !== undefined && isJoinable(join) ? unmergeAt(s, join) : null;
658
+ out.push(done ?? s);
659
+ if (done) {
660
+ i++; // the join statement now lives in both arms
661
+ }
662
+ }
663
+ return out;
664
+ };
665
+
666
+ const rewrite = (s: Stmt): Stmt => {
667
+ switch (s.k) {
668
+ case 'if':
669
+ return { ...s, then: list(s.then), else: list(s.else) };
670
+ case 'while':
671
+ case 'dowhile':
672
+ case 'for':
673
+ return { ...s, body: list(s.body) };
674
+ case 'switch':
675
+ return {
676
+ ...s,
677
+ cases: s.cases.map((c) => ({ ...c, body: list(c.body) })),
678
+ ...(s.default ? { default: list(s.default) } : {}),
679
+ };
680
+ default:
681
+ return s;
682
+ }
683
+ };
684
+
685
+ const body = list(sfn.body);
686
+ return consumed.size === 0 ? null : { ...sfn, body, locals: sfn.locals.filter((l) => !consumed.has(l.name)) };
687
+ }