@asmlift/core 0.6.0 → 0.8.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 (88) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +39 -11
  4. package/src/backend/pascal.ts +2 -2
  5. package/src/codegen-flags.ts +640 -0
  6. package/src/contracts.ts +60 -11
  7. package/src/frontend/disasm.ts +141 -11
  8. package/src/frontend/high-half.ts +149 -0
  9. package/src/frontend/mips.ts +458 -209
  10. package/src/frontend/ppc.ts +332 -67
  11. package/src/frontend/reloc-symbol.ts +109 -0
  12. package/src/frontend/splat.ts +56 -18
  13. package/src/frontend/ssa.ts +127 -30
  14. package/src/frontend/stackargs.ts +420 -0
  15. package/src/frontend/thumb.ts +209 -232
  16. package/src/ir/alias.ts +24 -0
  17. package/src/ir/core.ts +70 -3
  18. package/src/ir/opcodes.ts +52 -7
  19. package/src/ir/parse.ts +7 -1
  20. package/src/ir/simplify.ts +1 -1
  21. package/src/l3/address.ts +2 -2
  22. package/src/l3/advance.ts +373 -0
  23. package/src/l3/argbase.ts +6 -6
  24. package/src/l3/argcopy.ts +269 -0
  25. package/src/l3/ast.ts +110 -22
  26. package/src/l3/basecse.ts +50 -30
  27. package/src/l3/coalesce.ts +118 -61
  28. package/src/l3/gates.ts +75 -1
  29. package/src/l3/hoist.ts +1 -1
  30. package/src/l3/homesplit.ts +13 -13
  31. package/src/l3/initfirst.ts +3 -3
  32. package/src/l3/inlinebase.ts +16 -16
  33. package/src/l3/mentions.ts +68 -5
  34. package/src/l3/mulfirst.ts +3 -3
  35. package/src/l3/nearbase.ts +4 -4
  36. package/src/l3/offmember.ts +5 -5
  37. package/src/l3/parkfirst.ts +6 -6
  38. package/src/l3/pollguard.ts +3 -3
  39. package/src/l3/ptrfield.ts +4 -4
  40. package/src/l3/regspell.ts +8 -8
  41. package/src/l3/reindex.ts +22 -17
  42. package/src/l3/scopebase.ts +32 -29
  43. package/src/l3/sinkinit.ts +7 -7
  44. package/src/l3/slotorder.ts +3 -3
  45. package/src/l3/storage.ts +1 -1
  46. package/src/l3/tailmerge.ts +2 -2
  47. package/src/l3/tailret.ts +70 -0
  48. package/src/l3/typing.ts +3 -3
  49. package/src/l3/unmerge.ts +483 -59
  50. package/src/l3/unreduce.ts +15 -14
  51. package/src/l3/volatileptr.ts +11 -11
  52. package/src/l3/volatileval.ts +11 -11
  53. package/src/l3/volstore.ts +16 -16
  54. package/src/l3/zerosub.ts +6 -6
  55. package/src/mangle.ts +49 -0
  56. package/src/pattern/engine.ts +132 -17
  57. package/src/pipeline.ts +39 -16
  58. package/src/proto.ts +2 -2
  59. package/src/raise/const.ts +203 -3
  60. package/src/raise/divpow2.ts +2 -2
  61. package/src/raise/extscale.ts +345 -0
  62. package/src/raise/globalshape.ts +32 -12
  63. package/src/raise/gvn.ts +2 -2
  64. package/src/raise/magicdiv.ts +2 -2
  65. package/src/raise/memberarrays.ts +4 -4
  66. package/src/raise/narrowlocal.ts +18 -2
  67. package/src/raise/paramwidth.ts +133 -3
  68. package/src/raise/pre-recovery.ts +100 -25
  69. package/src/raise/retsink.ts +389 -19
  70. package/src/raise/shortcircuit.ts +595 -34
  71. package/src/raise/structs.ts +4 -4
  72. package/src/raise/tailsink.ts +141 -0
  73. package/src/rank-declare.ts +21 -13
  74. package/src/{rank-axes.ts → rank-variations.ts} +319 -189
  75. package/src/rank.ts +1176 -805
  76. package/src/structure/analysis.ts +87 -90
  77. package/src/structure/bitfields.ts +130 -30
  78. package/src/structure/globalaccess.ts +30 -4
  79. package/src/structure/namecoalesce.ts +32 -13
  80. package/src/structure/retspell.ts +95 -0
  81. package/src/structure/structure.ts +1425 -201
  82. package/src/structure/switch-recover.ts +101 -8
  83. package/src/symbols.ts +127 -6
  84. package/src/target.ts +374 -44
  85. package/src/trace.ts +28 -19
  86. package/src/variation-definitions.ts +1590 -0
  87. package/src/variation-gates.ts +92 -0
  88. package/src/variation-tokens.ts +356 -0
package/src/l3/basecse.ts CHANGED
@@ -13,14 +13,14 @@
13
13
  // question the source answered per BASE — one register file spelled as a pointer local beside
14
14
  // scalar cells spelled as bare derefs. The `single-cell` gate is what makes the narrower answer
15
15
  // reachable: under `LIVEBASE_BLOCK_GATES` a base every access of which is ONE fixed offset stays
16
- // inline, and rank's LIVEBASE_ADMISSIONS roster emits each table's hoist — and every product of
16
+ // inline, and rank's LIVEBASE_HOISTS roster emits each table's hoist — and every composition onto
17
17
  // it — as its own candidate family, for the differ to referee between them. The unit is
18
18
  // the (base, width, signedness) KEY, not the base — a base read at two widths is two keys, and the
19
19
  // gate can leave one of them inline while the other binds.
20
20
  //
21
21
  // COVERAGE: the roster (rank.ts) is SEVEN rows over FIVE gate tables — two PAIRS share a table and
22
22
  // differ only in placement, `/basefold` with `/basefold/sinkinit` and `/orderbase` with
23
- // `/orderbase/scoped` — and it is a set of hand-picked SUBSETS rather than a narrowness ranking;
23
+ // `/orderbase-scoped` — and it is a set of hand-picked SUBSETS rather than a narrowness ranking;
24
24
  // only `/livebase` ⊇ `/livebase-block` are ordered by inclusion. A table
25
25
  // whose predicate cuts across the others therefore carves out a PARTIAL answer, which is what
26
26
  // `UNFOLDED_GATES` does. Measured at ONE stated scope, `decompile()`'s default structuring,
@@ -61,7 +61,7 @@
61
61
  // and `ldrb r0, [r4, #0x3]` — agbcc CSEs the symbol reference where it re-materializes the integer
62
62
  // — so the inline subscript spelling produces exactly the shape this rule reads as evidence
63
63
  // against it. asmlift lifts that asm back to the correct `((u8 *)&gS)[3]` and then offers the
64
- // named-base respelling anyway. Measured reach: of the 21 keys the symbol half newly admits over
64
+ // spelling through a named base anyway. Measured reach: of the 21 keys the symbol half newly admits over
65
65
  // the artifact's agbcc rows in both symbol-map configurations, 4 are on a base whose address the
66
66
  // tree also uses as a value (2 distinct keys, on `kleod:ProcessInputAndUpdateEntities` and
67
67
  // `pokeemerald:TrySetCantSelectMoveBattleScript`).
@@ -74,14 +74,14 @@
74
74
  // `synthetic:foldhead` its match — which is why `index.operandOff` is carried from the lift
75
75
  // instead of re-derived, and why a committed pass that can drop it is worth a test
76
76
  // (test/basecse.test.ts, the "`operandOff` is provenance" describe). WHAT EACH ROW IS WORTH is
77
- // measured in rank.ts's note on `BASEFOLD_ADMISSIONS`, not here, and the two rows are not worth
77
+ // measured in rank.ts's note on `BASEFOLD_HOISTS`, not here, and the two rows are not worth
78
78
  // the same thing. Promoting the hint to a default would need this paragraph to say something it
79
79
  // does not.
80
80
  //
81
- // It is EVIDENCE and not proof, which is why `BASEFOLD_GATES` below is a lever rather than a
81
+ // It is EVIDENCE and not proof, which is why `BASEFOLD_GATES` below backs a variation rather than a
82
82
  // relaxation of the default table. agbcc folds a subscript but keeps an aggregate MEMBER offset in
83
83
  // the memory operand: `((struct S *)0x3001100)->b` emits `.word 0x3001100` + `ldr [r0, #0x4]`,
84
- // byte-identical to the named-base spelling, and the same holds for a union member and for a
84
+ // byte-identical to the spelling through a named base, and the same holds for a union member and for a
85
85
  // store. So the shape has two sources and asmlift can spell only one of them; rank.ts offers both
86
86
  // and the differ referees.
87
87
  //
@@ -97,7 +97,7 @@
97
97
  // AGGREGATE base (F9 spells a SCALAR global as a bare `var`, which is never an `index`-of-leaf, so
98
98
  // scalar recovery is untouched). Non-leaf bases (a local, a struct-element `p[a0]`,
99
99
  // arithmetic) are excluded: agbcc may re-derive those, so hoisting them can
100
- // MISMATCH (empirically confirmed) — the differ-refereed `/addr-home` axis
100
+ // MISMATCH (empirically confirmed) — the differ-refereed `/addr-home` variation
101
101
  // (structure/analysis.ts homeSharedAddresses) serves the shared gaddr-free ARITHMETIC bases
102
102
  // instead.
103
103
  // The hoisted local carries the access's pointer type, so the
@@ -108,7 +108,7 @@
108
108
  import { assertHoistsDominate } from '../contracts';
109
109
  import { type IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
110
110
  import type { Expr, SFn, Stmt } from './ast';
111
- import { exprChildren, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
111
+ import { exprChildren, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
112
112
  import { type Gate, ablateHeuristic, firstRejection } from './gates';
113
113
  import { type BaseInit, type HoistPlacement, nameAllocator, placeBaseLocals } from './hoist';
114
114
 
@@ -149,8 +149,8 @@ const keyOf = (base: HoistableBase, width: number, signed: boolean): string => `
149
149
  /** The key's own grammar, read back — `<leafId>[ <type>] <width> <signed>`.
150
150
  *
151
151
  * IT LIVES BESIDE `keyOf` BECAUSE THAT IS THE ONLY THING THAT MAKES IT SAFE. The key is a string
152
- * and its readers are elsewhere — `l3/homesplit.ts` builds a candidate LABEL out of it, and a
153
- * label is a candidate's identity — so a second file knowing this grammar is a collision waiting
152
+ * and its readers are elsewhere — `l3/homesplit.ts` builds a candidate's VARIATION out of it, and a
153
+ * candidate's variations are its identity — so a second file knowing this grammar is a collision waiting
154
154
  * for the next base kind (`homeSplitTag` states the one the cast form causes).
155
155
  *
156
156
  * The one space inside a cast's base id is this grammar's own separator, not the type's: every
@@ -240,7 +240,7 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
240
240
  for (const s of stmts) {
241
241
  // A loop's OWN condition (`stmtExprs` of a while/do-while/for) runs every iteration, so a base
242
242
  // there is loop-invariant just like a body use — visit it with `nested`, not the outer flag.
243
- const nested = loop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
243
+ const nested = loop || isLoop(s);
244
244
  for (const e of stmtExprs(s)) {
245
245
  visitExpr(e, nested);
246
246
  }
@@ -357,7 +357,7 @@ export interface BaseKey {
357
357
  * what a pointer local's own initializer STATEMENT produces (see raise/globalshape.ts's header
358
358
  * for the compile that separates the two), while the inline cast produces the other order — so
359
359
  * it is evidence a home is what the source wrote. Read only by `ORDERBASE_GATES` (rank.ts);
360
- * false for every base a compiler that has not opted in produced, which is what keeps the axis
360
+ * false for every base a compiler that has not opted in produced, which is what keeps the variation
361
361
  * off those targets. */
362
362
  orderLicensed: boolean;
363
363
  }
@@ -380,13 +380,13 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
380
380
  // Censused at `decompile()`'s default structuring, map-less, one tree per row over the
381
381
  // artifact's 404 agbcc rows: `ORDERBASE_GATES` admits 12 keys on 10 rows, 11 of them cast keys.
382
382
  id: 'cast-base',
383
- why: 'a struct element’s reinterpret cast is the inline spelling unless the assembly says the base had a home',
383
+ why: 'a struct element’s reinterpret cast is the inline spelling unless the assembly says the base was held in a local',
384
384
  sound: false,
385
385
  rejects: (c) => c.castBase,
386
386
  },
387
387
  {
388
388
  id: 'single-use',
389
- why: 'one access re-materializes as cheaply as a named local',
389
+ why: 'a base accessed once is as cheap to load again as to hold in a named local',
390
390
  sound: false,
391
391
  rejects: reachedOnce,
392
392
  },
@@ -397,8 +397,27 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
397
397
  rejects: (c) => c.inLoop,
398
398
  },
399
399
  {
400
+ // A KNOWN COUNTEREXAMPLE, recorded here rather than fixed. `LIVEBASE_GATES` already names it —
401
+ // an MMIO wait or poll stores and re-reads ONE fixed offset through ONE register the whole
402
+ // time — so this gate rejects a base the target demonstrably held in a register, and the row
403
+ // it costs is carried by `/livebase`, which is this table minus the placement gates.
404
+ //
405
+ // NOT FIXED BY EXCLUDING A VALUELESS READ FROM THE CENSUS: that hides a real access to make
406
+ // the gate right for the wrong reason — the premise is falsified by the function's assembly,
407
+ // not by how the access is spelled. NOT FIXED BY EXEMPTING DEVICE-REGISTER BASES EITHER: that
408
+ // is the sound rule, and its blast radius is every MMIO row in the corpus against a gate this
409
+ // comment records was bought with a real match. It wants its own round and its own zero-flip
410
+ // gate.
411
+ //
412
+ // THE STRUCTURER'S DEAD-READ SPELLING (structure.ts `unreadResult`) INTERACTS, but not on the
413
+ // map-fed default: that spelling requires a qualifier to reach the access, a CAST spelling
414
+ // (`((s32 *)&REG_DMA3SAD)[2]`) carries none, and a map-fed DMA tree spells it exactly that
415
+ // way — so no statement is emitted there, offset 8 is touched once, and the base local
416
+ // survives. The inhabitant is the `/raw-globals` subtree, where the read IS spelled and this
417
+ // gate DOES demote; that subtree's own winner is a `/livebase` candidate, so nothing on the
418
+ // ranked path loses by it. Stated because the demotion is invisible from either file alone.
400
419
  id: 'repeated-const-offset',
401
- why: 'a fixed offset touched twice is a scalar RMW, which the compiler re-materializes',
420
+ why: 'a fixed offset read and then written is one scalar update, and the compiler loads its address again for it',
402
421
  sound: false,
403
422
  rejects: (c) => c.repeatedConstOffset,
404
423
  },
@@ -427,14 +446,14 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
427
446
  export const BASEFOLD_GATES: readonly Gate<BaseKey>[] = [
428
447
  {
429
448
  id: 'single-use-unfolded',
430
- why: 'one access re-materializes as cheaply as a named local, unless its offset survived the fold',
449
+ why: 'a base accessed once is as cheap to load again as to hold in a named local, unless its offset survived the fold',
431
450
  sound: false,
432
451
  rejects: (c) => reachedOnce(c) && !c.unfoldedOffset,
433
452
  },
434
453
  ...ablateHeuristic(BASECSE_GATES, 'single-use'),
435
454
  ];
436
455
 
437
- /** The `/livebase` lever's admission (rank.ts): the default rules with both PLACEMENT heuristics
456
+ /** The `/livebase` variation's admission (rank.ts): the default rules with both PLACEMENT heuristics
438
457
  * ablated, keeping only `single-use`. `loop` and `repeated-const-offset` predict which spelling
439
458
  * the compiler chose, and both predictions have a counterexample — an MMIO poll (`p[2] = go;
440
459
  * while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register the whole
@@ -447,7 +466,7 @@ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
447
466
 
448
467
  /** `/livebase-block`'s admission (rank.ts): `/livebase` plus `single-cell`. The two tables differ
449
468
  * by exactly one gate, so `without(LIVEBASE_BLOCK_GATES, 'single-cell')` is `/livebase`'s own
450
- * admission and this selectivity axis prices by ablation like every other.
469
+ * admission and this selectivity rule prices by ablation like every other.
451
470
  *
452
471
  * `single-cell` GENERATES a narrower candidate; it does not classify, and taking it for a compiler
453
472
  * fact is the way to misuse it. Its counterexample is in this corpus: `synthetic:sizebound`'s
@@ -470,13 +489,13 @@ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
470
489
  * whenever an admission is added to or removed from the roster.
471
490
  * HOW: prefer the edit-free form — import this array and `splice` the gate out of it before the
472
491
  * first `enumerateCandidates` call, since the roster holds a reference to this very object. The
473
- * env-read recipe on BASEFOLD_ADMISSIONS edits files instead, and a tap reverted underneath a
492
+ * env-read recipe on BASEFOLD_HOISTS edits files instead, and a tap reverted underneath a
474
493
  * running process reports ZEROES rather than crashing, which reads exactly like "the rule never
475
494
  * fires"; if you use it, hash the tree either side of the window and quote both hashes.
476
495
  *
477
- * A CENSUS OVER WINNING LABELS CANNOT STAND IN FOR THAT — "only a row whose winner carries
496
+ * A CENSUS OVER WINNERS' VARIATIONS CANNOT STAND IN FOR THAT — "only a row whose winner carries
478
497
  * `/livebase-block` can move" is unsound for the reason rank.ts's `seen` dedup spells out. This
479
- * table's own winning-label census reads 5 rows and read 7 before `/unfolded` shipped, and the
498
+ * table's own census over winners' variations reads 5 rows and read 7 before `/unfolded` shipped, and the
480
499
  * two that left differ: `synthetic:foldpark` by RENAME (byte-identical source, MATCH either
481
500
  * side), `synthetic:unfoldpark` because its winning SPELLING changed, 402 bytes at diff:9 to 397
482
501
  * at MATCH.
@@ -597,7 +616,8 @@ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
597
616
  * reading the population: where `raise/globalshape.ts` shapes a name the structurer usually spells
598
617
  * it bare and no key exists here at all — but a shape is ONE element type for the whole name, so an
599
618
  * access that strides something else keeps its cast and its key. `kleod:SetupBG3WindowOverlay`'s
600
- * `gBgInfo` derives `elemSize 4` and still reaches this table at stride 28, in both arms.
619
+ * `gBgInfo` (a row retired 2026-09-13) derived `elemSize 4` and still reached this table at stride
620
+ * 28, in both arms.
601
621
  *
602
622
  * What this table admits, censused over the artifact's 370 agbcc rows: map-less 8 rows / 10 keys,
603
623
  * map-ful 10 rows / 12 keys. TWO shapes, and the arms differ:
@@ -624,8 +644,8 @@ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
624
644
  * exemption cannot have.
625
645
  *
626
646
  * `loop` and `repeated-const-offset` STAY. Neither is about the base's identity and both are fan
627
- * control; ablating them is `/livebase`'s axis, already on the roster, and a row that wants the
628
- * product is one roster line. THE PRICE OF THAT IS A HOLE, and it is named rather than left for a
647
+ * control; ablating them is `/livebase`'s variation, already on the roster, and a row that wants the
648
+ * pairing is one hoist. THE PRICE OF THAT IS A HOLE, and it is named rather than left for a
629
649
  * reader to find: a licensed base with a use inside a loop is admitted by NO table on the roster —
630
650
  * this one refuses it on `loop`, and every table that ablates `loop` refuses it on `cast-base` or
631
651
  * `single-use` — which is the "a base set that is no row's stays unreachable" debt
@@ -652,7 +672,7 @@ export const ORDERBASE_GATES: readonly Gate<BaseKey>[] = [
652
672
  ...ablateHeuristic(ablateHeuristic(BASECSE_GATES, 'cast-base'), 'single-use'),
653
673
  {
654
674
  id: 'order-licensed',
655
- why: 'nothing in the assembly says this base had a home: the index was scaled first, or the order says nothing',
675
+ why: 'nothing in the assembly says this base was held in a local: the index was scaled first, or the order says nothing',
656
676
  sound: false,
657
677
  rejects: (c) => !c.orderLicensed,
658
678
  },
@@ -718,13 +738,13 @@ function admit(sfn: SFn, gates: readonly Gate<BaseKey>[]): { c: Collected; keys:
718
738
  * `scope` DECLINES, and the overload is how a caller is told: `null` means the placement had
719
739
  * nothing to say about this function, because no init landed inside a nested list. That tree is
720
740
  * byte-for-byte the `first-use` spelling (l3/hoist.ts's `nested`), and the roster withholds the
721
- * `first-use` row for this table deliberately (rank.ts, ORDERBASE_ADMISSIONS) — so returning it
741
+ * `first-use` row for this table deliberately (rank.ts, ORDERBASE_HOISTS) — so returning it
722
742
  * ships the withheld candidate under the scoped row's name.
723
743
  *
724
744
  * IT WITHDRAWS A SPELLING RATHER THAN COLLAPSING A DUPLICATE, which is what the decline costs.
725
- * `ORDERBASE_ADMISSIONS` holds exactly two rows, `head` and `scope`, so nothing is ever enumerated
745
+ * `ORDERBASE_HOISTS` holds exactly two rows, `head` and `scope`, so nothing is ever enumerated
726
746
  * at `first-use` for this table and the refused tree has no twin to fold into — its shape and
727
- * `/volatile` products go with it. Over each project's whole `asm` tree, map-ful: of the 48
747
+ * `/volatile` compositions go with it. Over each project's whole `asm` tree, map-ful: of the 48
728
748
  * functions `ORDERBASE_GATES` admits, 7 place an init inside a nested list and 41 do not, and for
729
749
  * 29 of the 41 the refused spelling is one the `head` row does not already produce. Instrumented
730
750
  * on two of those, both map-ful — the `kleod:StreamCmd_SetBGScroll` row (fan 11), and
@@ -785,13 +805,13 @@ export function hoistBaseLocals(
785
805
  }
786
806
  const out = { ...sfn, body, locals };
787
807
  // The two FLAT placements can only put the run in the top-level list, above every use of it by
788
- // construction. `scope` puts an init inside a nested list, which is where a placing lever can ship
808
+ // construction. `scope` puts an init inside a nested list, which is where a placing variation can ship
789
809
  // the one failure the byte differ rewards — a read of a local whose assignment does not reach it —
790
810
  // so the tree it emits is checked rather than argued (contracts.ts).
791
811
  //
792
812
  // THE POPULATION IS THE MOTION, and `moved` is what the placer says it moved rather than what this
793
813
  // function minted. The leading run this pass inherits is the DEFAULT hoist's, committed by
794
- // `structureChecked` before rank's levers see the tree (pipeline.ts), and `scope` moves those
814
+ // `structureChecked` before rank's variations see the tree (pipeline.ts), and `scope` moves those
795
815
  // inits too — so `newLocals` names less than half of what has to be judged. Real inhabitants, in the
796
816
  // CHECKOUTS rather than in a benchmark row — `DecompressAndLoadLevel` in klonoa and `sub_8052474`
797
817
  // in sa3, both map-ful — each sink one inherited `p0` beside the minted `p1`.
@@ -6,18 +6,15 @@
6
6
  // TWO admission paths live here, each with its own gate table and its own reading of loops. The
7
7
  // SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
8
8
  // loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
9
- // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one `if` picks
10
- // between them, so it asks only whether ANY loop encloses that `if` — a second entry breaks the
11
- // argument however the arms' own loops relate. `coalesceCandidates` offers both.
9
+ // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one BRANCH picks
10
+ // between them an `if`'s two arms or a `switch`'s case bodies alike so it asks only whether ANY
11
+ // loop encloses that branch (a second entry breaks the argument however the arms' own loops relate)
12
+ // and whether fall-through joins the two arms onto one path. `coalesceCandidates` offers both.
12
13
  import { typeToString } from '../ir/types';
13
14
  import type { Expr, SFn, Stmt } from './ast';
14
- import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
15
+ import { exprChildren, isLoop, mapExprChildren, stmtChildren, stmtExprs } from './ast';
15
16
  import { type Gate, firstRejection } from './gates';
16
17
 
17
- /** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
18
- * walk and the arm path's `visit`. */
19
- const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
20
-
21
18
  function namesIn(e: Expr, out: Set<string>): void {
22
19
  // `addr` names a GLOBAL (`&gSym`) or a LOCAL — the structurer renders an `laddr` frame object
23
20
  // as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
@@ -217,7 +214,7 @@ export interface MergePair {
217
214
  * `const-fed` is what keeps three of them (ablate it and the span path offers 273). A rule
218
215
  * refusing every in-loop local would make that bound redundant; this one does not, so any further
219
216
  * relaxation of `const-fed` is a multiplier, and two call sites pay it (`/coalesce` and
220
- * `/scopebase-coalesce`; the `/livebase` pairings enumerate the ARM path and pay
217
+ * `/scopebase/coalesce`; the `/livebase` pairings enumerate the ARM path and pay
221
218
  * ARM_DISJOINT_GATES' `arm-init` instead). */
222
219
  export const COALESCE_GATES: readonly Gate<MergePair>[] = [
223
220
  {
@@ -234,7 +231,7 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
234
231
  },
235
232
  {
236
233
  id: 'volatile',
237
- why: 'a volatile qualifier (object or pointee) is observable and typeToString does not spell it merging strips or adds it',
234
+ why: 'a `volatile` qualifier, on the variable or on what it points to, is observable, and merging would drop or add it',
238
235
  sound: true,
239
236
  guardedBy: 'coalesce.test.ts: a volatile pair never merges',
240
237
  rejects: (c) => c.eitherIsVolatile,
@@ -252,7 +249,7 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
252
249
  },
253
250
  {
254
251
  id: 'const-fed',
255
- why: 'a load-fed local other than a for induction variable, whose feeds are its own is one the compiler had a reason to keep where it was',
252
+ why: 'a local set from a memory load, other than a `for` loop counter, is one the compiler had a reason to keep where it was',
256
253
  sound: false,
257
254
  rejects: (c) => !c.x.constFed || !c.y.constFed,
258
255
  },
@@ -265,20 +262,20 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
265
262
  },
266
263
  {
267
264
  id: 'first-is-write',
268
- why: 'a survivor first MENTIONED by a read would see the absorbed value there',
265
+ why: 'a survivor whose first mention is a read would see the absorbed value there',
269
266
  sound: false,
270
267
  rejects: (c) => !c.y.firstIsWrite,
271
268
  },
272
269
  ];
273
270
 
274
- /** Every legal single merge, each as its own tree — NOT one committed choice.
271
+ /** Every legal single merge, each as its own tree — NOT one committed decision.
275
272
  *
276
273
  * Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
277
274
  * it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
278
275
  * and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
279
276
  * and declaration order is the one that picks it. Emitting no merges at all costs that row its
280
277
  * match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
281
- * `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so every candidate is
278
+ * `/regcopy`'s "the tail decision is allocator-ambiguous, so both are ranked" — so every candidate is
282
279
  * emitted and the differ referees.
283
280
  *
284
281
  * ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
@@ -325,8 +322,11 @@ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SF
325
322
  export interface ArmPair {
326
323
  a: string;
327
324
  b: string;
328
- /** the confining `if` has a loop ancestor, so it can run more than once */
325
+ /** the confining branch has a loop ancestor, so it can run more than once */
329
326
  ifInLoop: boolean;
327
+ /** control can run from one of the two arms INTO the other — `switch` fall-through, the one
328
+ * way two arms of a branch land on a single path. Always false for an `if`. */
329
+ armsJoined: boolean;
330
330
  sameType: boolean;
331
331
  /** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
332
332
  eitherIsVolatile: boolean;
@@ -341,12 +341,13 @@ export interface ArmPair {
341
341
  * different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
342
342
  * so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
343
343
  * there. Two locals confined to
344
- * OPPOSITE arms of one `if` never coexist at runtime: the `if` picks one arm, so no read of either
345
- * can observe the other's write — no liveness reasoning needed. That argument is exactly what the
346
- * `loop` gate here protects: a loop ancestor re-enters the `if`, later entries can take the other
347
- * arm, and a value written on one visit becomes readable on the next. Note this gate wants ANY
348
- * enclosing loop, not the span model's shared-loop rule: never-coexisting is a claim about one
349
- * entry, so a second entry breaks it however the two arms' loops relate. */
344
+ * DIFFERENT arms of one branch never coexist at runtime: the branch picks one arm, so no read of
345
+ * either can observe the other's write — no liveness reasoning needed. `branchArms` says what an
346
+ * arm is, and the argument holds for a `switch`'s case bodies exactly as it does for an `if`'s two.
347
+ *
348
+ * Two gates protect it, one per way it fails. `loop` covers a SECOND ENTRY, which is why it wants
349
+ * ANY enclosing loop rather than the span model's shared-loop rule: never-coexisting is a claim
350
+ * about one entry. `fall-through` covers the failure WITHIN one entry. */
350
351
  export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
351
352
  {
352
353
  id: 'type',
@@ -356,21 +357,28 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
356
357
  },
357
358
  {
358
359
  id: 'volatile',
359
- why: 'a volatile qualifier (object or pointee) is observable merging strips or adds it',
360
+ why: 'a `volatile` qualifier, on the variable or on what it points to, is observable, and merging would drop or add it',
360
361
  sound: true,
361
362
  guardedBy: 'coalesce.test.ts: a volatile pair never merges',
362
363
  rejects: (c) => c.eitherIsVolatile,
363
364
  },
364
365
  {
365
366
  id: 'loop',
366
- why: 'a loop ancestor re-enters the if, so opposite arms both run and a value could cross',
367
+ why: 'a loop ancestor re-enters the branch, so different arms both run and a value could cross',
367
368
  sound: true,
368
- guardedBy: 'coalesce.test.ts: an in-loop if never admits its arm pair',
369
+ guardedBy: 'coalesce.test.ts: never admits its arm pair',
369
370
  rejects: (c) => c.ifInLoop,
370
371
  },
372
+ {
373
+ id: 'fall-through',
374
+ why: 'a case that runs on into the other arm puts both locals on one path, so they coexist',
375
+ sound: true,
376
+ guardedBy: 'coalesce.test.ts: two arms joined by fall-through never merge',
377
+ rejects: (c) => c.armsJoined,
378
+ },
371
379
  {
372
380
  id: 'arm-init',
373
- why: 'a local not const-initialized at its arm’s first mention is one the compiler had a reason to keep — the growth bound const-fed gives the span table',
381
+ why: 'a local its arm does not first set to a constant is one the compiler had a reason to keep apart',
374
382
  sound: false,
375
383
  rejects: (c) => !c.bothArmConstInit,
376
384
  },
@@ -378,7 +386,7 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
378
386
 
379
387
  /** The arm-disjoint merges alone — the class the livebase pairings enumerate (rank.ts): the
380
388
  * demanding row's shared counter is arm-disjoint, and the span-model merges already ride the
381
- * plain /coalesce label, so pairing them too would multiply candidates with no row behind it. */
389
+ * plain /coalesce variation, so pairing them too would multiply candidates with no row behind it. */
382
390
  export function armDisjointCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
383
391
  return armDisjointUnder(ARM_DISJOINT_GATES, sfn).candidates;
384
392
  }
@@ -477,6 +485,44 @@ function mentionIndex(): {
477
485
  return { mentionsOf, mentionsUnder, firstMention };
478
486
  }
479
487
 
488
+ /** The MUTUALLY EXCLUSIVE arms of a branching statement, and which pairs of them control can
489
+ * nevertheless run through together — the one shape both admission sites read, so `if` and
490
+ * `switch` are one rule here rather than two walkers.
491
+ *
492
+ * An `if` has exactly two arms and no way to reach one from the other. A `switch`'s arms are its
493
+ * `case` bodies, and FALL-THROUGH is the one way two of them land on a single path: arm `i` runs
494
+ * into arm `j` exactly when every arm from `i` up to `j` falls through, so the reach is the
495
+ * TRANSITIVE chain and not merely the adjacent pair.
496
+ *
497
+ * ITS REACH, MEASURED, because a soundness argument with no corpus witness should say so. Counted
498
+ * 2026-09-19 with a throwaway counter in this walk, over `pnpm bench sweep --fan --map-modes
499
+ * harness,nomap` — 2,396 records over the 1,198-row corpus: the switch half ADMITS 192 pairs, all
500
+ * on `pokeemerald:SetMauvilleOldManLanguage:agbcc` and none on any other function. Across all five
501
+ * gates, `arm-init` refuses 4,024 and `type` 1,346, while `volatile`, `loop` and `fall-through`
502
+ * refuse **zero** — so what bounds this extension corpus-wide is a COST gate, while both SOUND
503
+ * rules are witnessed only by the fixtures in `coalesce.test.ts`. Re-take the count rather than
504
+ * quoting it: a corpus that grows falsifies the number, not the argument.
505
+ *
506
+ * A `switch`'s `default` body is deliberately NOT an arm. `defaultAt` may place the label between
507
+ * case labels, where the body is reachable both by dispatch and by running on into the arm below
508
+ * it — a path `fallsThrough` does not describe, because the flag indexes the `cases` array the
509
+ * label does not sit in. Rather than reason about a position this file cannot see, no pair
510
+ * involving the default is offered: a merge withheld is a candidate missed, a merge admitted on a
511
+ * path that exists is a wrong answer. */
512
+ const branchArms = (st: Stmt): { arms: Stmt[][]; joined: (i: number, j: number) => boolean } | null => {
513
+ if (st.k === 'if' && st.then.length && st.else.length) {
514
+ return { arms: [st.then, st.else], joined: () => false };
515
+ }
516
+ if (st.k === 'switch') {
517
+ const { cases } = st;
518
+ return {
519
+ arms: cases.map((c) => c.body),
520
+ joined: (i, j) => cases.slice(Math.min(i, j), Math.max(i, j)).every((c) => c.fallsThrough),
521
+ };
522
+ }
523
+ return null;
524
+ };
525
+
480
526
  /** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
481
527
  * same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
482
528
  export function armDisjointUnder(
@@ -498,44 +544,55 @@ export function armDisjointUnder(
498
544
  const l = locals.get(n);
499
545
  return l !== undefined && isVolatileLocal(l);
500
546
  };
547
+ // locals only, and never a name that is ALSO a param — the span path holds the same belief as a
548
+ // gate, and a local shadowing a param would let rename() rewrite the param's own mentions
549
+ const confined = (m: Map<string, number>): string[] =>
550
+ [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
501
551
  const visit = (stmts: Stmt[], inLoop: boolean): void => {
502
552
  for (const st of stmts) {
503
- if (st.k === 'if' && st.then.length && st.else.length) {
504
- const thenM = mentionsOf(st.then);
505
- const elseM = mentionsOf(st.else);
506
- // locals only, and never a name that is ALSO a param — the span path holds the same
507
- // belief as a gate, and a local shadowing a param would let rename() rewrite the param's
508
- // own mentions
509
- const confined = (m: Map<string, number>): string[] =>
510
- [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
511
- for (const a of confined(thenM)) {
512
- for (const b of confined(elseM)) {
513
- // the survivor is the earlier declaration, matching how a shared source local reads.
514
- //
515
- // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into the
516
- // target's frame order at EMIT time (l3/slotorder.ts), after this pass, so `declIdx`
517
- // is the naming walk's order and the choice means "the earlier declaration in the
518
- // source asmlift recovered". Ordering the list any earlier would silently change which
519
- // local survives every arm-disjoint merge on a function whose frame order disagrees
520
- // with its declaration order — exactly the population the ordering exists for.
521
- const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
522
- const refused = firstRejection(gates, {
523
- a: gone,
524
- b: kept,
525
- ifInLoop: inLoop,
526
- sameType: typeOf.get(a) === typeOf.get(b),
527
- eitherIsVolatile: isVolatile(a) || isVolatile(b),
528
- bothArmConstInit:
529
- firstMention(st.then, a) === 'const-write' && firstMention(st.else, b) === 'const-write',
530
- });
531
- if (refused !== null) {
532
- refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
533
- continue;
553
+ const branch = branchArms(st);
554
+ if (branch !== null) {
555
+ const { arms, joined } = branch;
556
+ const confinedIn = arms.map((body) => confined(mentionsOf(body)));
557
+ for (let i = 0; i < arms.length; i++) {
558
+ for (let j = i + 1; j < arms.length; j++) {
559
+ for (const a of confinedIn[i]) {
560
+ for (const b of confinedIn[j]) {
561
+ // the survivor is the earlier declaration, matching how a shared source local
562
+ // reads.
563
+ //
564
+ // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into
565
+ // the target's frame order at EMIT time (l3/slotorder.ts), after this pass, so
566
+ // `declIdx` is the naming walk's order and the choice means "the earlier
567
+ // declaration in the source asmlift recovered". Ordering the list any earlier
568
+ // would silently change which local survives every arm-disjoint merge on a
569
+ // function whose frame order disagrees with its declaration order exactly the
570
+ // population the ordering exists for.
571
+ const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
572
+ const refused = firstRejection(gates, {
573
+ a: gone,
574
+ b: kept,
575
+ ifInLoop: inLoop,
576
+ armsJoined: joined(i, j),
577
+ sameType: typeOf.get(a) === typeOf.get(b),
578
+ eitherIsVolatile: isVolatile(a) || isVolatile(b),
579
+ bothArmConstInit:
580
+ firstMention(arms[i], a) === 'const-write' && firstMention(arms[j], b) === 'const-write',
581
+ });
582
+ if (refused !== null) {
583
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
584
+ continue;
585
+ }
586
+ out.push({
587
+ merged: `${gone}-${kept}`,
588
+ sfn: {
589
+ ...sfn,
590
+ body: rename(sfn.body, gone, kept),
591
+ locals: localsAfterMerge(sfn.locals, gone, kept),
592
+ },
593
+ });
594
+ }
534
595
  }
535
- out.push({
536
- merged: `${gone}-${kept}`,
537
- sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
538
- });
539
596
  }
540
597
  }
541
598
  }
package/src/l3/gates.ts CHANGED
@@ -7,10 +7,14 @@
7
7
  //
8
8
  // `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
9
9
  // which has room; duplicating it here is how a table stops paying for itself.
10
+ //
11
+ // A table a variation's definition names (`variation-gates.ts`) shows its `why` to a reader, in the
12
+ // webapp's variation drawer, so there the label is plain prose: no tag, file or function name, or
13
+ // shouted word. `variation-offers.test.ts` holds it to that.
10
14
  export interface Gate<Ctx> {
11
15
  /** stable, kebab-case; appears in test names and in the contract report */
12
16
  readonly id: string;
13
- /** one line: the reason the rule exists */
17
+ /** one line: the reason the rule exists, readable without the code */
14
18
  readonly why: string;
15
19
  /** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
16
20
  * heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
@@ -86,3 +90,73 @@ export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
86
90
  }
87
91
  return out;
88
92
  }
93
+
94
+ /** A gate table that counts its own refusals — {@link tallying}'s return. */
95
+ export interface Tallied<Ctx> {
96
+ /** Hand this to the pass, in place of the table it wraps. */
97
+ readonly gates: readonly Gate<Ctx>[];
98
+ /** The census so far, most-refused first, ties in table order. A snapshot: counts keep
99
+ * accumulating across every later call, which is what a corpus-wide census wants.
100
+ *
101
+ * THERE IS NO RESET, deliberately — a per-row census is two snapshots DIFFED, not a fresh
102
+ * wrapper per row, because a wrapper is a new table IDENTITY and a reader that keys on one
103
+ * (`rank.ts`'s `censuses` memo) sees a fresh key every row. Counting is keyed by `g.id`, so a
104
+ * table COMPOSED from several should be run past `gateTableDefects` first: two rules sharing an
105
+ * id sum into one number, and the contract test only checks the tables on its own roster. */
106
+ readonly refusals: () => readonly (readonly [string, number])[];
107
+ }
108
+
109
+ /** The same table, wrapping each `rejects` in a counter — so a caller OUTSIDE core can obtain the
110
+ * per-id census that `l3/coalesce.ts` and `structure/namecoalesce.ts` hand-roll into their return
111
+ * type (`l3/scopebase.ts` reports the same attribution per KEY), from any pass that takes its
112
+ * table as a parameter:
113
+ *
114
+ * const t = tallying(UNMERGE_SITE_GATES);
115
+ * unmergeJoins(sfn, { site: t.gates });
116
+ * console.log(t.refusals()); // [['empty-arm', 168], ['no-merge-name', 80]]
117
+ *
118
+ * THAT IS THE API AND NOT YET A CENSUS: nothing exports a corpus of trees to loop over, and a
119
+ * tabled pass's only shipped caller is normally inside core. Taking the census off a REAL
120
+ * enumeration is `pnpm bench gates --pass <id>`, whose header
121
+ * (`apps/benchmark/src/run/gate-census.ts`) holds the measured reasons it is a subcommand rather
122
+ * than a script to copy, and what a SECOND censusable pass costs.
123
+ *
124
+ * WHAT IT COUNTS IS AN EVALUATION THAT ANSWERED TRUE, not a site. Under `firstRejection` — which
125
+ * short-circuits — that is the FIRST rejecter, so this produces the same census the hand-rolled
126
+ * maps do, with the same reading: an id absent from it is starved OR REDUNDANT WITH an earlier
127
+ * rule, and telling the two apart takes the same rule run with the rest of the table empty
128
+ * (`grep -n "ON ITS OWN" packages/core/src/raise/globalshape.ts`, whose dated table ships three
129
+ * rules of the second kind). A consumer that asks the table something else — `.some`, `.filter` —
130
+ * gets one count per evaluation instead, which is a different question and rarely the one wanted.
131
+ *
132
+ * AND IT COUNTS REFUSALS, WHICH IS NOT REACH. A rule can refuse hundreds of times and still change
133
+ * no output, because a later rule or a narrowing outside the table would have refused the same
134
+ * sites: that is the MOVED column, it costs an ablation rather than a census, and `l3/unmerge.ts`'s
135
+ * header carries the worked example of the two disagreeing.
136
+ *
137
+ * IT CHANGES NO BEHAVIOUR: each wrapper's predicate IS the original's, `id`/`why`/`sound`/
138
+ * `guardedBy` are carried, so `without`, `ablateHeuristic` and `gateTableDefects` all still hold
139
+ * over the result. What it does change is the table's IDENTITY, and some readers key on that —
140
+ * `rank.ts`'s `censuses` memo is a `Map` over `Gate<BaseKey>[]` instances — so wrap once and reuse
141
+ * `gates`, rather than per call. */
142
+ export function tallying<Ctx>(gates: readonly Gate<Ctx>[]): Tallied<Ctx> {
143
+ const counts = new Map<string, number>();
144
+ const order = new Map(gates.map((g, i) => [g.id, i]));
145
+ return {
146
+ gates: gates.map((g) => ({
147
+ ...g,
148
+ rejects: (c: Ctx) => {
149
+ const r = g.rejects(c);
150
+ if (r) {
151
+ counts.set(g.id, (counts.get(g.id) ?? 0) + 1);
152
+ }
153
+ return r;
154
+ },
155
+ })),
156
+ refusals: () =>
157
+ [...counts].sort((a, b) => b[1] - a[1] || (order.get(a[0]) ?? 0) - (order.get(b[0]) ?? 0)) as readonly (readonly [
158
+ string,
159
+ number,
160
+ ])[],
161
+ };
162
+ }
package/src/l3/hoist.ts CHANGED
@@ -208,7 +208,7 @@ function scopeSite(list: Stmt[], name: string): { list: Stmt[]; at: number } | n
208
208
  * different bytes. Where no nested list holds every mention this IS `first-use`, which is what
209
209
  * makes it a placement rather than a second policy.
210
210
  *
211
- * These three are the axis a roster admission may state (rank.ts) and the only values
211
+ * These three are the positions a hoist may state (rank.ts) and the only values
212
212
  * `hoistBaseLocals` accepts. */
213
213
  export type HoistPlacement = 'head' | 'first-use' | 'scope';
214
214