@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -36,14 +36,45 @@
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';
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 {
44
+ Block,
45
+ Fn,
46
+ Op,
47
+ Successor,
48
+ Value,
49
+ defOpMap,
50
+ foldWriteOrder,
51
+ forwardingTarget,
52
+ mkOp,
53
+ mkValue,
54
+ predecessors,
55
+ reachableBlocks,
56
+ replaceAllUsesWith,
57
+ } from '../ir/core';
42
58
  import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
43
59
  import { T } from '../ir/types';
44
60
 
45
61
  const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
46
62
 
63
+ /** Run `step` until it stops rewriting, and answer whether it ever did.
64
+ *
65
+ * Both folds in this file are driven the same way and have to be: `defOpMap` and `predecessors`
66
+ * are stale the moment a block is spliced or dropped, so a scan performs at most ONE rewrite and
67
+ * then starts over. `step` is that scan — it recomputes everything it reads, does at most one
68
+ * rewrite, and returns whether it did — which makes the rescan point a function boundary instead
69
+ * of a `break` to a label. */
70
+ function untilFixpoint(step: () => boolean): boolean {
71
+ let changed = false;
72
+ while (step()) {
73
+ changed = true;
74
+ }
75
+ return changed;
76
+ }
77
+
47
78
  /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
48
79
  // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
49
80
  // internal call preserves.
@@ -86,7 +117,6 @@ function recognizeBoolNormalize(fn: Fn): boolean {
86
117
 
87
118
  /** Collapse a simple boolean short-circuit diamond into one `logic_and`/`logic_or`, in place. */
88
119
  export function recognizeShortCircuit(fn: Fn): boolean {
89
- let changed = recognizeBoolNormalize(fn);
90
120
  const term = (b: Block) => b.ops[b.ops.length - 1];
91
121
  const constOf = (defs: Map<Value, Op>, v: Value): number | null => {
92
122
  const d = defs.get(v);
@@ -97,12 +127,15 @@ export function recognizeShortCircuit(fn: Fn): boolean {
97
127
  return !!d && BOOL_OPS.has(d.opcode);
98
128
  };
99
129
 
100
- let progress = true;
101
- while (progress) {
102
- progress = false;
130
+ // TWO STATEMENTS, not `recognizeBoolNormalize(fn) || untilFixpoint(…)`: the normalisation must
131
+ // run BEFORE the fold (it is what turns agbcc's branchless is-nonzero into the `icmp_ne` the
132
+ // diamond's second operand has to be), and `||` would skip the fold whenever it reported a
133
+ // change. Both results are returned, because either one is a change to the IR.
134
+ const normalized = recognizeBoolNormalize(fn);
135
+ const folded = untilFixpoint(() => {
103
136
  const defs = defOpMap(fn);
104
137
  const preds = predecessors(fn);
105
- outer: for (const m of fn.blocks) {
138
+ for (const m of fn.blocks) {
106
139
  if (m.params.length !== 1) {
107
140
  continue;
108
141
  }
@@ -124,9 +157,8 @@ export function recognizeShortCircuit(fn: Fn): boolean {
124
157
  // below: `predecessors()` walks successor edges only, so an entry block that is also a loop
125
158
  // header shows one predecessor while actually running BEFORE it on the first iteration.
126
159
  // 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.
160
+ // assertResolved and assertDerefsTyped all pass. Pinned by 'a feeder that is the entry block
161
+ // is not folded away'; the branch form has the same refusal and its own test.
130
162
  if (bfeed === fn.blocks[0]) {
131
163
  continue;
132
164
  }
@@ -162,10 +194,24 @@ export function recognizeShortCircuit(fn: Fn): boolean {
162
194
  continue;
163
195
  }
164
196
  const cond = ht.operands[0];
165
- const condDef = defs.get(cond);
166
- if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
197
+ // The head condition must be a C BOOLEAN computed here — a negatable comparison or a
198
+ // `logic_and`/`logic_or`, which is exactly `BOOL_OPS` and exactly the set `negateCondOps`
199
+ // accepts at top level, so a fused connective head still folds. Mere def-existence is NOT
200
+ // the test — `call` declares `results: 1` (ir/opcodes.ts), so `defOpMap` maps a call result
201
+ // like any other — because the const/const reduction below hands `cond` ITSELF on as the
202
+ // merge value (`res = condSide`), replacing a phi that was `cond ? 1 : 0`. A non-boolean
203
+ // head there is a silent WRONG VALUE, not a missed fold: `and(6, 3)` emitted where the
204
+ // program yields 1. The branch form carries no such obligation — its result only ever feeds
205
+ // a `cond_br`, which reads truthiness — which is why this gate lives here and not in the
206
+ // shared helper, whose own result is boolean by construction and so covers the negated case.
207
+ //
208
+ // Instrumented over the whole benchmark under BOTH lift configurations, it refuses
209
+ // NOTHING — every head reaching it is a negatable icmp — while the pass folds 6 value-form
210
+ // diamonds per configuration, 3 of them through the const/const reduction. An invariant's
211
+ // guard, not a filter any row depends on.
212
+ if (!isBool(defs, cond)) {
167
213
  continue;
168
- } // head condition must be a negatable icmp
214
+ }
169
215
 
170
216
  // The `cond`-side operand is negated iff `cond` guards the short-circuit (taken+0 / fall+1).
171
217
  const wantNeg = (c === 0 && mIsTaken) || (c === 1 && mIsFall);
@@ -180,11 +226,26 @@ export function recognizeShortCircuit(fn: Fn): boolean {
180
226
  if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
181
227
  continue;
182
228
  }
229
+ // NEGATABLE only when the orientation actually inverts the head — asked here rather than up
230
+ // with the other head checks, so a fused `logic_and`/`logic_or` head (what the branch form
231
+ // below leaves behind, and what an earlier round of THIS pass leaves behind in a chain) is
232
+ // not refused when nothing needs inverting. `negateCondOps` is the branch form's helper too,
233
+ // so both siblings refuse the same three shapes. Its dominance precondition holds here for a
234
+ // different reason than there: `cond` is ^h's OWN terminator operand, so its whole cone
235
+ // dominates the point `before` splices into.
236
+ //
237
+ // Minted below every other refusal and above the first mutation: a site refused for any
238
+ // other reason pays nothing, and a refusal below the hoist would leave ^h half-rewritten.
239
+ const negation = wantNeg ? negateCondOps(defs, cond, NEGATE_BUDGET) : null;
240
+ if (wantNeg && !negation) {
241
+ continue;
242
+ }
183
243
  bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
244
+ foldWriteOrder(fn.writeOrder, bfeed, h); // …and its writes now follow H's (ir/core.ts)
184
245
  let condSide = cond;
185
- if (wantNeg) {
186
- condSide = mkValue(T.unk(32));
187
- before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
246
+ if (negation) {
247
+ negation.ops.forEach((op) => before(op));
248
+ condSide = negation.result;
188
249
  }
189
250
  // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
190
251
  let res = condSide;
@@ -203,13 +264,12 @@ export function recognizeShortCircuit(fn: Fn): boolean {
203
264
  m.params = [];
204
265
  }
205
266
  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
267
+ return true; // defs/preds are stale after mutation — the driver rescans
209
268
  }
210
269
  }
211
- }
212
- return changed;
270
+ return false;
271
+ });
272
+ return normalized || folded;
213
273
  }
214
274
 
215
275
  // ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
@@ -253,10 +313,51 @@ export function recognizeShortCircuit(fn: Fn): boolean {
253
313
  // not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
254
314
  // `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
255
315
  // 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.
316
+ // of nested `if`s. The two spellings are mutually exclusive within one raise and BOTH are
317
+ // legitimate C, and which of them the asm rules out depends on the shape: `x == 0 || x == 2`
318
+ // and `switch (x) { case 0: case 2: }` are ONE object only where the switch has a single case
319
+ // group plus `default:` (agbcc 12 instructions each, one md5; IDO 64 bytes each, one md5), and
320
+ // part as soon as there is a second group to balance a dispatch against (agbcc 20 against 16,
321
+ // IDO 80 bytes and different bytes). So this is a DEFAULT rather than a decision: the switch
322
+ // is the more specific recovery and wins the shape here, while
323
+ // `foldTreeOwned` spells the connective instead and the differ referees (rank.ts's
324
+ // `/connective`). ONLY THIS CLAUSE. Its notion of "same scrutinee" is switch-recover.ts's own
325
+ // PRE1 — a NECESSARY condition for recovery, never a sufficient one, so what it refuses is
326
+ // "a switch could not be ruled out here" and it is a proxy too, just a far tighter one than
327
+ // the relayed clause. Priced over the set it REFUSES rather than the set it admits: it fires on
328
+ // 6 rows, protects 2 (`pokeemerald:IsStringLengthAtLeast`,
329
+ // `pokeemerald:TrySetCantSelectMoveBattleScript`), and on the other 4 the published winner
330
+ // folds THROUGH it — `kleod:CheckWorldCompletion`'s refused site is `v5 == 3 || v5 == 5` on an
331
+ // ordinary inner-loop counter with no dispatch region near it. It is the axis, not the clause,
332
+ // that keeps those 4. A structural discriminator is L1-visible and would be strictly better —
333
+ // is the shared block the entry of a region with dispatch-shaped in-edges, is the scrutinee
334
+ // defined by the enclosing loop header — and is UNBUILT.
335
+ // The relayed clause below is a different statement (see its own note: a blunt proxy that
336
+ // fires on an ordinary loop counter), it has NO inhabitant anywhere in the benchmark, and a
337
+ // candidate born there would carry a `/connective` label for a fold that answers
338
+ // no connective-vs-tree question. It stays absolute.
339
+ // - the shared block was reached through a RELAY, and either test's scrutinee is compared against
340
+ // constants more than once in the function. This one is ABSOLUTE — `foldTreeOwned` does not
341
+ // widen it. Same reason as the bullet above, widened because the reach is: a relay is what
342
+ // agbcc puts on a tree's default edge, so resolving one walks this fold into a dispatch chain,
343
+ // where the sibling that gives the tree away may be neither test in
344
+ // hand — the split node is RELATIONAL and only its children are equalities, which is precisely
345
+ // what the pairwise test cannot see. Without it `sub_807BD88` and `sub_808491C` each lose a
346
+ // `switch` (sa3).
347
+ //
348
+ // It is BLUNT, and that is why it is held to the relayed case. The count does not distinguish a
349
+ // dispatch chain from any variable tested against constants at two sites: on `sub_8080AD4` it
350
+ // fires on `v2 > 2` and `v2 != 2` — one ordinary loop counter — and refusing there costs the
351
+ // function its whole decompilation, because the only fold it has is the `do…while` exit. Its
352
+ // notion of "same scrutinee" is SSA-value identity, which is switch-recover.ts's own PRE1, so it
353
+ // cannot refuse a tree that recovery could not have taken either — but it is a proxy for "am I
354
+ // inside a dispatch chain", not a test of it. A direct edge onto a relational split node still
355
+ // escapes: `sub_807F334` folds `x > 1 && x == 2` and loses a `switch` on main and here alike.
356
+ // - BOTH of ^g's edges rejoin the shared block, with NEITHER of them landing on it directly. Then
357
+ // there is no "other" arm and nothing decides which side the connective guards. Only resolution
358
+ // creates this — an edge that arrives directly is preferred exactly so the MIPS divide-guard
359
+ // idiom, whose emptied trap block forwards to the same place, keeps folding as it always has
360
+ // (`af:adds:ido7.1` and the `divv`/`gcd`/`modv` rows).
260
361
  // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
261
362
  // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
262
363
  // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
@@ -267,21 +368,133 @@ export function recognizeShortCircuit(fn: Fn): boolean {
267
368
  // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
268
369
  // so it can only carry one argument list; picking either would silently drop the other path's
269
370
  // 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.
371
+ // - ^g's two successors are the same block, or ^g's condition cannot be NEGATED when the
372
+ // orientation needs negating. `negateCondOps` decides that, over two shapes: a negatable
373
+ // `icmp_*` (the swapped opcode) and a `logic_and`/`logic_or` (De Morgan — the dual connective
374
+ // over recursively negated operands). The connective case is what lets a chain fold past its
375
+ // FIRST level: this pass is iterative, so by the time it tries an outer diamond the inner one is
376
+ // already fused and ^g's condition is a connective, which `NEGATED_ICMP` — a table over
377
+ // comparison opcodes — has no entry for. Refuse it and `a || !(b || c)` stops after one level at
378
+ // all 13 sites this fires on (`synthetic:llcmp:agbcc`, `:gcc2.7.2kmc`, and 11 real agbcc
379
+ // functions in klonoa+sa3); on the two benchmark rows the structurer then tail-duplicates the
380
+ // shared return into both arms. The helper's own refusals — no def, a non-negatable leaf
381
+ // ANYWHERE in the cone (no partial De Morgan), a cone over the node budget — are on the helper.
382
+ //
383
+ // De Morgan DUPLICATES leaf comparisons, so a duplicated leaf could gain a second consumer that
384
+ // analysis.ts renders as a statement BEFORE the `if` — the hazard the single-icmp negation
385
+ // always carried. Measured over those 11 BRANCH-form sites: NO site gains a local (one,
386
+ // `sub_80B7CD0`, loses one, 8 → 7) and every site that already decompiled gets SHORTER. Two
387
+ // (`sub_80930B8`, `sub_80932E0`) go from DECLINED to a full decompilation — folding a loop-exit
388
+ // connective removes the back-edge loop recovery was refusing — so they get LONGER (1 → 112 and
389
+ // 1 → 55 lines) and are the only sites whose local count rises at all, 0 → 22 and 0 → 7. A
390
+ // decompilation appearing, not a leaf escaping.
391
+ //
392
+ // That number is SCOPED to this fold and does not carry to the value form, which shares the
393
+ // helper but no use-count condition: `definedValuesStayLocal` (bottom of this file)
394
+ // independently forbids a ^g-defined value with a second consumer HERE, while the value form
395
+ // relies on the original cone dying to the pass list's own `dce: true`. There is nothing to
396
+ // transfer the number to yet either — instrumenting the value form over the whole benchmark
397
+ // under both lift configurations counts 6 folds per configuration, every head a single icmp.
398
+ //
399
+ // The `/connective` LIFT AXIS is a separate question from the default lift, and is unwidened:
400
+ // `onTreeOwned` below is what tells rank.ts the axis exists for a row, and this check sits ABOVE
401
+ // it. Over the whole benchmark under BOTH configurations rank.ts lifts with (`foldTreeOwned`
402
+ // false and true), against the same rows with the connective case ablated: the recovered IR
403
+ // moves on the same 2 rows under each, `onTreeOwned` fires on the same rows either way, and
404
+ // nothing new throws.
272
405
  //
273
406
  // Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
274
407
  // miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
275
408
  // more condition block.
276
- export function recognizeBranchShortCircuit(fn: Fn): boolean {
277
- let changed = false;
409
+ //
410
+ // WHICH SPELLING, and why the fold alone does not decide it. `if (a && b) X else Y` and its dual
411
+ // `if (!a || !b) Y else X` are the same program and NOT the same bytes — agbcc lays the arms out in
412
+ // source order, so which was written is recorded in the branch senses. This rewrite keeps ^h's
413
+ // unchanged successor slot, so the connective comes out in the orientation those senses spell, and
414
+ // which of the two that is depends on the branch RANGE below, not on the source. Reaching the
415
+ // other is `negateCond`'s job (l3/ast.ts distributes `!(a && b)`), and rank.ts's `/flip-join` axis
416
+ // is what asks for it on a RECONVERGING if — the default spells the layout reading and the axis
417
+ // spells its dual, so both orientations are compiled and the differ picks (synthetic:ifand_near
418
+ // matches at the default, synthetic:ifor_near on the axis).
419
+ //
420
+ // What neither reaches is the MIXED spelling. `negateJoinedBranchSense` is a per-FUNCTION boolean,
421
+ // so the axis negates every joined `if` at once — and of the 28 real rows carrying the
422
+ // `short-circuit` tag, 16 hold two or more TWO-ARMED ifs (counted by `else`, which is what the
423
+ // axis's own `thenS.length && elseS.length` gate needs) and 12 hold two or more conditions
424
+ // carrying a connective. TWO-ARMED is the count that matters: both sense booleans exclude a
425
+ // one-armed `if` by construction, so a tally of `if (` of any kind is the wrong denominator.
426
+ // A per-SITE negation is the open lever; a gate on whether to ENUMERATE the axis does not reach
427
+ // it, and removes a spelling the differ would referee.
428
+ //
429
+ // The De Morgan negation below forecloses a third spelling, at a measured price: it DISTRIBUTES, so
430
+ // the leaves come out negated (`a || (!b && !c)`) and `a || !(b || c)` has no
431
+ // candidate — the IR has no `logic_not` to build one from (ir/opcodes.ts). Compiled both ways on
432
+ // the `a || (b && c)` guard shape at agbcc's default flags, the two source spellings assemble to
433
+ // the same bytes (12/12 rows, score 0), so the foreclosure costs nothing here. A shape that ever
434
+ // separated them would be a new axis, not a bug in this fold.
435
+ //
436
+ // WHICH slot ^g lands in is decided by the asm's branch POLARITY, and on Thumb the branch RANGE
437
+ // decides the polarity — so the same source `&&` reaches this pass two different ways:
438
+ //
439
+ // short branch `beq shared` ^g is ^h's FALL → logic_or
440
+ // long branch `bne ^g / b shared` ^g is ^h's TAKEN → logic_and
441
+ //
442
+ // agbcc inverts a conditional it cannot reach, so past ±256 bytes it emits the second form, and the
443
+ // trampoline it leaves on the `b` sits on the edge into the SHARED block — which `forwardingTarget`
444
+ // (ir/core.ts) looks through. Only that edge needs it: the INVERTED branch is the one that still
445
+ // reaches, so `bne ^g` always arrives at ^g directly and no relay can sit between them.
446
+ //
447
+ // SO THE CONNECTIVE THIS FOLD MINTS IS THE RANGE'S, NEVER THE SOURCE'S: both `synthetic:ifand_near`
448
+ // (source `&&`) and `synthetic:ifor_near` (source `||`) are short-branch rows and both come out
449
+ // `logic_or`. Which SPELLING then wins is the joined-sense default's question, one layer up, and
450
+ // BOTH halves have a dual candidate there — `ifand_near:agbcc` matches at the default (`unsigned`),
451
+ // while `ifor_near:agbcc` and the long-branch `ifand_far:agbcc` each match on `/flip-join`.
452
+ //
453
+ // `gIsFall` IS NOT THE CARRIER FOR A PER-SITE SENSE, and that was measured rather than argued. It
454
+ // reads the branch RANGE, exactly as the table above says — so in any function small enough for
455
+ // every branch to be short it is the SAME at every site, including sites whose sources wrote
456
+ // opposite connectives. Instrumented at the fold and run through the bench: a two-site row whose
457
+ // first site wrote `&&` and whose second wrote its dual reads `gIsFall=true` at BOTH
458
+ // (`synthetic:joinsense`), and a four-site ladder row with two sites inverted in the source reads
459
+ // `true` at all four (`synthetic:mixsense`). The positive control reads the other way —
460
+ // `synthetic:ifand_far`, the long-branch row, gives `false` against
461
+ // `synthetic:ifand_near`'s `true`. So carrying this boolean to L3
462
+ // as a node stamp (the `#144` `Expr.baseOrdered` shape) would hand every site of such a function
463
+ // one answer and reach exactly the two configurations `negateJoinedBranchSense` already reaches.
464
+ //
465
+ // Every refusal falls through untouched — a miss, never a miscompile.
466
+ /** Per-call options for `recognizeBranchShortCircuit` — the tree-ownership refusal's two ends. */
467
+ export interface BranchShortCircuitOptions {
468
+ /** Take the fold at a site the PAIRWISE comparison-tree refusal owns, spelling the connective
469
+ * where the default leaves the tree for switch-recover.ts. rank.ts's `/connective` axis; see the
470
+ * REFUSALS note. It widens the SHAPE the fold accepts and nothing about what the fold may move —
471
+ * every other refusal still applies, the RELAYED clause included.
472
+ *
473
+ * A NEW REFUSAL CONDITION, stated because it is one: this is per-FUNCTION and the question is
474
+ * per-SITE. Every tree-owned site in a function flips together, so a function with two of them
475
+ * wanting OPPOSITE spellings has no candidate that spells the mix, and nothing reports the gap.
476
+ * A per-FUNCTION predicate cannot decide a per-SITE question — the same shape the joined-if
477
+ * default hit — and here it costs completeness rather than correctness. The alternative is a
478
+ * fork per site: `kleod:CheckWorldCompletion` refuses at 10 and goes 96 → 192 candidates as one
479
+ * boolean, where a per-site fork would be 1024×. That is why the boolean, not an oversight. */
480
+ foldTreeOwned?: boolean;
481
+ /** Called at each site the pairwise tree-ownership refusal is the ONE thing stopping the fold —
482
+ * how rank.ts learns the axis has an inhabitant here without re-running the matcher. Asked LAST,
483
+ * after `sameArgs` and the negatability check, so a report means a `/connective` candidate that
484
+ * differs from its sibling: reporting a refusal merely REACHED would double the row's whole
485
+ * candidate cross for a lift that produces duplicates the dedup collapses. (Its sibling gate
486
+ * `hasSetupArgsNarrowing` asks the same question the same way — does the lever CHANGE anything.)
487
+ * The pass re-scans after every rewrite, so one site can report more than once; read it as a
488
+ * boolean. */
489
+ onTreeOwned?: () => void;
490
+ }
491
+
492
+ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOptions = {}): boolean {
278
493
  const term = (b: Block) => b.ops[b.ops.length - 1];
279
- let progress = true;
280
- while (progress) {
281
- progress = false;
494
+ return untilFixpoint(() => {
282
495
  const defs = defOpMap(fn);
283
496
  const preds = predecessors(fn);
284
- outer: for (const h of fn.blocks) {
497
+ for (const h of fn.blocks) {
285
498
  const ht = term(h);
286
499
  if (ht.opcode !== 'cond_br') {
287
500
  continue;
@@ -312,18 +525,12 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
312
525
  if (gTaken.block === gFall.block) {
313
526
  continue;
314
527
  }
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
528
  // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
529
  // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
530
  // short circuit.
322
- // HOIST_UNSAFE_OPS, not EFFECTFUL_OPS: a live `opaque` is an instruction asmlift could not
323
- // model, and moving it out of the arm that guards it is the reordering this refuses. Loud
324
- // either way today (a decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under
325
- // `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
326
- // an observed bug.
531
+ // HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
532
+ // out of the arm that guards it is the reordering this refuses. Loud either way today — a
533
+ // decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
327
534
  const body = g.ops.slice(0, -1);
328
535
  if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
329
536
  continue;
@@ -331,12 +538,32 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
331
538
  if (!definedValuesStayLocal(fn, g)) {
332
539
  continue;
333
540
  }
334
- // Which of ^g's edges rejoins ^h's other successor? That is the shared block.
335
- const sharedEdge =
336
- gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
541
+ // Which of ^g's edges rejoins ^h's other successor? That is the shared block. A DIRECT edge
542
+ // wins, so resolution only ever adds reach — it never re-picks an edge this fold already had.
543
+ const sharedTarget = forwardingTarget(sharedFromH.block);
544
+ const rejoins = (e: Successor): boolean => forwardingTarget(e.block) === sharedTarget;
545
+ const direct = gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
546
+ // With neither edge direct, both may resolve onto the shared block — and then there is no
547
+ // "other" arm left and nothing decides which side the connective guards.
548
+ if (direct === null && rejoins(gTaken) && rejoins(gFall)) {
549
+ continue;
550
+ }
551
+ const sharedEdge = direct ?? (rejoins(gTaken) ? gTaken : rejoins(gFall) ? gFall : null);
337
552
  if (!sharedEdge) {
338
553
  continue;
339
554
  }
555
+ // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold. A relay
556
+ // is what agbcc puts on a tree's default edge, so a shared block reached through one is
557
+ // searched function-wide; a direct edge keeps the pairwise test. See the REFUSALS note —
558
+ // the wider test is blunt, and that is why it is not asked everywhere.
559
+ const throughRelay = sharedEdge.block !== sharedFromH.block;
560
+ if (
561
+ throughRelay &&
562
+ (inComparisonTree(fn, defs, ht.operands[0]) || inComparisonTree(fn, defs, gt.operands[0]))
563
+ ) {
564
+ continue; // the relayed clause is absolute — `foldTreeOwned` does not widen it
565
+ }
566
+ const treeOwned = !throughRelay && sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0]);
340
567
  const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
341
568
  if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
342
569
  continue;
@@ -345,16 +572,27 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
345
572
  // reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
346
573
  const wantEdge = gIsFall ? sharedEdge : otherEdge;
347
574
  const c2 = gt.operands[0];
348
- const c2Def = defs.get(c2);
349
- let second = c2;
350
- const negated: Op[] = [];
351
- if (wantEdge !== gTaken) {
352
- if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
575
+ // ^g's condition may itself be a CONNECTIVE — the fold is iterative, so an inner diamond is
576
+ // already fused by the time the outer one is tried, and negating one is De Morgan rather
577
+ // than an opcode swap. `negateCondOps` does both, and returns null on anything else.
578
+ const negation = wantEdge !== gTaken ? negateCondOps(defs, c2, NEGATE_BUDGET) : null;
579
+ if (wantEdge !== gTaken && !negation) {
580
+ continue;
581
+ }
582
+ // LAST refusal, so `onTreeOwned` reports a site where tree ownership is the ONE thing in the
583
+ // way — which is why the negatability check stays above it even though it MINTS the negated
584
+ // cone (up to NEGATE_BUDGET ops) and discards it whenever this gate refuses. That discard is
585
+ // free rather than merely cheap: `mkValue` is `{ type }` with no identity counter
586
+ // (ir/core.ts) and nothing is spliced until below, so a site refused here leaves the CFG
587
+ // byte-identical. See the option's doc for why the position is the gate's meaning.
588
+ if (treeOwned) {
589
+ opts.onTreeOwned?.();
590
+ if (!opts.foldTreeOwned) {
353
591
  continue;
354
592
  }
355
- second = mkValue(T.unk(32));
356
- negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
357
593
  }
594
+ const second = negation ? negation.result : c2;
595
+ const negated: Op[] = negation ? negation.ops : [];
358
596
  const res = mkValue(T.unk(32));
359
597
  const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
360
598
  operands: [ht.operands[0], second],
@@ -374,35 +612,195 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
374
612
  { block: sharedEdge.block, args: [...sharedEdge.args] },
375
613
  ],
376
614
  });
615
+ foldWriteOrder(fn.writeOrder, g, h); // ^g's writes now follow ^h's own (ir/core.ts)
377
616
  fn.blocks = fn.blocks.filter((x) => x !== g);
378
- changed = true;
379
- progress = true;
380
- break outer; // defs/preds are stale after the mutation recompute on the next round
617
+ // ^h's old shared edge is gone, so the relay it pointed at may have become unreachable —
618
+ // and once that link goes, so may the next, all the way down the chain. `dominators`
619
+ // (ir/core.ts) gives a block with no in-edges only ITSELF, which empties the intersection at
620
+ // everything it still branches to, so a half-dropped chain declines two passes later with a
621
+ // def-does-not-dominate-use out of `verify`.
622
+ //
623
+ // REACHABILITY, not predecessor count: in-edges from blocks that are themselves unreachable
624
+ // leave a block just as orphaned, and the thumb frontend does hand over unreachable blocks
625
+ // (see raise/gvn.ts). Only relays are dropped — the chain ends at the first block that does
626
+ // real work, and that one stays whatever its in-edges look like.
627
+ //
628
+ // The relay test below is `isBodyless` (ir/core.ts) MINUS its parameter clause, and the
629
+ // omission is what the reachability guard above buys: an unreachable block binds nothing, so
630
+ // a param on one says nothing about a live edge, while refusing it would leave the chain
631
+ // HALF-DROPPED — and a half-dropped chain fails `verify` two passes later with a
632
+ // def-does-not-dominate-use. `isBodyless` itself is unchanged and right for its own three
633
+ // callers, every one of which asks about a block that is still reached.
634
+ for (let link = sharedFromH.block; link.ops.length === 1 && link.ops[0].opcode === 'br';) {
635
+ const dead = link;
636
+ if (dead === fn.blocks[0] || reachableBlocks(fn).has(dead)) {
637
+ break;
638
+ }
639
+ link = dead.ops[0].successors[0].block;
640
+ fn.blocks = fn.blocks.filter((x) => x !== dead);
641
+ }
642
+ return true; // defs/preds are stale after the mutation — the driver rescans
381
643
  }
382
644
  }
383
- }
384
- return changed;
645
+ return false;
646
+ });
385
647
  }
386
648
 
387
- /** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
388
- * comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
389
- * only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
390
- * range check) is a genuine connective this fold should still take. */
391
- function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
392
- const eqTest = (v: Value): { scrutinee: Value } | null => {
393
- const d = defs.get(v);
394
- if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
649
+ /** How many ops `negateCondOps` may KEEP for one negation. De Morgan rebuilds the cone PER PATH and
650
+ * shares nothing deliberately, see the helper's note so a fold's cost is linear in the cone's
651
+ * nodes and this is the cheap stop on it.
652
+ *
653
+ * What 8 decides, said as the shape rather than as headroom, because "8" reads like more room than
654
+ * it is. A negation mints one op per cone node and a binary expansion has leaves = internal + 1, so
655
+ * a minted count is always ODD: 1, 3, 5, 7, 9. At 8 a FOUR-clause inner conjunct (7 ops) folds and
656
+ * a FIVE-clause one (9 ops) does not; 7 and 8 are therefore one gate, and so is 9 over everything
657
+ * measured here, the deepest cone in the 2,047 lifted klonoa+sa3 functions minting 5 (17
658
+ * connective negations, 0 refused). Clause COUNT is not the axis either: a FLAT `a || b || c || …`
659
+ * chain pays nothing at all, because ^g's condition is never a connective in that shape.
660
+ *
661
+ * The bound is on ops KEPT, and the frontier is a NODE COUNT — not a shape. Pinned as such rather
662
+ * than argued: branch-shortcircuit.test.ts's "the fold's frontier is the cone's NODE COUNT, not its
663
+ * shape" enumerates all 23,714 binary cone shapes with up to 10 internal nodes (21 nodes) and
664
+ * asserts the PUBLIC fold takes one exactly when `2k + 1 <= budget`.
665
+ * Shape decides only WHICH guard refuses and how many ops had been
666
+ * minted when it did, neither of them visible to a caller — at budget 8 a 15-node cone is caught by
667
+ * the ENTRY guard at 9 (left chain) or 8 (balanced cone) or by the POST-check at 15 (right chain).
668
+ * Because `go` pushes a parent only after its children, a refused walk transiently mints more than
669
+ * the budget before unwinding — `2 * budget - 1` at the worst shape, that right chain, at every
670
+ * budget measured. Bounded, and nothing escapes it.
671
+ *
672
+ * A refusal here is SILENT, unlike the `onTreeOwned` gate above which exists so a sweep need not
673
+ * re-instrument. So raising this constant is not free advice: finding a corpus site that wants it
674
+ * means patching a hook back into this file. No callback is added because no consumer has asked for
675
+ * one; test/branch-shortcircuit.test.ts pins both sides of the frontier instead. */
676
+ const NEGATE_BUDGET = 8;
677
+
678
+ /** The ops computing `!v`, or `null` when `v` cannot be negated.
679
+ *
680
+ * Two cases, and no third:
681
+ * - an `icmp_*` in `NEGATED_ICMP` → the swapped-opcode comparison over the SAME operands.
682
+ * - a `logic_and`/`logic_or` → the DUAL connective over its two recursively negated operands.
683
+ * De Morgan: `!(a || b)` is `!a && !b`. This is the only reason the helper is recursive, and
684
+ * it is what lets a chain fold past its first level (`a || !(b || c)`).
685
+ *
686
+ * REFUSALS — each returns `null`, which the caller turns into its existing `continue`, so the CFG
687
+ * is left exactly as it was and the structurer emits today's tail-duplicated spelling. A bytes
688
+ * miss, never a wrong answer:
689
+ * - the value has no def in this function, which means a block param — a call RESULT has one,
690
+ * `call` declaring `results: 1` (ir/opcodes.ts), and is refused by the next bullet instead;
691
+ * - the def is neither a negatable icmp nor a connective (a call, any arithmetic) — there is no
692
+ * sound inverse to build, and `!x` as `x == 0` is a DIFFERENT spelling, not this fold's
693
+ * business;
694
+ * - ANY leaf anywhere in the cone is non-negatable ⇒ the WHOLE negation is refused. Half a
695
+ * De Morgan is not a conservative approximation of one;
696
+ * - the cone exceeds `NEGATE_BUDGET` minted ops.
697
+ *
698
+ * Ops are MINTED, never mutated: the originals stay where the caller hoisted them and die to the
699
+ * pass list's own `dce: true` (raise/pre-recovery.ts). Only `icmp_*`/`logic_and`/`logic_or` are
700
+ * ever rebuilt, all of them pure, so no effect can be duplicated or reordered by construction.
701
+ *
702
+ * Nothing is SHARED between paths, and that is the point rather than a limitation. Memoizing `go`
703
+ * in a `Map<Value, Value>` is three lines and would make the budget unnecessary — and it would
704
+ * create exactly the hazard this fold exists to remove: a shared negated sub-condition has two
705
+ * consumers, and analysis.ts renders a value with two consumers as a statement BEFORE the `if`.
706
+ * Nothing collapses the duplicates later either — `numberPureValues` runs as `addrnum`, far ahead
707
+ * of both folds. So the per-path rebuild is the mechanism and NEGATE_BUDGET is its price.
708
+ *
709
+ * What it GUARANTEES about its result, which one caller leans on: every op it mints is an `icmp_*`
710
+ * or a `logic_and`/`logic_or`, so the returned value is always a C boolean. The value form hands an
711
+ * un-negated head straight on as the merge VALUE, which is why that caller checks booleanness for
712
+ * itself before deciding whether to call here at all (see its head gate); the branch form needs no
713
+ * such check, because its result only ever feeds a `cond_br`.
714
+ *
715
+ * PRECONDITION, earned by the caller and not checked here: every value in the cone must dominate
716
+ * the point the caller splices `ops` into — the minted comparisons reuse the ORIGINAL leaf
717
+ * operands. The branch fold gets it free from ^g's single-predecessor gate: with ^h ^g's only
718
+ * predecessor, every def in the cone either sits in ^g's body (spliced in ahead of these ops) or
719
+ * dominates ^h. A caller without that invariant emits a def that does not dominate its use — loud
720
+ * at `verify`, but this helper does not look.
721
+ *
722
+ * It stays in this file rather than joining `NEGATED_ICMP` in ir/opcodes.ts: that table is a fact
723
+ * about opcodes, this MINTS ops, and both consumers are the two folds above — the value form and
724
+ * the branch form, which is the whole reason it is a helper and not inline. */
725
+ function negateCondOps(defs: Map<Value, Op>, v: Value, budget: number): { ops: Op[]; result: Value } | null {
726
+ const ops: Op[] = [];
727
+ const go = (x: Value): Value | null => {
728
+ if (ops.length >= budget) {
729
+ return null;
730
+ }
731
+ const d = defs.get(x);
732
+ if (!d) {
395
733
  return null;
396
734
  }
397
- const [x, y] = d.operands;
398
- const xc = defs.get(x)?.opcode === 'const';
399
- const yc = defs.get(y)?.opcode === 'const';
400
- // exactly one side constant — `x == y` between two variables is no switch test
401
- return xc === yc ? null : { scrutinee: xc ? y : x };
735
+ const out = mkValue(T.unk(32));
736
+ if (NEGATED_ICMP[d.opcode]) {
737
+ ops.push(mkOp(NEGATED_ICMP[d.opcode], { operands: [...d.operands], results: [out] }));
738
+ return out;
739
+ }
740
+ if (d.opcode === 'logic_and' || d.opcode === 'logic_or') {
741
+ const a = go(d.operands[0]);
742
+ if (a === null) {
743
+ return null;
744
+ }
745
+ const b = go(d.operands[1]);
746
+ if (b === null) {
747
+ return null;
748
+ }
749
+ // Operands are pushed BEFORE the connective, so the op list is already in dominating order.
750
+ ops.push(mkOp(d.opcode === 'logic_and' ? 'logic_or' : 'logic_and', { operands: [a, b], results: [out] }));
751
+ return out;
752
+ }
753
+ return null;
402
754
  };
403
- const a = eqTest(c1);
404
- const b = eqTest(c2);
405
- return a !== null && b !== null && a.scrutinee === b.scrutinee;
755
+ const result = go(v);
756
+ return result === null || ops.length > budget ? null : { ops, result };
757
+ }
758
+
759
+ /** Do `c1` and `c2` compare the SAME value against CONSTANTS? The signature of a comparison-tree
760
+ * `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests only: a switch
761
+ * dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the range check) is a
762
+ * genuine connective this fold should still take. */
763
+ function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
764
+ const s1 = constTestScrutinee(defs, c1);
765
+ const isEq = (v: Value): boolean => {
766
+ const op = defs.get(v)?.opcode;
767
+ return op === 'icmp_eq' || op === 'icmp_ne';
768
+ };
769
+ return s1 !== null && s1 === constTestScrutinee(defs, c2) && isEq(c1) && isEq(c2);
770
+ }
771
+
772
+ /** Is `c`'s scrutinee compared against constants by MORE THAN ONE `cond_br` in the function?
773
+ *
774
+ * The function-wide question, for the case where the shared block was reached through a relay. A
775
+ * tree's split node is RELATIONAL and its children are equalities (`if (x > 10) { if (x == 20) }`),
776
+ * so the two tests in hand need not look alike, and the one that would give the tree away may be
777
+ * neither of them. Counting every constant test on the scrutinee catches the split either way. */
778
+ function inComparisonTree(fn: Fn, defs: Map<Value, Op>, c: Value): boolean {
779
+ const scrutinee = constTestScrutinee(defs, c);
780
+ if (scrutinee === null) {
781
+ return false;
782
+ }
783
+ let seen = 0;
784
+ for (const b of fn.blocks) {
785
+ const t = b.ops[b.ops.length - 1];
786
+ if (t?.opcode === 'cond_br' && constTestScrutinee(defs, t.operands[0]) === scrutinee && ++seen > 1) {
787
+ return true;
788
+ }
789
+ }
790
+ return false;
791
+ }
792
+
793
+ /** The value an `icmp_* <value>, <const>` tests, or null when `c` is not one. */
794
+ function constTestScrutinee(defs: Map<Value, Op>, c: Value): Value | null {
795
+ const d = defs.get(c);
796
+ if (!d || NEGATED_ICMP[d.opcode] === undefined) {
797
+ return null;
798
+ }
799
+ const [x, y] = d.operands;
800
+ const xc = defs.get(x)?.opcode === 'const';
801
+ const yc = defs.get(y)?.opcode === 'const';
802
+ // exactly one side constant — `x == y` between two variables is no switch test
803
+ return xc === yc ? null : xc ? y : x;
406
804
  }
407
805
 
408
806
  /** True when every value `g` defines is read at most once, and any read is inside `g`.
@@ -413,11 +811,11 @@ function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): bo
413
811
  * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
414
812
  * escape, and only this check stops it.
415
813
  *
416
- * An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
417
- * itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
418
- * WRONG — the entry block dominates every block whatever M's predecessor count and it was wrong
419
- * about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
420
- * form was missing entirely. Both now have it. When changing either fold, check the other. */
814
+ * What the two folds genuinely DO share is the `fn.blocks[0]` refusal, and each has a test that
815
+ * pins its own half: 'a feeder that is the entry block is not folded away' for the value form, and
816
+ * 'the ENTRY block is never folded away' for the branch form. They are deliberately NOT routed
817
+ * through one shared `isEntry` helper a helper enforces nothing, and it is the two tests that
818
+ * hold each fold to the refusal. When changing either fold, check the other. */
421
819
  function definedValuesStayLocal(fn: Fn, g: Block): boolean {
422
820
  const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
423
821
  if (defined.size === 0) {