@asmlift/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +39 -11
  4. package/src/contracts.ts +60 -11
  5. package/src/frontend/ssa.ts +1 -1
  6. package/src/frontend/thumb.ts +2 -2
  7. package/src/ir/alias.ts +24 -0
  8. package/src/ir/core.ts +8 -0
  9. package/src/ir/opcodes.ts +43 -7
  10. package/src/ir/simplify.ts +1 -1
  11. package/src/l3/address.ts +2 -2
  12. package/src/l3/advance.ts +373 -0
  13. package/src/l3/argbase.ts +4 -4
  14. package/src/l3/ast.ts +65 -21
  15. package/src/l3/basecse.ts +48 -28
  16. package/src/l3/coalesce.ts +9 -9
  17. package/src/l3/gates.ts +75 -1
  18. package/src/l3/hoist.ts +1 -1
  19. package/src/l3/homesplit.ts +13 -13
  20. package/src/l3/initfirst.ts +3 -3
  21. package/src/l3/inlinebase.ts +16 -16
  22. package/src/l3/mentions.ts +68 -5
  23. package/src/l3/mulfirst.ts +3 -3
  24. package/src/l3/nearbase.ts +4 -4
  25. package/src/l3/offmember.ts +5 -5
  26. package/src/l3/parkfirst.ts +6 -6
  27. package/src/l3/pollguard.ts +3 -3
  28. package/src/l3/ptrfield.ts +4 -4
  29. package/src/l3/regspell.ts +8 -8
  30. package/src/l3/reindex.ts +22 -17
  31. package/src/l3/scopebase.ts +28 -25
  32. package/src/l3/sinkinit.ts +7 -7
  33. package/src/l3/slotorder.ts +3 -3
  34. package/src/l3/storage.ts +1 -1
  35. package/src/l3/tailmerge.ts +2 -2
  36. package/src/l3/typing.ts +3 -3
  37. package/src/l3/unmerge.ts +483 -59
  38. package/src/l3/unreduce.ts +13 -13
  39. package/src/l3/volatileptr.ts +11 -11
  40. package/src/l3/volatileval.ts +11 -11
  41. package/src/l3/volstore.ts +16 -16
  42. package/src/l3/zerosub.ts +6 -6
  43. package/src/pattern/engine.ts +4 -4
  44. package/src/pipeline.ts +17 -5
  45. package/src/proto.ts +2 -2
  46. package/src/raise/const.ts +203 -3
  47. package/src/raise/divpow2.ts +2 -2
  48. package/src/raise/extscale.ts +342 -0
  49. package/src/raise/globalshape.ts +32 -12
  50. package/src/raise/gvn.ts +2 -2
  51. package/src/raise/magicdiv.ts +2 -2
  52. package/src/raise/memberarrays.ts +4 -4
  53. package/src/raise/narrowlocal.ts +18 -2
  54. package/src/raise/paramwidth.ts +24 -2
  55. package/src/raise/pre-recovery.ts +90 -25
  56. package/src/raise/retsink.ts +381 -15
  57. package/src/raise/shortcircuit.ts +595 -34
  58. package/src/raise/structs.ts +4 -4
  59. package/src/raise/tailsink.ts +126 -0
  60. package/src/rank-declare.ts +4 -4
  61. package/src/{rank-axes.ts → rank-variations.ts} +319 -189
  62. package/src/rank.ts +1148 -803
  63. package/src/structure/analysis.ts +87 -90
  64. package/src/structure/bitfields.ts +130 -30
  65. package/src/structure/globalaccess.ts +30 -4
  66. package/src/structure/namecoalesce.ts +32 -13
  67. package/src/structure/structure.ts +1415 -200
  68. package/src/structure/switch-recover.ts +100 -7
  69. package/src/symbols.ts +127 -6
  70. package/src/target.ts +155 -35
  71. package/src/trace.ts +1 -1
  72. package/src/variation-definitions.ts +1540 -0
  73. package/src/variation-gates.ts +89 -0
  74. package/src/variation-tokens.ts +355 -0
package/src/l3/unmerge.ts CHANGED
@@ -1,5 +1,5 @@
1
- // L3 re-spelling lever: duplicate a join statement back into the arms the compiler merged it out
2
- // of — the dual of l3/tailmerge.ts, and a LEVER where that one is unconditional.
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
3
  //
4
4
  // agbcc cross-jumps a store the source wrote in both arms into the join block, so the lifted CFG
5
5
  // carries the address and the value on merge parameters and SSA destruction mints a temp per
@@ -12,9 +12,25 @@
12
12
  // `*(u16 *)A2 = B2;` inside the other — two whole statements, no temps. Substituting each arm's
13
13
  // own definitions into the join statement and duplicating it back recovers that spelling.
14
14
  //
15
- // A LEVER, NOT A DEFAULT, and the reason is the tower's: the asm UNDERDETERMINES this. A source
16
- // that really did write the temps and one store compiles to the same bytes, because the merge is
17
- // the compiler's own. Both spellings are emitted and the differ referees.
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.
18
34
  //
19
35
  // SOUND BY ITS DUAL'S ARGUMENT, READ BACKWARDS. The join runs on every path out of the `if`,
20
36
  // immediately after that arm's own tail, with nothing between; a copy at the end of each arm runs
@@ -27,10 +43,10 @@
27
43
  // (tailmerge's scope, for its reason: control flow duplicated into an arm changes what the arm
28
44
  // still reaches);
29
45
  // - the join statement reads no local the arms define — there is no merge to undo;
30
- // - a local it reads is neither a merge temp (assigned EXACTLY ONCE IN EACH ARM, read only by
31
- // the join statement, never address-taken — the counts are function-wide, so a second reader
32
- // anywhere refuses) nor untouched by both arms (a name the arms DO write and this cannot
33
- // substitute would read a different value at the arm's end);
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);
34
50
  // - anything but an EFFECT-FREE assignment TO A DECLARED LOCAL stands between the first
35
51
  // definition and the arm's end: the substituted values are evaluated where the copy lands, so
36
52
  // an intervening store or call could answer a load inside one of them differently. All three
@@ -56,7 +72,64 @@
56
72
  // - a definition's value performs a VOLATILE access. `exprHasEffect` above answers "a call, or a
57
73
  // marker" and says nothing about a qualifier, so it is not the test for this: the refusal is
58
74
  // asked of the qualifier's own model (`exprReadsVolatile`), which knows all three spellings —
59
- // the cast, the pointee-volatile pointer local, and the volatile local object.
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.
60
133
  //
61
134
  // AND THE SCOPE OF THE VOLATILE ONE IS WHAT MOVES, which is exactly one thing. A kept statement
62
135
  // holds its position, and the join runs where it already ran (immediately after that arm), so the
@@ -73,7 +146,7 @@
73
146
  // reading `*p` and a kept assignment to an address-taken local can name the same object under two
74
147
  // spellings, and the name-keyed refusal below (an intervening assignment writes a name one of
75
148
  // those values READS) cannot see it. That is one question further out than this pass models —
76
- // every other lever here defers it to the same name-keyed model — and the sweep behind this note
149
+ // every other respell variation here defers it to the same name-keyed model — and the sweep behind this note
77
150
  // found no inhabitant: 0 arms this pass ACCEPTS hold a kept assignment to an address-taken local
78
151
  // after the first definition, the same sweep that found 0 holding one to a global. That sweep's
79
152
  // population was the agbcc rows whose BASE TREE the rig could build, which is not the corpus's
@@ -89,7 +162,8 @@ import {
89
162
  stmtChildren,
90
163
  walkExprs,
91
164
  } from './ast';
92
- import { localMentions, readsOf } from './mentions';
165
+ import { type Gate, firstRejection } from './gates';
166
+ import { localMentions, mentionsAnyLocal, readsOf } from './mentions';
93
167
 
94
168
  /** every statement under `body`, itself included */
95
169
  function* walkStmts(body: Stmt[]): Generator<Stmt> {
@@ -126,12 +200,248 @@ function readsIn(e: Expr): Set<string> {
126
200
  return out;
127
201
  }
128
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
+
129
437
  /** The arm's definitions of `names` and the statements that survive beside them, or null when the
130
- * substituted copy would not evaluate to the same values at the arm's end. */
438
+ * substituted copy would not evaluate to the same values at the arm's end — `UNMERGE_ARM_GATES`
439
+ * carries every reason. */
131
440
  function armDefs(
132
441
  arm: Stmt[],
133
442
  names: ReadonlySet<string>,
134
443
  declared: ReadonlySet<string>,
444
+ gates: readonly Gate<UnmergeArm>[],
135
445
  ): { defs: Map<string, Expr>; keep: Stmt[] } | null {
136
446
  const at = new Map<string, number>();
137
447
  arm.forEach((s, i) => {
@@ -139,34 +449,33 @@ function armDefs(
139
449
  at.set(s.name, i);
140
450
  }
141
451
  });
142
- if (at.size !== names.size) {
143
- return null;
144
- }
145
- const first = Math.min(...at.values());
146
- // From the first definition on, nothing but EFFECT-FREE assignments TO A DECLARED LOCAL: a store
147
- // or a call there would run BEFORE a value this moves to the arm's end, and could answer a load
148
- // inside it differently. Neither the KIND nor the VALUE alone says that. Not the kind — an
149
- // `assign` whose value is a call is an intervening call — so the effect test is applied to the
150
- // kept statements too, not only to the definition values below. And not the value either: an
151
- // `assign` names a VARIABLE, and structure.ts spells a write to a scalar global as one, so
152
- // `gBlendValue = v;` passes both tests and writes memory. `declared` is the tree's own locals
153
- // and params, so the target has to be an object no moved read can reach except by the name the
154
- // refusal below already keys on.
155
- if (arm.slice(first).some((s) => s.k !== 'assign' || !declared.has(s.name) || exprHasEffect(s.value))) {
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) {
156
474
  return null;
157
475
  }
158
- const defs = new Map([...at].map(([n, i]) => [n, (arm[i] as Extract<Stmt, { k: 'assign' }>).value] as const));
159
- const read = new Set([...defs.values()].flatMap((v) => [...readsIn(v)]));
160
- const keep: Stmt[] = [];
161
- for (const [i, s] of arm.entries()) {
162
- if (i >= first && s.k === 'assign' && names.has(s.name)) {
163
- continue; // the definition itself, consumed by the substitution
164
- }
165
- if (i > first && s.k === 'assign' && read.has(s.name)) {
166
- return null; // it would change a value this moves past it
167
- }
168
- keep.push(s);
169
- }
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)));
170
479
  return { defs, keep };
171
480
  }
172
481
 
@@ -176,9 +485,81 @@ function substitute(s: Stmt, defs: ReadonlyMap<string, Expr>): Stmt {
176
485
  return mapStmtExprs(s, rec);
177
486
  }
178
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
+
179
560
  /** The tree with every eligible join statement pushed back into its arms, or null when no site
180
- * qualified — the lever declines rather than re-emitting the primary spelling. */
181
- export function unmergeJoins(sfn: SFn): SFn | null {
561
+ * qualified — the variation declines rather than re-emitting the default spelling. */
562
+ export function unmergeJoins(sfn: SFn, gates: UnmergeGates = {}): SFn | null {
182
563
  const mentions = localMentions(sfn);
183
564
  const localNames = new Set(sfn.locals.map((l) => l.name));
184
565
  // Locals AND params — both name an automatic object, and an assignment to either is the write
@@ -187,42 +568,85 @@ export function unmergeJoins(sfn: SFn): SFn | null {
187
568
  const declaredNames = new Set([...localNames, ...sfn.params.map((p) => p.name)]);
188
569
  const consumed = new Set<string>();
189
570
 
190
- const unmergeAt = (iff: Extract<Stmt, { k: 'if' }>, join: Joinable): Stmt | null => {
191
- if (iff.then.length === 0 || iff.else.length === 0) {
192
- return null;
193
- }
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) => {
194
575
  const read = namesRead(join, localNames);
195
576
  const written = new Set(
196
577
  [...iff.then, ...iff.else].flatMap((s) => [...walkStmts([s])].filter((x) => x.k === 'assign').map((x) => x.name)),
197
578
  );
198
579
  const merge = new Set<string>();
580
+ const unsubstitutable = new Set<string>();
199
581
  for (const n of read) {
200
582
  const m = mentions.get(n);
201
- if (m && m.assigns === 2 && readsOf(m) === 1 && m.addrTaken === 0) {
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) {
202
597
  merge.add(n);
203
598
  } else if (written.has(n)) {
204
- return null; // the arms write it and this cannot substitute it
599
+ unsubstitutable.add(n);
205
600
  }
206
601
  }
207
- if (merge.size === 0) {
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) {
208
618
  return null;
209
619
  }
210
- const then = armDefs(iff.then, merge, declaredNames);
211
- const els = armDefs(iff.else, merge, declaredNames);
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);
212
623
  if (then === null || els === null) {
213
624
  return null;
214
625
  }
215
- for (const v of [...then.defs.values(), ...els.defs.values()]) {
216
- if (exprHasEffect(v) || exprReadsVolatile(v, sfn) || [...readsIn(v)].some((n) => merge.has(n))) {
217
- return null;
218
- }
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;
219
647
  }
220
648
  merge.forEach((n) => consumed.add(n));
221
- return {
222
- ...iff,
223
- then: [...then.keep, substitute(join, then.defs)],
224
- else: [...els.keep, substitute(join, els.defs)],
225
- };
649
+ return totality.out;
226
650
  };
227
651
 
228
652
  const list = (xs: Stmt[]): Stmt[] => {