@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
@@ -36,14 +36,49 @@
36
36
  // folds bottom-up: the innermost diamond becomes a bare condition, which the next diamond consumes as its
37
37
  // Vb, and so on. SCOPE: the shared-arm must be reachable as a single-predecessor `br` feeder; the `||`
38
38
  // form where the const-1 "true" block has TWO predecessors (`return a || b`) is not folded.
39
- // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
40
- // negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
41
- import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
42
- import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
39
+ // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, and the head condition
40
+ // is NEGATABLE whenever the orientation inverts it an icmp by opcode swap or a `logic_and`/`logic_or` by
41
+ // De Morgan, both via `negateCondOps`, which the control-flow form below shares. Any deviation falls
42
+ // through untouched (a miss, never a miscompile).
43
+ import { disjointConstSlots } from '../ir/alias';
44
+ import {
45
+ Block,
46
+ Fn,
47
+ Op,
48
+ Successor,
49
+ Value,
50
+ defOpMap,
51
+ dominators,
52
+ foldWriteOrder,
53
+ forwardingTarget,
54
+ mkOp,
55
+ mkValue,
56
+ predecessors,
57
+ reachableBlocks,
58
+ replaceAllUsesWith,
59
+ successorsOf,
60
+ } from '../ir/core';
61
+ import { EFFECTFUL_OPS, HOIST_UNSAFE_OPS, NEGATED_ICMP, ORDER_SENSITIVE_OPS } from '../ir/opcodes';
43
62
  import { T } from '../ir/types';
63
+ import { type Gate, firstRejection } from '../l3/gates';
44
64
 
45
65
  const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
46
66
 
67
+ /** Run `step` until it stops rewriting, and answer whether it ever did.
68
+ *
69
+ * Both folds in this file are driven the same way and have to be: `defOpMap` and `predecessors`
70
+ * are stale the moment a block is spliced or dropped, so a scan performs at most ONE rewrite and
71
+ * then starts over. `step` is that scan — it recomputes everything it reads, does at most one
72
+ * rewrite, and returns whether it did — which makes the rescan point a function boundary instead
73
+ * of a `break` to a label. */
74
+ function untilFixpoint(step: () => boolean): boolean {
75
+ let changed = false;
76
+ while (step()) {
77
+ changed = true;
78
+ }
79
+ return changed;
80
+ }
81
+
47
82
  /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
48
83
  // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
49
84
  // internal call preserves.
@@ -86,7 +121,6 @@ function recognizeBoolNormalize(fn: Fn): boolean {
86
121
 
87
122
  /** Collapse a simple boolean short-circuit diamond into one `logic_and`/`logic_or`, in place. */
88
123
  export function recognizeShortCircuit(fn: Fn): boolean {
89
- let changed = recognizeBoolNormalize(fn);
90
124
  const term = (b: Block) => b.ops[b.ops.length - 1];
91
125
  const constOf = (defs: Map<Value, Op>, v: Value): number | null => {
92
126
  const d = defs.get(v);
@@ -97,12 +131,15 @@ export function recognizeShortCircuit(fn: Fn): boolean {
97
131
  return !!d && BOOL_OPS.has(d.opcode);
98
132
  };
99
133
 
100
- let progress = true;
101
- while (progress) {
102
- progress = false;
134
+ // TWO STATEMENTS, not `recognizeBoolNormalize(fn) || untilFixpoint(…)`: the normalisation must
135
+ // run BEFORE the fold (it is what turns agbcc's branchless is-nonzero into the `icmp_ne` the
136
+ // diamond's second operand has to be), and `||` would skip the fold whenever it reported a
137
+ // change. Both results are returned, because either one is a change to the IR.
138
+ const normalized = recognizeBoolNormalize(fn);
139
+ const folded = untilFixpoint(() => {
103
140
  const defs = defOpMap(fn);
104
141
  const preds = predecessors(fn);
105
- outer: for (const m of fn.blocks) {
142
+ for (const m of fn.blocks) {
106
143
  if (m.params.length !== 1) {
107
144
  continue;
108
145
  }
@@ -124,9 +161,8 @@ export function recognizeShortCircuit(fn: Fn): boolean {
124
161
  // below: `predecessors()` walks successor edges only, so an entry block that is also a loop
125
162
  // header shows one predecessor while actually running BEFORE it on the first iteration.
126
163
  // Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
127
- // assertResolved and assertDerefsTyped all pass. PRE-EXISTING (this fold predates the branch
128
- // form and `main` miscompiles the same MIPS input); fixed here because the branch form's
129
- // note used to assert this one was safe.
164
+ // assertResolved and assertDerefsTyped all pass. Pinned by 'a feeder that is the entry block
165
+ // is not folded away'; the branch form has the same refusal and its own test.
130
166
  if (bfeed === fn.blocks[0]) {
131
167
  continue;
132
168
  }
@@ -162,10 +198,24 @@ export function recognizeShortCircuit(fn: Fn): boolean {
162
198
  continue;
163
199
  }
164
200
  const cond = ht.operands[0];
165
- const condDef = defs.get(cond);
166
- if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
201
+ // The head condition must be a C BOOLEAN computed here — a negatable comparison or a
202
+ // `logic_and`/`logic_or`, which is exactly `BOOL_OPS` and exactly the set `negateCondOps`
203
+ // accepts at top level, so a fused connective head still folds. Mere def-existence is NOT
204
+ // the test — `call` declares `results: 1` (ir/opcodes.ts), so `defOpMap` maps a call result
205
+ // like any other — because the const/const reduction below hands `cond` ITSELF on as the
206
+ // merge value (`res = condSide`), replacing a phi that was `cond ? 1 : 0`. A non-boolean
207
+ // head there is a silent WRONG VALUE, not a missed fold: `and(6, 3)` emitted where the
208
+ // program yields 1. The branch form carries no such obligation — its result only ever feeds
209
+ // a `cond_br`, which reads truthiness — which is why this gate lives here and not in the
210
+ // shared helper, whose own result is boolean by construction and so covers the negated case.
211
+ //
212
+ // Instrumented over the whole benchmark under BOTH lift configurations, it refuses
213
+ // NOTHING — every head reaching it is a negatable icmp — while the pass folds 6 value-form
214
+ // diamonds per configuration, 3 of them through the const/const reduction. An invariant's
215
+ // guard, not a filter any row depends on.
216
+ if (!isBool(defs, cond)) {
167
217
  continue;
168
- } // head condition must be a negatable icmp
218
+ }
169
219
 
170
220
  // The `cond`-side operand is negated iff `cond` guards the short-circuit (taken+0 / fall+1).
171
221
  const wantNeg = (c === 0 && mIsTaken) || (c === 1 && mIsFall);
@@ -180,11 +230,26 @@ export function recognizeShortCircuit(fn: Fn): boolean {
180
230
  if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
181
231
  continue;
182
232
  }
233
+ // NEGATABLE only when the orientation actually inverts the head — asked here rather than up
234
+ // with the other head checks, so a fused `logic_and`/`logic_or` head (what the branch form
235
+ // below leaves behind, and what an earlier round of THIS pass leaves behind in a chain) is
236
+ // not refused when nothing needs inverting. `negateCondOps` is the branch form's helper too,
237
+ // so both siblings refuse the same three shapes. Its dominance precondition holds here for a
238
+ // different reason than there: `cond` is ^h's OWN terminator operand, so its whole cone
239
+ // dominates the point `before` splices into.
240
+ //
241
+ // Minted below every other refusal and above the first mutation: a site refused for any
242
+ // other reason pays nothing, and a refusal below the hoist would leave ^h half-rewritten.
243
+ const negation = wantNeg ? negateCondOps(defs, cond, NEGATE_BUDGET) : null;
244
+ if (wantNeg && !negation) {
245
+ continue;
246
+ }
183
247
  bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
248
+ foldWriteOrder(fn.writeOrder, bfeed, h); // …and its writes now follow H's (ir/core.ts)
184
249
  let condSide = cond;
185
- if (wantNeg) {
186
- condSide = mkValue(T.unk(32));
187
- before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
250
+ if (negation) {
251
+ negation.ops.forEach((op) => before(op));
252
+ condSide = negation.result;
188
253
  }
189
254
  // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
190
255
  let res = condSide;
@@ -203,13 +268,12 @@ export function recognizeShortCircuit(fn: Fn): boolean {
203
268
  m.params = [];
204
269
  }
205
270
  fn.blocks = fn.blocks.filter((x) => x !== bfeed);
206
- changed = true;
207
- progress = true;
208
- break outer; // defs/preds are stale after mutation — recompute on the next iteration
271
+ return true; // defs/preds are stale after mutation — the driver rescans
209
272
  }
210
273
  }
211
- }
212
- return changed;
274
+ return false;
275
+ });
276
+ return normalized || folded;
213
277
  }
214
278
 
215
279
  // ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
@@ -253,35 +317,235 @@ export function recognizeShortCircuit(fn: Fn): boolean {
253
317
  // not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
254
318
  // `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
255
319
  // disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
256
- // of nested `if`s. The switch is the better recovery and it is the more specific one, so it
257
- // wins the shape. Cost: a genuine source-level `x == 1 || x == 2` that is NOT part of a wider
258
- // tree also declines the same conservative trade loops.ts makes when it refuses to infer a
259
- // header from `cond_br` shape.
320
+ // of nested `if`s. The two spellings are mutually exclusive within one raise and BOTH are
321
+ // legitimate C, and which of them the asm rules out depends on the shape: `x == 0 || x == 2`
322
+ // and `switch (x) { case 0: case 2: }` are ONE object only where the switch has a single case
323
+ // group plus `default:` (agbcc 12 instructions each, one md5; IDO 64 bytes each, one md5), and
324
+ // part as soon as there is a second group to balance a dispatch against (agbcc 20 against 16,
325
+ // IDO 80 bytes and different bytes). So this is a DEFAULT rather than a decision: the switch
326
+ // is the more specific recovery and wins the shape here, while
327
+ // `foldTreeOwned` spells the connective instead and the differ referees (rank.ts's
328
+ // `/connective`). ONLY THIS CLAUSE. Its notion of "same scrutinee" is switch-recover.ts's own
329
+ // PRE1 — a NECESSARY condition for recovery, never a sufficient one, so what it refuses is
330
+ // "a switch could not be ruled out here" and it is a proxy too, just a far tighter one than
331
+ // the relayed clause. Priced over the set it REFUSES rather than the set it admits: it fires on
332
+ // 6 rows, protects 2 (`pokeemerald:IsStringLengthAtLeast`,
333
+ // `pokeemerald:TrySetCantSelectMoveBattleScript`), and on the other 4 the published winner
334
+ // folds THROUGH it — `kleod:CheckWorldCompletion`'s refused site is `v5 == 3 || v5 == 5` on an
335
+ // ordinary inner-loop counter with no dispatch region near it. It is the variation, not the clause,
336
+ // that keeps those 4. A structural discriminator is L1-visible and would be strictly better —
337
+ // is the shared block the entry of a region with dispatch-shaped in-edges, is the scrutinee
338
+ // defined by the enclosing loop header — and is UNBUILT.
339
+ // The relayed clause below is a different statement (see its own note: a blunt proxy that
340
+ // fires on an ordinary loop counter), it has NO inhabitant anywhere in the benchmark, and a
341
+ // candidate born there would carry a `/connective` variation for a fold that answers
342
+ // no connective-vs-tree question. It stays absolute.
343
+ // - the shared block was reached through a RELAY, and either test's scrutinee is compared against
344
+ // constants more than once in the function. This one is ABSOLUTE — `foldTreeOwned` does not
345
+ // widen it. Same reason as the bullet above, widened because the reach is: a relay is what
346
+ // agbcc puts on a tree's default edge, so resolving one walks this fold into a dispatch chain,
347
+ // where the sibling that gives the tree away may be neither test in
348
+ // hand — the split node is RELATIONAL and only its children are equalities, which is precisely
349
+ // what the pairwise test cannot see. Without it `sub_807BD88` and `sub_808491C` each lose a
350
+ // `switch` (sa3).
351
+ //
352
+ // It is BLUNT, and that is why it is held to the relayed case. The count does not distinguish a
353
+ // dispatch chain from any variable tested against constants at two sites: on `sub_8080AD4` it
354
+ // fires on `v2 > 2` and `v2 != 2` — one ordinary loop counter — and refusing there costs the
355
+ // function its whole decompilation, because the only fold it has is the `do…while` exit. Its
356
+ // notion of "same scrutinee" is SSA-value identity, which is switch-recover.ts's own PRE1, so it
357
+ // cannot refuse a tree that recovery could not have taken either — but it is a proxy for "am I
358
+ // inside a dispatch chain", not a test of it. A direct edge onto a relational split node still
359
+ // escapes: `sub_807F334` folds `x > 1 && x == 2` and loses a `switch` on main and here alike.
360
+ // - BOTH of ^g's edges rejoin the shared block, with NEITHER of them landing on it directly. Then
361
+ // there is no "other" arm and nothing decides which side the connective guards. Only resolution
362
+ // creates this — an edge that arrives directly is preferred exactly so the MIPS divide-guard
363
+ // idiom, whose emptied trap block forwards to the same place, keeps folding as it always has
364
+ // (`af:adds:ido7.1` and the `divv`/`gcd`/`modv` rows).
260
365
  // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
261
366
  // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
262
- // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
263
- // MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
264
- // conditional computation into an unconditional one. Single-use-and-local is precisely the
265
- // shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
367
+ // - a value defined in ^g is used more than once inside ^g. Then the structurer MATERIALIZES it
368
+ // into a local, which renders as a statement BEFORE the `if` — turning `b`'s conditional
369
+ // computation into an unconditional one. Single-use-and-local is precisely the shape
370
+ // analysis.ts inlines into the connective's right operand, where C's own short-circuit
266
371
  // re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
372
+ // - a value defined in ^g is ALSO read past ^g, and a rule in `ARM_REREAD_GATES` refuses
373
+ // re-deriving it in the arm. A read past ^g is otherwise not a refusal: on verified IR it can
374
+ // only sit under the arm ^g's non-shared edge enters (^g dominates nothing else — the shared
375
+ // block is reached from ^h too), so the arm RE-DERIVES it (`armRereadCone`) and ^g keeps only
376
+ // its own use. Refusing every such read splits the condition into a nest whose shared block
377
+ // the structurer duplicates into both negative branches — `synthetic:ladder5` and
378
+ // `synthetic:ladidx2`. The two
379
+ // are different evidence: `ladidx2` is `if (a && (p->f & 0x7F) == 0x7F) { p->f &= 0x80; }`,
380
+ // which names `p->f` twice while agbcc reads it once and carries the register into the arm,
381
+ // so its copy is a LOAD; `ladder5`'s arm reuses an ADDRESS its test computed, a
382
+ // pure cone analysis.ts re-derives at every use anyway. Two of the table's rules are sound
383
+ // (the copy must not run on a path the original did not); the rest are fidelity, and say
384
+ // which bytes they protect.
267
385
  // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
268
386
  // so it can only carry one argument list; picking either would silently drop the other path's
269
387
  // phi input.
270
- // - ^g's two successors are the same block, or ^g's condition is not a negatable icmp when the
271
- // orientation needs negating.
388
+ // - ^g's two successors are the same block, or ^g's condition cannot be NEGATED when the
389
+ // orientation needs negating. `negateCondOps` decides that, over two shapes: a negatable
390
+ // `icmp_*` (the swapped opcode) and a `logic_and`/`logic_or` (De Morgan — the dual connective
391
+ // over recursively negated operands). The connective case is what lets a chain fold past its
392
+ // FIRST level: this pass is iterative, so by the time it tries an outer diamond the inner one is
393
+ // already fused and ^g's condition is a connective, which `NEGATED_ICMP` — a table over
394
+ // comparison opcodes — has no entry for. Refuse it and `a || !(b || c)` stops after one level at
395
+ // all 13 sites this fires on (`synthetic:llcmp:agbcc`, `:gcc2.7.2kmc`, and 11 real agbcc
396
+ // functions in klonoa+sa3); on the two benchmark rows the structurer then tail-duplicates the
397
+ // shared return into both arms. The helper's own refusals — no def, a non-negatable leaf
398
+ // ANYWHERE in the cone (no partial De Morgan), a cone over the node budget — are on the helper.
399
+ //
400
+ // De Morgan DUPLICATES leaf comparisons, so a duplicated leaf could gain a second consumer that
401
+ // analysis.ts renders as a statement BEFORE the `if` — the hazard the single-icmp negation
402
+ // always carried. Measured over those 11 BRANCH-form sites: NO site gains a local (one,
403
+ // `sub_80B7CD0`, loses one, 8 → 7) and every site that already decompiled gets SHORTER. Two
404
+ // (`sub_80930B8`, `sub_80932E0`) go from DECLINED to a full decompilation — folding a loop-exit
405
+ // connective removes the back-edge loop recovery was refusing — so they get LONGER (1 → 112 and
406
+ // 1 → 55 lines) and are the only sites whose local count rises at all, 0 → 22 and 0 → 7. A
407
+ // decompilation appearing, not a leaf escaping.
408
+ //
409
+ // That number is SCOPED to this fold and does not carry to the value form, which shares the
410
+ // helper but no use-count condition: `definedValuesStayLocal` (bottom of this file)
411
+ // independently forbids a ^g-defined value with a second consumer HERE, while the value form
412
+ // relies on the original cone dying to the pass list's own `dce: true`. There is nothing to
413
+ // transfer the number to yet either — instrumenting the value form over the whole benchmark
414
+ // under both lift configurations counts 6 folds per configuration, every head a single icmp.
415
+ //
416
+ // The `/connective` LIFT VARIATION is a separate question from the default lift, and is unwidened:
417
+ // `onTreeOwned` below is what tells rank.ts the variation exists for a row, and this check sits ABOVE
418
+ // it. Over the whole benchmark under BOTH configurations rank.ts lifts with (`foldTreeOwned`
419
+ // false and true), against the same rows with the connective case ablated: the recovered IR
420
+ // moves on the same 2 rows under each, `onTreeOwned` fires on the same rows either way, and
421
+ // nothing new throws.
272
422
  //
273
423
  // Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
274
424
  // miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
275
425
  // more condition block.
276
- export function recognizeBranchShortCircuit(fn: Fn): boolean {
277
- let changed = false;
426
+ //
427
+ // WHICH SPELLING, and why the fold alone does not decide it. `if (a && b) X else Y` and its dual
428
+ // `if (!a || !b) Y else X` are the same program and NOT the same bytes — agbcc lays the arms out in
429
+ // source order, so which was written is recorded in the branch senses. This rewrite keeps ^h's
430
+ // unchanged successor slot, so the connective comes out in the orientation those senses spell, and
431
+ // which of the two that is depends on the branch RANGE below, not on the source. Reaching the
432
+ // other is `negateCond`'s job (l3/ast.ts distributes `!(a && b)`), and rank.ts's `/flip-join` variation
433
+ // is what asks for it on a RECONVERGING if — the default spells the layout reading and the variation
434
+ // spells its dual, so both orientations are compiled and the differ picks (synthetic:ifand_near
435
+ // matches at the default, synthetic:ifor_near on the variation).
436
+ //
437
+ // What neither reaches is the MIXED spelling. `negateJoinedBranchSense` is a per-FUNCTION boolean,
438
+ // so the variation negates every joined `if` at once — and of the 28 real rows carrying the
439
+ // `short-circuit` tag, 16 hold two or more TWO-ARMED ifs (counted by `else`, which is what the
440
+ // variation's own `thenS.length && elseS.length` gate needs) and 12 hold two or more conditions
441
+ // carrying a connective. TWO-ARMED is the count that matters: both sense booleans exclude a
442
+ // one-armed `if` by construction, so a tally of `if (` of any kind is the wrong denominator.
443
+ // The per-SITE negation is `/site-sense` (rank-variations.ts), which reads the orientation this fold
444
+ // stamps on the fused branch (`scSharedOnFall`, below) instead of the per-function boolean. A gate
445
+ // on whether to ENUMERATE the variation does not reach the mixed spelling and removes one the differ
446
+ // would referee.
447
+ //
448
+ // The De Morgan negation below forecloses a third spelling, at a measured price: it DISTRIBUTES, so
449
+ // the leaves come out negated (`a || (!b && !c)`) and `a || !(b || c)` has no
450
+ // candidate — the IR has no `logic_not` to build one from (ir/opcodes.ts). Compiled both ways on
451
+ // the `a || (b && c)` guard shape at agbcc's default flags, the two source spellings assemble to
452
+ // the same bytes (12/12 rows, score 0), so the foreclosure costs nothing here. A shape that ever
453
+ // separated them would be a new variation, not a bug in this fold.
454
+ //
455
+ // WHICH slot ^g lands in is decided by the asm's branch POLARITY, and on Thumb the branch RANGE
456
+ // decides the polarity — so the same source `&&` reaches this pass two different ways:
457
+ //
458
+ // short branch `beq shared` ^g is ^h's FALL → logic_or
459
+ // long branch `bne ^g / b shared` ^g is ^h's TAKEN → logic_and
460
+ //
461
+ // agbcc inverts a conditional it cannot reach, so past ±256 bytes it emits the second form, and the
462
+ // trampoline it leaves on the `b` sits on the edge into the SHARED block — which `forwardingTarget`
463
+ // (ir/core.ts) looks through. Only that edge needs it: the INVERTED branch is the one that still
464
+ // reaches, so `bne ^g` always arrives at ^g directly and no relay can sit between them.
465
+ //
466
+ // SO THE CONNECTIVE THIS FOLD MINTS IS THE RANGE'S, NEVER THE SOURCE'S: both `synthetic:ifand_near`
467
+ // (source `&&`) and `synthetic:ifor_near` (source `||`) are short-branch rows and both come out
468
+ // `logic_or`. Which SPELLING then wins is the joined-sense default's question, one layer up, and
469
+ // BOTH halves have a dual candidate there — `ifand_near:agbcc` matches at the default (`unsigned`),
470
+ // while `ifor_near:agbcc` and the long-branch `ifand_far:agbcc` each match on `/flip-join`.
471
+ //
472
+ // `gIsFall` IS NOT THE CARRIER FOR A PER-SITE SENSE, and that was measured rather than argued. It
473
+ // reads the branch RANGE, exactly as the table above says — so in any function small enough for
474
+ // every branch to be short it is the SAME at every site, including sites whose sources wrote
475
+ // opposite connectives. Instrumented at the fold and run through the bench: a two-site row whose
476
+ // first site wrote `&&` and whose second wrote its dual reads `gIsFall=true` at BOTH
477
+ // (`synthetic:joinsense`), and a four-site ladder row with two sites inverted in the source reads
478
+ // `true` at all four (`synthetic:mixsense`). The positive control reads the other way —
479
+ // `synthetic:ifand_far`, the long-branch row, gives `false` against
480
+ // `synthetic:ifand_near`'s `true`. So carrying this boolean to L3
481
+ // as a node stamp (the `#144` `Expr.baseOrdered` shape) would hand every site of such a function
482
+ // one answer and reach exactly the two configurations `negateJoinedBranchSense` already reaches.
483
+ //
484
+ // WHAT IS THE CARRIER is the OTHER boolean this loop computes — whether the SHARED block was
485
+ // reached from ^g by its branch or by falling into it — and the fold stamps it as
486
+ // `scSharedOnFall`. It separates every site the sources above wrote as a dual from every site
487
+ // they did not, at constant branch range, because it reads the source's connective rather than
488
+ // the range: an `&&` sends every failing test AWAY to the shared block, an `||` falls into it.
489
+ //
490
+ // IT IS HALF THE CARRIER. Which SOURCE arm the shared block is only names the spelling once you
491
+ // also know which SUCCESSOR SLOT it lands in here, and the slot is `gIsFall` — stamped beside it
492
+ // as `scSharedIsTaken`. `gIsFall` false puts the shared arm in the FALL slot, which happens at the
493
+ // long-branch `&&` AND at every CHAINED fold, since an inner fold leaves the head's taken edge
494
+ // pointing at the next test. There the source's `then` is in the taken slot however ^g reached the
495
+ // shared block, so BOTH values of `scSharedOnFall` read POSITIVE — measured, not derived: the two
496
+ // inhabited layouts stamp opposite `scSharedOnFall` (`synthetic:chainsense` false,
497
+ // `synthetic:ifand_far` true) and want the same spelling, because the long branch is exactly the
498
+ // layout that falsifies the source-order premise ON that stamp.
499
+ //
500
+ // AND THAT IS STILL NOT ALL OF IT, because the long branch INVERTS the last test and so moves the
501
+ // shared arm between slots: a long `||` puts it back in the TAKEN slot and stamps the pair a short
502
+ // `&&` stamps (`synthetic:ifor_far` against `synthetic:ifand_near`, measured from their own asm),
503
+ // while wanting the opposite spelling. The third stamp is `scEdgeRelayed`, the trampoline the
504
+ // inversion leaves on one of the two edges — see its own note at the stamp. The table the consumer
505
+ // reads is `structure.ts`'s `senseFromFoldEvidence` site default, and one of its cells is
506
+ // undecided by all three facts (`kleod:CheckWorldCompletion:agbcc`, two sites, opposite senses).
507
+ //
508
+ // Every refusal falls through untouched — a miss, never a miscompile.
509
+ /** Per-call options for `recognizeBranchShortCircuit` — the tree-ownership refusal's two ends. */
510
+ export interface BranchShortCircuitOptions {
511
+ /** Take the fold at a site the PAIRWISE comparison-tree refusal owns, spelling the connective
512
+ * where the default leaves the tree for switch-recover.ts. rank.ts's `/connective` variation; see the
513
+ * REFUSALS note. It widens the SHAPE the fold accepts and nothing about what the fold may move —
514
+ * every other refusal still applies, the RELAYED clause included.
515
+ *
516
+ * A NEW REFUSAL CONDITION, stated because it is one: this is per-FUNCTION and the question is
517
+ * per-SITE. Every tree-owned site in a function flips together, so a function with two of them
518
+ * wanting OPPOSITE spellings has no candidate that spells the mix, and nothing reports the gap.
519
+ * A per-FUNCTION predicate cannot decide a per-SITE question — the same shape the joined-if
520
+ * default hit — and here it costs completeness rather than correctness. The alternative is a
521
+ * fork per site: `kleod:CheckWorldCompletion` refuses at 10 and goes 96 → 192 candidates as one
522
+ * boolean, where a per-site fork would be 1024×. That is why the boolean, not an oversight. */
523
+ foldTreeOwned?: boolean;
524
+ /** Called at each site the pairwise tree-ownership refusal is the ONE thing stopping the fold —
525
+ * how rank.ts learns the variation has an inhabitant here without re-running the matcher. Asked LAST,
526
+ * after `sameArgs` and the negatability check, so a report means a `/connective` candidate that
527
+ * differs from its sibling: reporting a refusal merely REACHED would double the row's whole
528
+ * candidate cross for a lift that produces duplicates the dedup collapses. (Its sibling gate
529
+ * `hasSetupArgsNarrowing` asks the same question the same way — does the variation CHANGE anything.)
530
+ * The pass re-scans after every rewrite, so one site can report more than once; read it as a
531
+ * boolean. */
532
+ onTreeOwned?: () => void;
533
+ /** The RE-READ admission's refusals — {@link ARM_REREAD_GATES} when absent. A parameter so a
534
+ * census can hand in `tallying(ARM_REREAD_GATES).gates` and a test can ablate one rule; the
535
+ * census does, through `bench gates --pass arm-reread` (apps/benchmark/src/run/gate-census.ts). */
536
+ armReread?: readonly Gate<ArmRereadSite>[];
537
+ /** The TARGET's `compilerBehaviors.reloadsLocalReread` (target.ts), which one conjunct of one rule
538
+ * in {@link ARM_REREAD_GATES} reads — `read-behind-effect`. Threaded by raise/pre-recovery.ts from
539
+ * the target it already holds, the way `narrowlocal`'s `hoistsSingleSetArm` is. Absent ⇒ false. */
540
+ reloadsLocalReread?: boolean;
541
+ }
542
+
543
+ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOptions = {}): boolean {
278
544
  const term = (b: Block) => b.ops[b.ops.length - 1];
279
- let progress = true;
280
- while (progress) {
281
- progress = false;
545
+ return untilFixpoint(() => {
282
546
  const defs = defOpMap(fn);
283
547
  const preds = predecessors(fn);
284
- outer: for (const h of fn.blocks) {
548
+ for (const h of fn.blocks) {
285
549
  const ht = term(h);
286
550
  if (ht.opcode !== 'cond_br') {
287
551
  continue;
@@ -312,29 +576,39 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
312
576
  if (gTaken.block === gFall.block) {
313
577
  continue;
314
578
  }
315
- // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold.
316
- if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
317
- continue;
318
- }
319
- // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
- // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
- // short circuit.
579
+ // ^g's body must be pure see the REFUSALS note.
322
580
  // HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
323
581
  // out of the arm that guards it is the reordering this refuses. Loud either way today — a
324
582
  // decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
325
- const body = g.ops.slice(0, -1);
326
- if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
583
+ if (g.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
327
584
  continue;
328
585
  }
329
- if (!definedValuesStayLocal(fn, g)) {
586
+ // Which of ^g's edges rejoins ^h's other successor? That is the shared block. A DIRECT edge
587
+ // wins, so resolution only ever adds reach — it never re-picks an edge this fold already had.
588
+ const sharedTarget = forwardingTarget(sharedFromH.block);
589
+ const rejoins = (e: Successor): boolean => forwardingTarget(e.block) === sharedTarget;
590
+ const direct = gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
591
+ // With neither edge direct, both may resolve onto the shared block — and then there is no
592
+ // "other" arm left and nothing decides which side the connective guards.
593
+ if (direct === null && rejoins(gTaken) && rejoins(gFall)) {
330
594
  continue;
331
595
  }
332
- // Which of ^g's edges rejoins ^h's other successor? That is the shared block.
333
- const sharedEdge =
334
- gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
596
+ const sharedEdge = direct ?? (rejoins(gTaken) ? gTaken : rejoins(gFall) ? gFall : null);
335
597
  if (!sharedEdge) {
336
598
  continue;
337
599
  }
600
+ // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold. A relay
601
+ // is what agbcc puts on a tree's default edge, so a shared block reached through one is
602
+ // searched function-wide; a direct edge keeps the pairwise test. See the REFUSALS note —
603
+ // the wider test is blunt, and that is why it is not asked everywhere.
604
+ const throughRelay = sharedEdge.block !== sharedFromH.block;
605
+ if (
606
+ throughRelay &&
607
+ (inComparisonTree(fn, defs, ht.operands[0]) || inComparisonTree(fn, defs, gt.operands[0]))
608
+ ) {
609
+ continue; // the relayed clause is absolute — `foldTreeOwned` does not widen it
610
+ }
611
+ const treeOwned = !throughRelay && sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0]);
338
612
  const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
339
613
  if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
340
614
  continue;
@@ -343,16 +617,40 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
343
617
  // reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
344
618
  const wantEdge = gIsFall ? sharedEdge : otherEdge;
345
619
  const c2 = gt.operands[0];
346
- const c2Def = defs.get(c2);
347
- let second = c2;
348
- const negated: Op[] = [];
349
- if (wantEdge !== gTaken) {
350
- if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
620
+ // ^g's condition may itself be a CONNECTIVE — the fold is iterative, so an inner diamond is
621
+ // already fused by the time the outer one is tried, and negating one is De Morgan rather
622
+ // than an opcode swap. `negateCondOps` does both, and returns null on anything else.
623
+ const negation = wantEdge !== gTaken ? negateCondOps(defs, c2, NEGATE_BUDGET) : null;
624
+ if (wantEdge !== gTaken && !negation) {
625
+ continue;
626
+ }
627
+ // Every value ^g defines is consumed only by ^g itself, once, or re-derived in the arm —
628
+ // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of
629
+ // the short circuit. Asked AFTER `sameArgs` and the negatability check, so a census of
630
+ // `ARM_REREAD_GATES` counts only sites nothing cheaper refuses — above `sameArgs` it would
631
+ // credit `read-behind-effect` with void functions whose shared edges carry different dead
632
+ // `r0` values. The verdict is a conjunction of pure tests, so the order moves no fold. And
633
+ // BEFORE tree ownership, so `onTreeOwned` keeps its meaning — the one thing in the way.
634
+ const reread = armRereadCone(fn, g, otherEdge.block, preds, opts);
635
+ if (!reread || !definedValuesStayLocal(fn, g, reread)) {
636
+ continue;
637
+ }
638
+ // LAST refusal, so `onTreeOwned` reports a site where tree ownership is the ONE thing in the
639
+ // way — which is why the negatability check stays above it even though it MINTS the negated
640
+ // cone (up to NEGATE_BUDGET ops) and discards it whenever this gate refuses. That discard is
641
+ // free rather than merely cheap: `mkValue` is `{ type }` with no identity counter
642
+ // (ir/core.ts) and nothing is spliced until below, so a site refused here leaves the CFG
643
+ // byte-identical. See the option's doc for why the position is the gate's meaning.
644
+ if (treeOwned) {
645
+ opts.onTreeOwned?.();
646
+ if (!opts.foldTreeOwned) {
351
647
  continue;
352
648
  }
353
- second = mkValue(T.unk(32));
354
- negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
355
649
  }
650
+ rereadInArm(fn, g, otherEdge.block, reread);
651
+ const body = g.ops.slice(0, -1);
652
+ const second = negation ? negation.result : c2;
653
+ const negated: Op[] = negation ? negation.ops : [];
356
654
  const res = mkValue(T.unk(32));
357
655
  const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
358
656
  operands: [ht.operands[0], second],
@@ -361,7 +659,69 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
361
659
  // ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
362
660
  // (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
363
661
  h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
364
- ...mkOp('cond_br', { operands: [res] }),
662
+ // THE ORIENTATION EVIDENCE, kept because only this pass can see it. `scSharedOnFall`
663
+ // says the SHARED block — the arm both tests reach — was reached from ^g by
664
+ // FALL-THROUGH rather than by ^g's branch. gcc lays a condition's arms out in source
665
+ // order, so an `&&` sends every failing test AWAY to the shared block (the source's
666
+ // `else`) while an `||` lets the last test FALL INTO it (the source's `then`) — which is
667
+ // the one thing that separates `if (a && b) X else Y` from its dual `if (!a || !b) Y
668
+ // else X`, two different objects that fold to the same connective and the same successor
669
+ // slots. Consumed at L3 by `StructureOptions.senseFromFoldEvidence`.
670
+ //
671
+ // `gIsFall` is NOT that separator and is stamped for a different reason. It reads the
672
+ // branch RANGE: over `synthetic:joinsense` (two sites, opposite source connectives) and
673
+ // `synthetic:mixsense` (four sites, two inverted) it is TRUE at every site of both,
674
+ // where `scSharedOnFall` separates them. What it is, is the SLOT — the successor order
675
+ // below is `gIsFall`'s — so `scSharedIsTaken` says where the shared arm went and
676
+ // `scSharedOnFall` says which source arm it is. The shared arm in the FALL slot means the
677
+ // taken slot holds the source's `then` whatever `scSharedOnFall` reads, so only the TAKEN
678
+ // quadrants can negate; `structure.ts` owns that table and the layout premise under it.
679
+ //
680
+ // `scEdgeRelayed` — the THIRD fact, and the only one of the three that is neither the
681
+ // source's connective nor a slot. It says a long-branch TRAMPOLINE sat on one of this
682
+ // fold's two edges: agbcc inverts a conditional it cannot reach in ±256 bytes and leaves
683
+ // a `br`-only block behind, which is exactly the inversion that breaks `scSharedOnFall`'s
684
+ // source-order premise. Measured on the four orientation rows, lifted from their own
685
+ // compiled asm (`ONLY=synthetic:<row>:agbcc` through the probe in the round's ledger):
686
+ //
687
+ // row source onFall isTaken relayed /site-sense spells
688
+ // ifand_near a && b false true false a && b ✅
689
+ // ifor_near a || b true true false a || b ✅
690
+ // ifand_far a && b true false true a && b ✅
691
+ // ifor_far a || b false true TRUE its DUAL ❌ without this stamp
692
+ //
693
+ // `ifand_near` and `ifor_far` stamp the IDENTICAL pair and want OPPOSITE spellings, so no
694
+ // two of these booleans can decide the site. The relay is what separates them, and
695
+ // `synthetic:ifor_far` is the only row whose spelling it decides — the row exists for this
696
+ // stamp, because its SCORE cannot referee it (MATCH 0/139 on `/flip-join`, like
697
+ // `ifand_far`'s 0/140).
698
+ //
699
+ // A PROXY, and named as one: what decides the spelling is the branch INVERSION, and what
700
+ // is observable here is the trampoline the inversion leaves. They coincide on every input
701
+ // measured, and a relay arriving from some other cause at a taken-slot site would read as
702
+ // an inversion that did not happen. The `&&` long branch relays the SHARED edge and the
703
+ // `||` long branch the OTHER one — measured on `ifand_far` and `ifor_far`, and both
704
+ // layouts pinned in `test/branch-shortcircuit.test.ts` — which is why both are asked.
705
+ //
706
+ // POSITIONAL, unlike its two neighbours: `scSharedOnFall` and `scEdgeRelayed` name arms
707
+ // and survive anything, while `scSharedIsTaken` names a SUCCESSOR SLOT of the very op it
708
+ // rides on. THE INVARIANT IT NEEDS IS NOT "nothing assigns `.successors`" — something
709
+ // does. It is that no pass between here and `structure` may REORDER a stamped `cond_br`'s
710
+ // successor slots. Downstream of this fold `raiseRecovered` (pipeline.ts) runs
711
+ // `sinkReturns` and `foldEmptyLatches`; `raise/latch.ts` holds the one successor rewrite in
712
+ // `packages/core/src` (grep `successors[i] =` / `successors.push|splice`) —
713
+ // `op.successors[i] = { block: onward.block, … }`, which repoints a slot IN PLACE at its
714
+ // own index and therefore keeps the stamp true. `sinkReturns` only replaces `br`
715
+ // terminators. A pass that canonicalised a stamped `cond_br`'s edges would turn the
716
+ // reading over with no refusal firing, and would have to re-stamp.
717
+ ...mkOp('cond_br', {
718
+ operands: [res],
719
+ attrs: {
720
+ scSharedOnFall: sharedEdge === gFall,
721
+ scSharedIsTaken: gIsFall,
722
+ scEdgeRelayed: throughRelay || forwardingTarget(otherEdge.block) !== otherEdge.block,
723
+ },
724
+ }),
365
725
  successors: gIsFall
366
726
  ? [
367
727
  { block: sharedEdge.block, args: [...sharedEdge.args] },
@@ -372,63 +732,664 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
372
732
  { block: sharedEdge.block, args: [...sharedEdge.args] },
373
733
  ],
374
734
  });
735
+ foldWriteOrder(fn.writeOrder, g, h); // ^g's writes now follow ^h's own (ir/core.ts)
375
736
  fn.blocks = fn.blocks.filter((x) => x !== g);
376
- changed = true;
377
- progress = true;
378
- break outer; // defs/preds are stale after the mutation recompute on the next round
737
+ // ^h's old shared edge is gone, so the relay it pointed at may have become unreachable —
738
+ // and once that link goes, so may the next, all the way down the chain. `dominators`
739
+ // (ir/core.ts) gives a block with no in-edges only ITSELF, which empties the intersection at
740
+ // everything it still branches to, so a half-dropped chain declines two passes later with a
741
+ // def-does-not-dominate-use out of `verify`.
742
+ //
743
+ // REACHABILITY, not predecessor count: in-edges from blocks that are themselves unreachable
744
+ // leave a block just as orphaned, and the thumb frontend does hand over unreachable blocks
745
+ // (see raise/gvn.ts). Only relays are dropped — the chain ends at the first block that does
746
+ // real work, and that one stays whatever its in-edges look like.
747
+ //
748
+ // The relay test below is `isBodyless` (ir/core.ts) MINUS its parameter clause, and the
749
+ // omission is what the reachability guard above buys: an unreachable block binds nothing, so
750
+ // a param on one says nothing about a live edge, while refusing it would leave the chain
751
+ // HALF-DROPPED — and a half-dropped chain fails `verify` two passes later with a
752
+ // def-does-not-dominate-use. `isBodyless` itself is unchanged and right for its own three
753
+ // callers, every one of which asks about a block that is still reached.
754
+ for (let link = sharedFromH.block; link.ops.length === 1 && link.ops[0].opcode === 'br';) {
755
+ const dead = link;
756
+ if (dead === fn.blocks[0] || reachableBlocks(fn).has(dead)) {
757
+ break;
758
+ }
759
+ link = dead.ops[0].successors[0].block;
760
+ fn.blocks = fn.blocks.filter((x) => x !== dead);
761
+ }
762
+ return true; // defs/preds are stale after the mutation — the driver rescans
379
763
  }
380
764
  }
381
- }
382
- return changed;
765
+ return false;
766
+ });
383
767
  }
384
768
 
385
- /** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
386
- * comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
387
- * only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
388
- * range check) is a genuine connective this fold should still take. */
769
+ /** How many ops `negateCondOps` may KEEP for one negation. De Morgan rebuilds the cone PER PATH and
770
+ * shares nothing deliberately, see the helper's note so a fold's cost is linear in the cone's
771
+ * nodes and this is the cheap stop on it.
772
+ *
773
+ * What 8 decides, said as the shape rather than as headroom, because "8" reads like more room than
774
+ * it is. A negation mints one op per cone node and a binary expansion has leaves = internal + 1, so
775
+ * a minted count is always ODD: 1, 3, 5, 7, 9. At 8 a FOUR-clause inner conjunct (7 ops) folds and
776
+ * a FIVE-clause one (9 ops) does not; 7 and 8 are therefore one gate, and so is 9 over everything
777
+ * measured here, the deepest cone in the 2,047 lifted klonoa+sa3 functions minting 5 (17
778
+ * connective negations, 0 refused). Clause COUNT is not the measure either: a FLAT `a || b || c || …`
779
+ * chain pays nothing at all, because ^g's condition is never a connective in that shape.
780
+ *
781
+ * The bound is on ops KEPT, and the frontier is a NODE COUNT — not a shape. Pinned as such rather
782
+ * than argued: branch-shortcircuit.test.ts's "the fold's frontier is the cone's NODE COUNT, not its
783
+ * shape" enumerates all 23,714 binary cone shapes with up to 10 internal nodes (21 nodes) and
784
+ * asserts the PUBLIC fold takes one exactly when `2k + 1 <= budget`.
785
+ * Shape decides only WHICH guard refuses and how many ops had been
786
+ * minted when it did, neither of them visible to a caller — at budget 8 a 15-node cone is caught by
787
+ * the ENTRY guard at 9 (left chain) or 8 (balanced cone) or by the POST-check at 15 (right chain).
788
+ * Because `go` pushes a parent only after its children, a refused walk transiently mints more than
789
+ * the budget before unwinding — `2 * budget - 1` at the worst shape, that right chain, at every
790
+ * budget measured. Bounded, and nothing escapes it.
791
+ *
792
+ * A refusal here is SILENT, unlike the `onTreeOwned` gate above which exists so a sweep need not
793
+ * re-instrument. So raising this constant is not free advice: finding a corpus site that wants it
794
+ * means patching a hook back into this file. No callback is added because no consumer has asked for
795
+ * one; test/branch-shortcircuit.test.ts pins both sides of the frontier instead. */
796
+ const NEGATE_BUDGET = 8;
797
+
798
+ /** The ops computing `!v`, or `null` when `v` cannot be negated.
799
+ *
800
+ * Two cases, and no third:
801
+ * - an `icmp_*` in `NEGATED_ICMP` → the swapped-opcode comparison over the SAME operands.
802
+ * - a `logic_and`/`logic_or` → the DUAL connective over its two recursively negated operands.
803
+ * De Morgan: `!(a || b)` is `!a && !b`. This is the only reason the helper is recursive, and
804
+ * it is what lets a chain fold past its first level (`a || !(b || c)`).
805
+ *
806
+ * REFUSALS — each returns `null`, which the caller turns into its existing `continue`, so the CFG
807
+ * is left exactly as it was and the structurer emits today's tail-duplicated spelling. A bytes
808
+ * miss, never a wrong answer:
809
+ * - the value has no def in this function, which means a block param — a call RESULT has one,
810
+ * `call` declaring `results: 1` (ir/opcodes.ts), and is refused by the next bullet instead;
811
+ * - the def is neither a negatable icmp nor a connective (a call, any arithmetic) — there is no
812
+ * sound inverse to build, and `!x` as `x == 0` is a DIFFERENT spelling, not this fold's
813
+ * business;
814
+ * - ANY leaf anywhere in the cone is non-negatable ⇒ the WHOLE negation is refused. Half a
815
+ * De Morgan is not a conservative approximation of one;
816
+ * - the cone exceeds `NEGATE_BUDGET` minted ops.
817
+ *
818
+ * Ops are MINTED, never mutated: the originals stay where the caller hoisted them and die to the
819
+ * pass list's own `dce: true` (raise/pre-recovery.ts). Only `icmp_*`/`logic_and`/`logic_or` are
820
+ * ever rebuilt, all of them pure, so no effect can be duplicated or reordered by construction.
821
+ *
822
+ * Nothing is SHARED between paths, and that is the point rather than a limitation. Memoizing `go`
823
+ * in a `Map<Value, Value>` is three lines and would make the budget unnecessary — and it would
824
+ * create exactly the hazard this fold exists to remove: a shared negated sub-condition has two
825
+ * consumers, and analysis.ts renders a value with two consumers as a statement BEFORE the `if`.
826
+ * Nothing collapses the duplicates later either — `numberPureValues` runs as `addrnum`, far ahead
827
+ * of both folds. So the per-path rebuild is the mechanism and NEGATE_BUDGET is its price.
828
+ *
829
+ * What it GUARANTEES about its result, which one caller leans on: every op it mints is an `icmp_*`
830
+ * or a `logic_and`/`logic_or`, so the returned value is always a C boolean. The value form hands an
831
+ * un-negated head straight on as the merge VALUE, which is why that caller checks booleanness for
832
+ * itself before deciding whether to call here at all (see its head gate); the branch form needs no
833
+ * such check, because its result only ever feeds a `cond_br`.
834
+ *
835
+ * PRECONDITION, earned by the caller and not checked here: every value in the cone must dominate
836
+ * the point the caller splices `ops` into — the minted comparisons reuse the ORIGINAL leaf
837
+ * operands. The branch fold gets it free from ^g's single-predecessor gate: with ^h ^g's only
838
+ * predecessor, every def in the cone either sits in ^g's body (spliced in ahead of these ops) or
839
+ * dominates ^h. A caller without that invariant emits a def that does not dominate its use — loud
840
+ * at `verify`, but this helper does not look.
841
+ *
842
+ * It stays in this file rather than joining `NEGATED_ICMP` in ir/opcodes.ts: that table is a fact
843
+ * about opcodes, this MINTS ops, and both consumers are the two folds above — the value form and
844
+ * the branch form, which is the whole reason it is a helper and not inline. */
845
+ function negateCondOps(defs: Map<Value, Op>, v: Value, budget: number): { ops: Op[]; result: Value } | null {
846
+ const ops: Op[] = [];
847
+ const go = (x: Value): Value | null => {
848
+ if (ops.length >= budget) {
849
+ return null;
850
+ }
851
+ const d = defs.get(x);
852
+ if (!d) {
853
+ return null;
854
+ }
855
+ const out = mkValue(T.unk(32));
856
+ if (NEGATED_ICMP[d.opcode]) {
857
+ ops.push(mkOp(NEGATED_ICMP[d.opcode], { operands: [...d.operands], results: [out] }));
858
+ return out;
859
+ }
860
+ if (d.opcode === 'logic_and' || d.opcode === 'logic_or') {
861
+ const a = go(d.operands[0]);
862
+ if (a === null) {
863
+ return null;
864
+ }
865
+ const b = go(d.operands[1]);
866
+ if (b === null) {
867
+ return null;
868
+ }
869
+ // Operands are pushed BEFORE the connective, so the op list is already in dominating order.
870
+ ops.push(mkOp(d.opcode === 'logic_and' ? 'logic_or' : 'logic_and', { operands: [a, b], results: [out] }));
871
+ return out;
872
+ }
873
+ return null;
874
+ };
875
+ const result = go(v);
876
+ return result === null || ops.length > budget ? null : { ops, result };
877
+ }
878
+
879
+ /** Do `c1` and `c2` compare the SAME value against CONSTANTS? The signature of a comparison-tree
880
+ * `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests only: a switch
881
+ * dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the range check) is a
882
+ * genuine connective this fold should still take. */
389
883
  function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
390
- const eqTest = (v: Value): { scrutinee: Value } | null => {
391
- const d = defs.get(v);
392
- if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
884
+ const s1 = constTestScrutinee(defs, c1);
885
+ const isEq = (v: Value): boolean => {
886
+ const op = defs.get(v)?.opcode;
887
+ return op === 'icmp_eq' || op === 'icmp_ne';
888
+ };
889
+ return s1 !== null && s1 === constTestScrutinee(defs, c2) && isEq(c1) && isEq(c2);
890
+ }
891
+
892
+ /** Is `c`'s scrutinee compared against constants by MORE THAN ONE `cond_br` in the function?
893
+ *
894
+ * The function-wide question, for the case where the shared block was reached through a relay. A
895
+ * tree's split node is RELATIONAL and its children are equalities (`if (x > 10) { if (x == 20) }`),
896
+ * so the two tests in hand need not look alike, and the one that would give the tree away may be
897
+ * neither of them. Counting every constant test on the scrutinee catches the split either way. */
898
+ function inComparisonTree(fn: Fn, defs: Map<Value, Op>, c: Value): boolean {
899
+ const scrutinee = constTestScrutinee(defs, c);
900
+ if (scrutinee === null) {
901
+ return false;
902
+ }
903
+ let seen = 0;
904
+ for (const b of fn.blocks) {
905
+ const t = b.ops[b.ops.length - 1];
906
+ if (t?.opcode === 'cond_br' && constTestScrutinee(defs, t.operands[0]) === scrutinee && ++seen > 1) {
907
+ return true;
908
+ }
909
+ }
910
+ return false;
911
+ }
912
+
913
+ /** The value an `icmp_* <value>, <const>` tests, or null when `c` is not one. */
914
+ function constTestScrutinee(defs: Map<Value, Op>, c: Value): Value | null {
915
+ const d = defs.get(c);
916
+ if (!d || NEGATED_ICMP[d.opcode] === undefined) {
917
+ return null;
918
+ }
919
+ const [x, y] = d.operands;
920
+ const xc = defs.get(x)?.opcode === 'const';
921
+ const yc = defs.get(y)?.opcode === 'const';
922
+ // exactly one side constant — `x == y` between two variables is no switch test
923
+ return xc === yc ? null : xc ? y : x;
924
+ }
925
+
926
+ /** `armRereadCone`'s answer: the ops the arm copies, and the originals that then leave ^g. */
927
+ interface ArmReread {
928
+ copy: ReadonlySet<Op>;
929
+ drop: ReadonlySet<Op>;
930
+ }
931
+
932
+ const readsOf = (op: Op): Value[] => [...op.operands, ...op.successors.flatMap((s) => s.args)];
933
+
934
+ /** What the arm must RE-DERIVE for the fold to go ahead: `copy` is every `g`-defined value read
935
+ * outside `g` with the part of its operand cone `g` computes, and `drop` the originals in `copy`
936
+ * nothing left in `g` reads — the ones that would MOVE rather than duplicate. Both empty when
937
+ * nothing escapes — then no gate is asked, because there is nothing to re-derive; null when a
938
+ * gate in `opts.armReread` (default {@link ARM_REREAD_GATES}) refuses the site.
939
+ *
940
+ * The copy is exact at the arm's head because nothing runs between: `g`'s body is pure (its
941
+ * caller refuses anything in HOIST_UNSAFE_OPS), so a load copied there reads what `g`'s own load
942
+ * read, and `g` is the arm's ONLY predecessor, so the copy runs exactly when the original's
943
+ * value would have been live there. On verified IR that also places every escaping read under
944
+ * the arm: `g` dominates nothing else, because the shared block is reached from ^h directly.
945
+ * That is the MEANING half, and it holds however analysis.ts spells the copy. It is not the bytes
946
+ * half — see ARM_REREAD_GATES' `read-behind-effect`. */
947
+ function armRereadCone(
948
+ fn: Fn,
949
+ g: Block,
950
+ arm: Block,
951
+ preds: Map<Block, Block[]>,
952
+ opts: BranchShortCircuitOptions,
953
+ ): ArmReread | null {
954
+ const defOf = new Map<Value, Op>();
955
+ for (const op of g.ops) {
956
+ for (const r of op.results) {
957
+ defOf.set(r, op);
958
+ }
959
+ }
960
+ const readers = fn.blocks
961
+ .filter((b) => b !== g)
962
+ .flatMap((b) => b.ops.filter((op) => readsOf(op).some((v) => defOf.has(v))));
963
+ if (readers.length === 0) {
964
+ return { copy: new Set(), drop: new Set() };
965
+ }
966
+ const site = armRereadSite(fn, g, arm, preds, defOf, readers, opts.reloadsLocalReread === true);
967
+ return firstRejection(opts.armReread ?? ARM_REREAD_GATES, site) === null
968
+ ? { copy: site.copy, drop: site.drop }
969
+ : null;
970
+ }
971
+
972
+ /** One site the RE-READ admission judges: a condition block ^g that defines a value something past
973
+ * it reads, and the arm ^g's non-shared edge enters — where that value would be re-derived.
974
+ *
975
+ * THE FIELDS ARE LAZY. `copy` walks the operand cone and `drop` the whole of ^g, and the two
976
+ * cheapest gates refuse without asking either. */
977
+ export interface ArmRereadSite {
978
+ readonly fn: Fn;
979
+ /** ^g, the second condition block the fold would merge into ^h */
980
+ readonly g: Block;
981
+ /** the block ^g's non-shared edge enters — where the copy goes */
982
+ readonly arm: Block;
983
+ readonly preds: ReadonlyMap<Block, readonly Block[]>;
984
+ /** the op in ^g defining each value ^g defines */
985
+ readonly defOf: ReadonlyMap<Value, Op>;
986
+ /** every op outside ^g that reads a value ^g defines — never empty (see `armRereadCone`) */
987
+ readonly readers: readonly Op[];
988
+ /** those values and the part of their operand cone ^g computes: what the arm would copy */
989
+ readonly copy: ReadonlySet<Op>;
990
+ /** the originals in `copy` nothing left in ^g reads — deleted from ^g, so they MOVE to the arm.
991
+ * Reverse order, so an original that fed only another dropped original goes too. */
992
+ readonly drop: ReadonlySet<Op>;
993
+ /** the target's compiler loads a LOCAL initialised with a read its test already performed a
994
+ * second time (`BranchShortCircuitOptions.reloadsLocalReread`) */
995
+ readonly targetReloadsLocalReread: boolean;
996
+ }
997
+
998
+ function armRereadSite(
999
+ fn: Fn,
1000
+ g: Block,
1001
+ arm: Block,
1002
+ preds: Map<Block, Block[]>,
1003
+ defOf: Map<Value, Op>,
1004
+ readers: Op[],
1005
+ targetReloadsLocalReread: boolean,
1006
+ ): ArmRereadSite {
1007
+ let copy: Set<Op> | undefined;
1008
+ let drop: Set<Op> | undefined;
1009
+ const coneOf = (): Set<Op> => {
1010
+ const out = new Set<Op>();
1011
+ for (const work = readers.flatMap(readsOf).filter((v) => defOf.has(v)); work.length;) {
1012
+ const d = defOf.get(work.pop()!);
1013
+ if (d && !out.has(d)) {
1014
+ out.add(d);
1015
+ work.push(...d.operands);
1016
+ }
1017
+ }
1018
+ return out;
1019
+ };
1020
+ const dropOf = (c: ReadonlySet<Op>): Set<Op> => {
1021
+ const out = new Set<Op>();
1022
+ const live = new Set<Value>();
1023
+ for (let i = g.ops.length - 1; i >= 0; i--) {
1024
+ const op = g.ops[i];
1025
+ if (c.has(op) && op.results.every((r) => !live.has(r))) {
1026
+ out.add(op);
1027
+ } else {
1028
+ readsOf(op).forEach((v) => live.add(v));
1029
+ }
1030
+ }
1031
+ return out;
1032
+ };
1033
+ return {
1034
+ fn,
1035
+ g,
1036
+ arm,
1037
+ preds,
1038
+ defOf,
1039
+ readers,
1040
+ targetReloadsLocalReread,
1041
+ get copy() {
1042
+ return (copy ??= coneOf());
1043
+ },
1044
+ get drop() {
1045
+ return (drop ??= dropOf(this.copy));
1046
+ },
1047
+ };
1048
+ }
1049
+
1050
+ /** Whether leaving ^g for `arm` leaves a natural loop ^g is in — some back-edge `t→h` (h dominates
1051
+ * t) whose body, h plus everything reaching t without passing h, holds ^g and not the arm. */
1052
+ function leavesALoop(fn: Fn, g: Block, arm: Block, preds: ReadonlyMap<Block, readonly Block[]>): boolean {
1053
+ const dom = dominators(fn);
1054
+ for (const t of fn.blocks) {
1055
+ for (const h of successorsOf(t)) {
1056
+ if (!dom.get(t)?.has(h)) {
1057
+ continue;
1058
+ }
1059
+ const body = new Set<Block>([h]);
1060
+ for (const work = [t]; work.length;) {
1061
+ const b = work.pop()!;
1062
+ if (!body.has(b)) {
1063
+ body.add(b);
1064
+ work.push(...(preds.get(b) ?? []));
1065
+ }
1066
+ }
1067
+ if (body.has(g) && !body.has(arm)) {
1068
+ return true;
1069
+ }
1070
+ }
1071
+ }
1072
+ return false;
1073
+ }
1074
+
1075
+ /** Whether analysis.ts would spell the arm's copy of a READ as a LOCAL rather than inline — a
1076
+ * MIRROR of analysis.ts's placement and barrier rules (structure/analysis.ts: `emitPos`, the
1077
+ * single-render `isBarrier`, the multi-render `isWrite`), not a coarser stand-in for them. It is
1078
+ * separate code, so the two can drift: the DIFFERENTIAL in the matching suite
1079
+ * (shortcircuit-branch.test.ts) is what holds them together. The copy sits at the arm's head, so
1080
+ * every question is about the arm block.
1081
+ *
1082
+ * Only the copied READS are asked about. An address member of the cone (`add`, `gaddr`) re-derives
1083
+ * at every use and carries nothing, however the address is spelled — so `p[i]` and `p[5]` get the
1084
+ * same answer. A read answers yes when:
1085
+ *
1086
+ * - it renders nowhere single: some PURE value between it and a statement has two consumers, so
1087
+ * `emitPos` is null and analysis.ts materializes it whatever stands in the way
1088
+ * (`p[2] = p[1] & 0x80; p[3] = p[1] & 0x80;` — one `and`, two stores);
1089
+ * - it has MORE THAN ONE direct reader, and an effect precedes one of their render positions —
1090
+ * the multi-render rule, `isWrite = EFFECTFUL_OPS`, which exempts nothing;
1091
+ * - it has ONE, and an effect precedes its render position that analysis.ts's `isBarrier`
1092
+ * counts: any `astore`/`opaque`, a `call` that is not rendered inside that same statement,
1093
+ * and a `store` UNLESS it is to a provably disjoint slot of the read's own base
1094
+ * (`disjointConstSlots`, ir/alias.ts — the helper `isBarrier` calls).
1095
+ *
1096
+ * A reader in any block other than the arm answers yes — the copy then outlives the arm's first
1097
+ * block. What this does NOT mirror, and which way each gap errs, is ARM_REREAD_GATES' RESIDUE. */
1098
+ function readHeldAcrossEffect(c: ArmRereadSite): boolean {
1099
+ const reads = [...c.copy].filter((op) => ORDER_SENSITIVE_OPS.has(op.opcode));
1100
+ if (reads.length === 0) {
1101
+ return false;
1102
+ }
1103
+ // The arm as analysis.ts will see it: the copied cone at its head (position 0 — nothing runs
1104
+ // before it), then the arm's own ops. A copied op's consumers are the arm ops and the OTHER copied
1105
+ // ops reading it, so a read feeding a copied `add` the arm reads renders where that `add` does.
1106
+ const cone = c.g.ops.filter((op) => c.copy.has(op));
1107
+ const posOf = (op: Op): number => Math.max(0, c.arm.ops.indexOf(op));
1108
+ const escapes = (vs: readonly Value[]): boolean =>
1109
+ c.fn.blocks.some((b) => b !== c.g && b !== c.arm && b.ops.some((op) => readsOf(op).some((v) => vs.includes(v))));
1110
+ const consumersOf = (vs: readonly Value[]): Op[] =>
1111
+ [...cone, ...c.arm.ops].filter((op) => readsOf(op).some((v) => vs.includes(v)));
1112
+ // `emitPos`: a statement renders where it stands, a value where its one consumer renders. A pure
1113
+ // value with two consumers renders in both — null. A load or call with two is one analysis.ts
1114
+ // names (a call executes once), so it renders where it stands. A consumer outside the arm leaves
1115
+ // the position unresolved — null, the other-block clause.
1116
+ const at = (op: Op): number | null => {
1117
+ if (op.successors.length > 0 || op.opcode === 'ret' || op.results.length === 0) {
1118
+ return posOf(op);
1119
+ }
1120
+ if (escapes(op.results)) {
393
1121
  return null;
394
1122
  }
395
- const [x, y] = d.operands;
396
- const xc = defs.get(x)?.opcode === 'const';
397
- const yc = defs.get(y)?.opcode === 'const';
398
- // exactly one side constant — `x == y` between two variables is no switch test
399
- return xc === yc ? null : { scrutinee: xc ? y : x };
1123
+ const cs = consumersOf(op.results);
1124
+ if (cs.length === 0) {
1125
+ return posOf(op);
1126
+ }
1127
+ if (cs.length === 1) {
1128
+ return at(cs[0]);
1129
+ }
1130
+ return ORDER_SENSITIVE_OPS.has(op.opcode) ? posOf(op) : null;
400
1131
  };
401
- const a = eqTest(c1);
402
- const b = eqTest(c2);
403
- return a !== null && b !== null && a.scrutinee === b.scrutinee;
1132
+ for (const l of reads) {
1133
+ if (escapes(l.results)) {
1134
+ return true;
1135
+ }
1136
+ const renders = consumersOf(l.results).map(at);
1137
+ if (renders.some((i) => i === null)) {
1138
+ return true;
1139
+ }
1140
+ const multi = renders.length > 1;
1141
+ const bars = (x: Op, pos: number): boolean => {
1142
+ if (!EFFECTFUL_OPS.has(x.opcode)) {
1143
+ return false;
1144
+ }
1145
+ if (multi) {
1146
+ return true;
1147
+ }
1148
+ if (x.opcode === 'store') {
1149
+ return !(l.opcode === 'load' && disjointConstSlots(l, x));
1150
+ }
1151
+ if (x.opcode === 'call') {
1152
+ return at(x) !== pos;
1153
+ }
1154
+ return true;
1155
+ };
1156
+ if (renders.some((pos) => c.arm.ops.slice(0, pos!).some((x) => bars(x, pos!)))) {
1157
+ return true;
1158
+ }
1159
+ }
1160
+ return false;
404
1161
  }
405
1162
 
406
- /** True when every value `g` defines is read at most once, and any read is inside `g`.
1163
+ // ── THE RE-READ ADMISSION'S REFUSALS, AS DATA ─────────────────────────────────────────────────
1164
+ //
1165
+ // A table so its refusals can be counted: taken as an OPTIONAL parameter
1166
+ // (`BranchShortCircuitOptions.armReread`), and censused from outside core by
1167
+ // `pnpm bench gates --pass arm-reread`, which swaps this pass's
1168
+ // entry in `PRE_RECOVERY_PASSES` (apps/benchmark/src/run/gate-census.ts). The table is asked only
1169
+ // at a site `sameArgs` and the negatability check have already passed, so a count is its own.
1170
+ //
1171
+ // SOUND is two rules — `entry-arm` and `second-pred` put the copy on a path the original never
1172
+ // ran. The other five are FIDELITY: each refuses a site where re-deriving costs bytes the nest did
1173
+ // not, and a wrong answer there is a miss, never wrong C, because the copy stays under the guard
1174
+ // either way.
1175
+ //
1176
+ // RESIDUE, named because it is not in the table:
1177
+ //
1178
+ // - the USE-COUNT half of `definedValuesStayLocal` — a value ^g reads twice itself is
1179
+ // materialized before the `if` — which judges ^g's own reads rather than this site's
1180
+ // re-derivation, and runs after the table on every fold, escaping or not.
1181
+ // - what `read-behind-effect` does not mirror of analysis.ts, of two kinds that err opposite
1182
+ // ways. (1) `/reread-globals`. Under that STRUCTURE variation analysis.ts lets a store to a
1183
+ // DIFFERENT named global through (`mayWriteGlobal`, ir/alias.ts), so there the copy inlines —
1184
+ // but this pass runs once per LIFT, and rank.ts structures every `/reread-globals` candidate
1185
+ // from a lift it shares with the others (`liftSettings`), so the verdict cannot follow the
1186
+ // variation without a lift of its own. It errs toward REFUSING, at a measured price:
1187
+ // `if (a && (gQ2[1] & 0x7f) == 0x7f) { gK = 1; gQ2[1] &= 0x80; return; } fnB();` keeps a 3/23
1188
+ // nest where the fold matches on that variation (MATCH 0/21 with this rule ablated), and so does the
1189
+ // same arm over a struct global's member; no corpus row. (2) the scopes analysis.ts
1190
+ // materializes a read in for reasons OTHER than a barrier — `liveAcrossLoop`, the address-home
1191
+ // variation, a load fed to a `cond_br` edge. They err toward ADMITTING: a local, under the guard,
1192
+ // one extra load on agbcc; no inhabitant measured. The DIFFERENTIAL in
1193
+ // packages/cli/test/matching/shortcircuit-branch.test.ts is what notices either one growing.
1194
+ //
1195
+ // MEASURED over the whole corpus, every row's default lift and whole enumerated fan with its
1196
+ // symbol map, both tiers (LBG and ProcessInputAndUpdateEntities default lift only): something
1197
+ // escapes ^g at a site on 10 rows. Against main the default source or the fan moves on 6 —
1198
+ // synthetic `ladder4`/`ladder5`/`ladidx1`/`ladidx2`, kleod `CountCollectedGems`, sa3
1199
+ // `EwramFree` — and on no other row; `nestinit` and `ucmp:mwcc_242_81` are refused here and come
1200
+ // out as main did, and `CheckTileCollisionVertical` and `TrySetCantSelectMoveBattleScript` are
1201
+ // refused at every site and come out as main did too — held
1202
+ // there by `loop-exit`, `moves-a-read` and `read-behind-effect`, whose prices outside the corpus
1203
+ // are on each rule. `read-behind-effect`'s target scoping and its analysis.ts mirror move nothing
1204
+ // in the corpus — default source and fan byte-identical on all 784 synthetic and 252 real rows,
1205
+ // and the same verdict at every site — so what they buy is off the corpus, on the rule.
1206
+ export const ARM_REREAD_GATES: readonly Gate<ArmRereadSite>[] = [
1207
+ {
1208
+ id: 'entry-arm',
1209
+ why: 'an arm that is the function’s first block is also reached on entry, where the copy would run too',
1210
+ sound: true,
1211
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm is the entry block',
1212
+ rejects: (c) => c.arm === c.fn.blocks[0],
1213
+ },
1214
+ {
1215
+ // With readers present the second predecessor can only be one ^g's copy dominates — a loop
1216
+ // back into the arm, re-running the re-read against memory the loop has since written.
1217
+ id: 'second-pred',
1218
+ why: 'a second way into the arm would run the copy on a path that never ran the original',
1219
+ sound: true,
1220
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm has a second predecessor',
1221
+ rejects: (c) => (c.preds.get(c.arm) ?? []).length !== 1,
1222
+ },
1223
+ {
1224
+ // The loop-guard shape, `for (i = 0; i < n; …)` under an `if`: agbcc tests the register it just
1225
+ // set to the induction's start (`mov r3, #0; cmp r3, r5`) and carries that register into the
1226
+ // loop, so ^g's condition reads a VARIABLE the arm goes on to own. A copy re-derives the
1227
+ // constant and the fold hands the outer `if` a `0 < n` loop recovery needed as its own guard
1228
+ // — `synthetic:nestinit:agbcc` MATCH → 6/49.
1229
+ id: 'edge-arg',
1230
+ why: 'a value an edge carries is a variable the arm owns, not an expression it re-reads',
1231
+ sound: false,
1232
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM carries on as an edge ARGUMENT',
1233
+ rejects: (c) => c.readers.some((op) => op.successors.some((s) => s.args.some((v) => c.defOf.has(v)))),
1234
+ },
1235
+ {
1236
+ // A value the SSA builder homed in a frame slot (`fn.slotHomes`) is one the target held in a
1237
+ // DECLARED local and reloads in the arm (`ldr rN, [sp, #k]`). The copy re-derives it instead,
1238
+ // and the copy's result carries no home, so the local and its `[sp, #k]` traffic are spelled
1239
+ // away and the frame order `l3/slotorder.ts` reads loses that name — silently, the failure
1240
+ // `replaceAllUsesWith` (ir/core.ts) ships an equally inhabitant-less guard against. Those are
1241
+ // the bytes it protects. NO INHABITANT in either tier or in any probe written against this
1242
+ // table, and stated rather than argued away: only Thumb stamps homes, and on agbcc a value
1243
+ // spilled in ^g is one live across a call in the arm, which `read-behind-effect` refuses on
1244
+ // its own unless the copy is a pure ADDRESS — the only shape where this rule alone would decide.
1245
+ id: 'slot-home',
1246
+ why: 'the machine kept this value in a stack slot rather than re-deriving it',
1247
+ sound: false,
1248
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the frame homed it',
1249
+ rejects: (c) => [...c.copy].some((op) => op.results.some((r) => c.fn.slotHomes?.has(r))),
1250
+ },
1251
+ {
1252
+ // A loop body's `if (a && … && d) { …; return; }`: the fold fuses the WHOLE condition into the
1253
+ // loop header, whose out-edge is then this early-return arm, and loop recovery reads it as the
1254
+ // loop's exit and declines the latch's (`unrecovered back-edge`). The shape is not this
1255
+ // admission's — the same loop with an arm that re-reads nothing folds the same way and
1256
+ // declines on main too — but this admission is what reached it: a probe of kleod
1257
+ // `CheckTileCollisionVertical`'s shape (not a corpus row) went diff:11/57 → declined, and the
1258
+ // real row's eight `/reread-globals` candidates all threw, which `bench run` does not report.
1259
+ // Refusing the re-read at a loop exit keeps exactly the nest main produced there. No row this
1260
+ // admission matches has its arm outside a loop ^g is in.
1261
+ //
1262
+ // What it protects that no corpus row pins is the ordinary SEARCH LOOP: `for (i = 0; i < 8;
1263
+ // i++) { if (q[i] != 0 && (v = p[i]) > 5) { r[0] = v; return; } } fnB();` matches as the nest
1264
+ // and scores 21/33 fused, and its `p[i] &= 0x80` twin MATCH against 31/37. The first is in the
1265
+ // matching suite (shortcircuit-branch.test.ts), which fails with this rule ablated.
1266
+ id: 'loop-exit',
1267
+ why: "fusing a loop body's whole condition makes this arm the header's exit, which loop recovery misreads",
1268
+ sound: false,
1269
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm LEAVES the loop',
1270
+ rejects: (c) => leavesALoop(c.fn, c.g, c.arm, c.preds),
1271
+ },
1272
+ {
1273
+ // `if (a) { v = p->f; if (b) use(v); }`: the target reads `p->f` on BOTH exits of `b`, before
1274
+ // `b`'s own reads; moved, it runs on one, after them. No row this admission matches moves
1275
+ // anything (the census: every fire site's `drop` is empty), and moving the read cost
1276
+ // the probe `if (gA) { v = gP->f[5]; if (gQ == 3) sink(v); }` its MATCH (0/20 → 15/24) and
1277
+ // turned a device-register read into one on one path only. Moving a PURE original stays: it
1278
+ // re-derives at every use whatever this does.
1279
+ id: 'moves-a-read',
1280
+ why: 'the target read it before the second test on both of its exits; moved, it runs on one',
1281
+ sound: false,
1282
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a READ only the arm consumes would move under the second test',
1283
+ rejects: (c) => [...c.drop].some((op) => ORDER_SENSITIVE_OPS.has(op.opcode)),
1284
+ },
1285
+ {
1286
+ // THE BYTES HALF of the copy (see `armRereadCone`), and a claim about ONE compiler. The arm of
1287
+ // `if (a && (p[1] & 0x7f) == 0x7f) { … }` loads `p[1]` again or not by SPELLING — loads of
1288
+ // `p[1]`, compiled at each bench toolchain's flags:
1289
+ //
1290
+ // agbcc ido7.1 gcc2.7.2kmc gcc2.7.2 mwcc_242_81
1291
+ // p[1] &= 0x80; 1 1 1 1 1
1292
+ // q[0] = 5; p[1] &= 0x80; 1 2 2 2 2
1293
+ // p[3] = 5; p[1] &= 0x80; 1 1 2 2 2
1294
+ // fnB(); p[2] = p[1]; 2 2 2 2 2
1295
+ // u8 v = p[1]; p[2] = v; 2 1 1 1 1
1296
+ // u8 v = p[1]; q[0] = 5; p[2] = v; 2 1 1 1 1
1297
+ // u8 v = p[1]; fnB(); p[2] = v; 2 1 1 1 1
1298
+ //
1299
+ // A LOCAL at the arm's head costs agbcc a second load and costs the other four nothing — the
1300
+ // target's `compilerBehaviors.reloadsLocalReread` (target.ts), the conjunct below. analysis.ts
1301
+ // spells the copy inline unless something stands between it and a use, and otherwise
1302
+ // materializes a local at the arm's head: correct, under the guard, and on agbcc a second
1303
+ // load. So on agbcc a read analysis.ts would materialize is refused, and the nest main spells
1304
+ // stays — `{ fnB(); sink(v); }`, `{ sink(v); sink(v); }` and `{ gP->f[3] = 0; sink(v); }` each
1305
+ // match only as the nest. Elsewhere the local IS the target's spelling: on mwcc,
1306
+ // `if (a) { u8 v = p[3]; if ((v & 0x7f) == 0x7f) { fnA(); p[4] = v; return; } } fnB();` was
1307
+ // 3/24 with this rule running there and matches without it.
1308
+ //
1309
+ // WHICH copies analysis.ts materializes is asked with analysis.ts's own rules
1310
+ // (`readHeldAcrossEffect`): only the copied READS, never the address arithmetic beside them,
1311
+ // and a store to a provably disjoint slot is no barrier. The coarser rule — ANY effect bars,
1312
+ // seeded from every copied op — holds 13 agbcc probes at 3 to 8 points that byte-match under
1313
+ // this one (`r->x = 5; r->fl &= 0x80;`, `p[3] = 5; p[1] &= 0x80;`, `p[i] &= 0x80; fnB(); p[i]
1314
+ // = 1;` among them), scores 8 more worse, and scores none of 249 probes over five toolchains
1315
+ // better. What this one does not mirror is the RESIDUE above.
1316
+ id: 'read-behind-effect',
1317
+ why: 'a re-read written as a local costs a second load on a target whose compiler reloads one',
1318
+ sound: false,
1319
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a READ the arm holds across an effect is not re-derived',
1320
+ rejects: (c) => c.targetReloadsLocalReread && readHeldAcrossEffect(c),
1321
+ },
1322
+ ];
1323
+
1324
+ /** Copy `copy` to the head of `arm`, point every read outside `g` at the copies, and delete `drop`.
1325
+ * A dropped original is deleted rather than left for the hoist: the arm now performs it, and a
1326
+ * dead READ left in ^h is one the structurer may still spell (SPELLED_WHEN_DEAD_OPS). */
1327
+ function rereadInArm(fn: Fn, g: Block, arm: Block, { copy, drop }: ArmReread): void {
1328
+ const copyOf = new Map<Value, Value>();
1329
+ const copies = g.ops
1330
+ .filter((op) => copy.has(op))
1331
+ .map((op): Op => ({
1332
+ opcode: op.opcode,
1333
+ operands: op.operands.map((v) => copyOf.get(v) ?? v),
1334
+ results: op.results.map((r) => {
1335
+ const c = mkValue(r.type);
1336
+ copyOf.set(r, c);
1337
+ return c;
1338
+ }),
1339
+ attrs: { ...op.attrs },
1340
+ successors: [],
1341
+ }));
1342
+ const redirect = (v: Value): Value => copyOf.get(v) ?? v;
1343
+ for (const b of fn.blocks) {
1344
+ if (b === g) {
1345
+ continue;
1346
+ }
1347
+ for (const op of b.ops) {
1348
+ op.operands = op.operands.map(redirect);
1349
+ for (const s of op.successors) {
1350
+ s.args = s.args.map(redirect);
1351
+ }
1352
+ }
1353
+ }
1354
+ arm.ops.unshift(...copies);
1355
+ g.ops = g.ops.filter((op) => !drop.has(op));
1356
+ }
1357
+
1358
+ /** True when every value `g` defines is read at most once, and any read is inside `g` or is one
1359
+ * `armRereadCone` answered for — reads outside `g` move to the arm's copy, and reads BY a
1360
+ * dropped original leave with it. The use-count half is the residue ARM_REREAD_GATES names.
407
1361
  *
408
1362
  * The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
409
1363
  * ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
410
1364
  * is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
411
1365
  * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
412
- * escape, and only this check stops it.
1366
+ * escape, and only this check and the arm's copy stop it.
413
1367
  *
414
- * An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
415
- * itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
416
- * WRONG — the entry block dominates every block whatever M's predecessor count and it was wrong
417
- * about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
418
- * form was missing entirely. Both now have it. When changing either fold, check the other. */
419
- function definedValuesStayLocal(fn: Fn, g: Block): boolean {
1368
+ * What the two folds genuinely DO share is the `fn.blocks[0]` refusal, and each has a test that
1369
+ * pins its own half: 'a feeder that is the entry block is not folded away' for the value form, and
1370
+ * 'the ENTRY block is never folded away' for the branch form. They are deliberately NOT routed
1371
+ * through one shared `isEntry` helper a helper enforces nothing, and it is the two tests that
1372
+ * hold each fold to the refusal. When changing either fold, check the other. */
1373
+ function definedValuesStayLocal(fn: Fn, g: Block, { copy, drop }: ArmReread): boolean {
420
1374
  const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
421
1375
  if (defined.size === 0) {
422
1376
  return true;
423
1377
  }
1378
+ const copied = new Set<Value>([...copy].flatMap((op) => op.results));
424
1379
  const uses = new Map<Value, number>();
425
1380
  for (const b of fn.blocks) {
426
1381
  for (const op of b.ops) {
427
- for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
1382
+ if (drop.has(op)) {
1383
+ continue;
1384
+ }
1385
+ for (const v of readsOf(op)) {
428
1386
  if (!defined.has(v)) {
429
1387
  continue;
430
1388
  }
431
1389
  if (b !== g) {
1390
+ if (copied.has(v)) {
1391
+ continue; // re-derived in the arm (`rereadInArm`)
1392
+ }
432
1393
  return false; // escapes ^g — the structurer would render it before the `if`
433
1394
  }
434
1395
  uses.set(v, (uses.get(v) ?? 0) + 1);