@asmlift/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +270 -171
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +243 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +78 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +345 -2
  16. package/src/ir/opcodes.ts +176 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/advance.ts +373 -0
  24. package/src/l3/argbase.ts +6 -5
  25. package/src/l3/ast.ts +510 -59
  26. package/src/l3/basecse.ts +686 -78
  27. package/src/l3/coalesce.ts +432 -46
  28. package/src/l3/dce.ts +31 -9
  29. package/src/l3/gates.ts +96 -1
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +176 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +114 -89
  42. package/src/l3/reindex.ts +722 -80
  43. package/src/l3/scopebase.ts +649 -220
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +16 -1
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +687 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +239 -16
  57. package/src/pipeline.ts +173 -60
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/const.ts +203 -3
  61. package/src/raise/divpow2.ts +4 -4
  62. package/src/raise/extscale.ts +342 -0
  63. package/src/raise/globalshape.ts +1058 -0
  64. package/src/raise/gvn.ts +33 -18
  65. package/src/raise/latch.ts +126 -0
  66. package/src/raise/magicdiv.ts +2 -2
  67. package/src/raise/memberarrays.ts +594 -0
  68. package/src/raise/narrow.ts +124 -0
  69. package/src/raise/narrowlocal.ts +572 -0
  70. package/src/raise/paramwidth.ts +201 -0
  71. package/src/raise/pre-recovery.ts +169 -21
  72. package/src/raise/recover.ts +56 -23
  73. package/src/raise/retsink.ts +585 -19
  74. package/src/raise/shortcircuit.ts +1050 -89
  75. package/src/raise/struct-arrays.ts +19 -2
  76. package/src/raise/structs.ts +34 -4
  77. package/src/raise/tailsink.ts +126 -0
  78. package/src/rank-declare.ts +256 -0
  79. package/src/rank-variations.ts +760 -0
  80. package/src/rank.ts +2122 -326
  81. package/src/structure/analysis.ts +1398 -150
  82. package/src/structure/bitfields.ts +432 -0
  83. package/src/structure/globalaccess.ts +300 -0
  84. package/src/structure/hazards.ts +411 -20
  85. package/src/structure/loops.ts +2 -49
  86. package/src/structure/namecoalesce.ts +454 -0
  87. package/src/structure/structure.ts +3979 -612
  88. package/src/structure/switch-recover.ts +710 -145
  89. package/src/symbols.ts +188 -6
  90. package/src/target.ts +495 -32
  91. package/src/trace.ts +112 -33
  92. package/src/variation-definitions.ts +1540 -0
  93. package/src/variation-gates.ts +89 -0
  94. package/src/variation-tokens.ts +355 -0
@@ -1,22 +1,43 @@
1
- // L3 re-spelling lever: hoist a reused global base into a pointer local at the INNERMOST scope
2
- // that contains all of its uses.
1
+ // L3 respell variation: name a reused global base in a pointer local placed by SCOPE rather than at
2
+ // the function top. Two region rules ship, one pass and one collected index behind them:
3
3
  //
4
- // The lever earns its place: returning `null` from `hoistScopedBases` costs
5
- // kleod:UpdateHUDCounterDisplay its match, so the benchmark's zero-lost gate guards this file.
4
+ // `/scopebase` ONE local for a key, at the innermost list holding all of its uses (else the
5
+ // deepest cluster of two).
6
+ // `/regionbase` ONE LOCAL PER REGION — a base the source spells inside N disjoint regions is N
7
+ // locals. agbcc discriminates on how many distinct locals with disjoint live
8
+ // ranges exist, NOT on where they are declared: the three-at-function-top spelling
9
+ // and the three-block-scoped one assemble byte-identically. So there is no nested
10
+ // declaration block here and none is needed — the locals are declared at function
11
+ // top and only their ASSIGNMENTS are placed per region. That compiler fact is
12
+ // PINNED rather than asserted: packages/cli/test/matching/decl-scope-variation.test.ts
13
+ // compiles both spellings through the project's own agbcc and compares the object
14
+ // bytes, and compiles a count-collapsed third spelling to show the COUNT is not
15
+ // free either.
6
16
  //
7
- // `l3/basecse.ts` already hoists a reused leaf base but always to the FUNCTION TOP, and only for
8
- // an `addr`/`const` base. Both limits are load-bearing here, and each costs a real row:
17
+ // The variation earned its place on kleod's kl-eod-decomp rows: returning `null` from `hoistScopedBases`
18
+ // cost kleod:UpdateHUDCounterDisplay its match. That row was retired on 2026-09-13 with its source,
19
+ // so no current benchmark row is known to guard this file; re-measure by ablation on the current
20
+ // rows before naming one.
9
21
  //
10
- // PLACEMENT. A base used only inside one `if` arm, hoisted to the function top, is live across
11
- // everything before that arm a live range the original never had, which is the register-pressure
12
- // failure basecse's own loop gate exists for. That argument is why the lever is scope-aware; it is
13
- // NOT a claim about what the lever achieves, and no committed measurement separates the two
14
- // placements (the one that did edited a reference source by hand and cannot be re-run). On
15
- // kleod:UpdateHUDCounterDisplay the primary path declines outright (a later pass retired the phi
16
- // it keyed on, so the base's uses span the function body), and the cluster fallback below is what
17
- // recovers it.
18
- // basecse's header already names the gap "a loop-body base is left
19
- // inline for a future scope-aware hoist" and this is that hoist.
22
+ // `l3/basecse.ts` already hoists a reused leaf base at three positions now, of which two are in
23
+ // the TOP-LEVEL statement list (the function top, or an init's first use where a roster row asks
24
+ // `l3/hoist.ts` for that) and only for an `addr`/`const` base. What is left to this file is one
25
+ // half of the placement question and the whole of eligibility:
26
+ //
27
+ // PLACEMENT NARROWED, NOT OWNED. A base used only inside one `if` arm is live across everything
28
+ // before that arm under either of basecse's FLAT positions a live range the original never had,
29
+ // which is the register-pressure failure basecse's own loop gate exists for. First-use placement
30
+ // narrows that range and does not close it: the init still lands ABOVE the `if`. `l3/hoist.ts`'s
31
+ // third placement, `scope`, now does close it for the run basecse places, so "into a nested list"
32
+ // is no longer this file's alone; what stays here is the base this file can SEE (below) and the
33
+ // COUNT question (`REGION_RULES`), which no placement answers. That argument is why the variation is
34
+ // scope-aware; it is NOT a claim about what the variation achieves, and no committed measurement
35
+ // separates basecse's two flat placements (the one that did edited a reference source by hand and
36
+ // cannot be re-run). On kleod:UpdateHUDCounterDisplay the primary path declines outright (a later
37
+ // pass retired the phi it keyed on, so the base's uses span the function body), and the cluster
38
+ // fallback below is what recovers it. basecse's header names a LOOP-BODY base as left inline for
39
+ // a future scope-aware hoist, and this is that hoist: `scope` cannot serve one, because every
40
+ // gate table paired with it keeps the `loop` rule that refuses the base outright.
20
41
  //
21
42
  // ELIGIBILITY. With a symbol map that states an array's RANK, the access renders as the bare
22
43
  // `gSym[0][i]`, whose base node is a `var` naming the global, not an `addr`. basecse's
@@ -28,39 +49,33 @@
28
49
  // shape. The decomp author's alternative is a no-op read-modify-write (`g[0][K] += 0;`) purely to
29
50
  // force that materialization; naming the base is the same codegen without the quirk.
30
51
  //
31
- // A LEVER, not a rewrite: emitted as an ADDITIONAL candidate (rank.ts `/scopebase`) with the
32
- // differ refereeing, so the un-hoisted spelling is always still in the list and this can never cost
33
- // a match.
52
+ // A VARIATION, not a committed rewrite: both region rules are emitted as ADDITIONAL candidates (rank.ts
53
+ // `/scopebase`, `/regionbase`) with the differ refereeing, so the un-hoisted spelling is always
54
+ // still in the list and neither can cost a match.
34
55
  //
35
56
  // SEMANTICS ARE PRESERVED BY CONSTRUCTION. The hoisted value is a pure ADDRESS of a global — no
36
57
  // load, nothing observable, nothing that can fault — so evaluating it earlier in a scope that
37
58
  // DOMINATES every use is invisible. The rewritten accesses keep their own width/signedness, so
38
- // every stride is unchanged. Domination is the load-bearing half: `collect` and `rewriteStmt` must
39
- // walk the SAME tree, or an access the planner never placed gets repointed at a local whose
40
- // assignment does not reach it compiling C that reads an uninitialized pointer, which neither
41
- // boundary contract catches (they check resolution and deref typing, not definite assignment).
59
+ // every stride is unchanged. Domination is the load-bearing half, and it is CHECKED rather than
60
+ // argued: `assertHoistsDominate` re-walks the emitted tree, because an access repointed at a local
61
+ // whose assignment does not reach it compiles, scores, and can WIN, and no stage-boundary contract
62
+ // sees it (they check resolution, deref typing, and whether a local is written ANYWHERE).
42
63
  //
43
- // ORDERING: `hoistReusedGlobalBases` (basecse) runs unconditionally in `structureChecked`, BEFORE
44
- // rank's levers see the tree. So this pass's `addr`/`const` input is only what basecse REFUSED
45
- // loop uses and repeated-constant-offset uses which is why it carries basecse's const-offset gate
46
- // rather than assuming those bases never arrive.
64
+ // ORDERING: `hoistBaseLocals` (basecse) runs unconditionally in `structureChecked`, BEFORE
65
+ // rank's variations see the tree. So this pass's `addr`/`const` input is what basecse's DEFAULT table
66
+ // refused EVERY gate in it, single-use bases as much as loop and repeated-constant-offset ones —
67
+ // which is why `SCOPEBASE_GATES` re-states basecse's rules rather than assuming those bases never
68
+ // arrive.
69
+ import { assertHoistsDominate } from '../contracts';
47
70
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
48
71
  import type { Expr, SFn, Stmt } from './ast';
49
- import { mapExprChildren, stmtExprs } from './ast';
50
- import { nameAllocator } from './hoist';
72
+ import { mapExprChildren, stmtExprs, stmtLists } from './ast';
73
+ import { type Gate, ablateHeuristic, firstRejection } from './gates';
74
+ import { nameAllocator, takenNames } from './hoist';
75
+ import { addressableGlobals } from './storage';
51
76
 
52
- /** A base this lever may name: a leaf whose value is a fixed address.
53
- *
54
- * `var` is included ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal` alone
55
- * (two call sites in structure.ts, both on the `bareArrayLead` path, which requires
56
- * `shape === 'array'`) — so a `var` base here is always an ARRAY-declared global and `(T *)&gSym`
57
- * is its start address under any declaration. The invariant is worth stating because it is what
58
- * keeps a POINTER-shaped global out: for one of those, `(T *)&gPtr` names the pointer CELL rather
59
- * than the object it points at, which would be silently the wrong address. A local `var` is
60
- * excluded for the ordinary reason: it can be assigned between the hoist point and a use. */
77
+ /** A base this variation may name: a leaf whose value is a fixed address. */
61
78
  type LeafBase = Extract<Expr, { k: 'addr' } | { k: 'const' } | { k: 'var' }>;
62
- const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
63
- e.k === 'addr' || e.k === 'const' || (e.k === 'var' && globals.has(e.name));
64
79
 
65
80
  /** THE identity of a base — what makes two accesses "the same address".
66
81
  *
@@ -68,25 +83,75 @@ const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
68
83
  * cell; keying on the NAME alone means a function that mixes them still sees one base. */
69
84
  const baseId = (b: LeafBase): string => (b.k === 'const' ? `c:${b.value}` : `n:${b.name}`);
70
85
 
71
- /** An access this lever may re-point, or null.
86
+ /** One candidate ACCESS, as the eligibility rules see it. */
87
+ export interface AccessCtx {
88
+ readonly base: LeafBase;
89
+ readonly lead: readonly Expr[] | undefined;
90
+ /** the names declared as GLOBALS in this function — a local or param of the same name is absent */
91
+ readonly addressable: ReadonlySet<string>;
92
+ }
93
+
94
+ /** Which accesses this variation may re-point. BOTH rules are SOUND: each one, removed, makes the
95
+ * rewrite name DIFFERENT BYTES — C that compiles, type-checks and scores, which is the failure
96
+ * mode nothing downstream catches.
72
97
  *
73
- * REFUSES a non-zero `lead`. `lead` pins the leading subscripts of a multidimensional array, so
74
- * `g[1][i]` is a whole ROW past `g[0][i]`. The hoisted local points at the START of the object, and
75
- * the rewrite DROPS the leadsound only when every leading subscript is 0. A non-zero lead would
76
- * silently address the wrong row, which no contract checks: the tree stays well-typed and
77
- * spellable, it just names different bytes. (Today `bareArrayLead` only ever emits zeros; this
78
- * guard is what keeps that an implementation detail rather than a correctness dependency.) */
79
- function eligible(e: Expr, globals: ReadonlySet<string>): Extract<Expr, { k: 'index' }> | null {
80
- if (e.k !== 'index' || !isLeaf(e.base, globals)) {
98
+ * `lead` pins the leading subscripts of a multidimensional array, so `g[1][i]` is a whole ROW past
99
+ * `g[0][i]`; the hoisted local points at the START of the object and the rewrite DROPS the lead.
100
+ * A subscript that is not the literal 0 a recovered row index included is therefore refused.
101
+ *
102
+ * A `var` base is admitted ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal`
103
+ * alone three call sites in structure.ts (`declaredSubscripts`' recovered subscripts, and
104
+ * `bareArrayLead`'s rank-pinned form on each of the byte-address and element-index paths) and
105
+ * the guarantee is not the count but what they SHARE: all three are gated on
106
+ * `structure/globalaccess.ts`'s `bareArrayElement`, which requires `shape === 'array'`. So a `var`
107
+ * base here is always an ARRAY-declared global and `(T *)&gSym` is its start address under any
108
+ * declaration, and a fourth spelling added there inherits that only by going through the same
109
+ * gate. For a POINTER-shaped global `(T *)&gPtr` names the
110
+ * pointer CELL rather than the object it points at; for a LOCAL it names a cell something may
111
+ * assign between the hoist point and a use. */
112
+ export const SCOPEBASE_ELIGIBILITY: readonly Gate<AccessCtx>[] = [
113
+ {
114
+ id: 'nonzero-lead',
115
+ why: 'the rewrite drops `lead`, so a non-zero one would name a different array row',
116
+ sound: true,
117
+ guardedBy: 'scopebase.test.ts: a NON-ZERO lead is refused',
118
+ rejects: (c) => (c.lead ?? []).some((n) => !(n.k === 'const' && n.value === 0)),
119
+ },
120
+ {
121
+ id: 'shadowed-or-nonarray-base',
122
+ why: 'taking `&name` of a local or a pointer-shaped global names a different object',
123
+ sound: true,
124
+ guardedBy: 'addr-placement.test.ts: scopebase declines the shadowed name rather than take its address',
125
+ rejects: (c) => c.base.k === 'var' && !c.addressable.has(c.base.name),
126
+ },
127
+ ];
128
+
129
+ /** An access this variation may re-point, or null. */
130
+ function eligible(
131
+ e: Expr,
132
+ globals: ReadonlySet<string>,
133
+ rules: readonly Gate<AccessCtx>[],
134
+ ): Extract<Expr, { k: 'index' }> | null {
135
+ if (e.k !== 'index') {
81
136
  return null;
82
137
  }
83
- return (e.lead ?? []).every((n) => n === 0) ? e : null;
138
+ const b = e.base;
139
+ if (b.k !== 'addr' && b.k !== 'const' && b.k !== 'var') {
140
+ return null;
141
+ }
142
+ return firstRejection(rules, { base: b, lead: e.lead, addressable: globals }) === null ? e : null;
84
143
  }
85
144
 
86
145
  /** The (base, access-shape) key an access shares with its reuse siblings. Width and signedness are
87
146
  * part of it because the hoisted local carries the access's pointer type — two widths through one
88
147
  * base are two different locals, exactly as in basecse. */
89
- const keyOf = (n: Extract<Expr, { k: 'index' }>): string => `${baseId(n.base as LeafBase)} ${n.width} ${n.signed}`;
148
+ const keyOf = (n: Extract<Expr, { k: 'index' }>): string => scopedBaseKey(n.base as LeafBase, n.width, n.signed);
149
+
150
+ /** The same key, from a base a DIFFERENT pass is holding. `l3/basecse.ts` spells an `addr` base's
151
+ * identity `a:name` where this one spells it `n:name` (it shares that spelling with the bare `var`
152
+ * the rank-aware lift produces, which denotes the same cell), so a caller crossing between the two
153
+ * translates through this rather than comparing strings that can never match. */
154
+ export const scopedBaseKey = (b: LeafBase, width: number, signed: boolean): string => `${baseId(b)} ${width} ${signed}`;
90
155
 
91
156
  /** One use, located by its chain of enclosing statement LISTS (outermost first).
92
157
  *
@@ -96,42 +161,64 @@ const keyOf = (n: Extract<Expr, { k: 'index' }>): string => `${baseId(n.base as
96
161
  interface Site {
97
162
  path: Stmt[][];
98
163
  loop: boolean[];
99
- /** `idx[i]` is the index, within `path[i]`, of the statement this use sits under. Used to place
100
- * the hoist immediately before the FIRST statement that needs it rather than at the list head:
101
- * a call between the assignment and the first use is exactly what forces the pointer into a
102
- * CALLEE-SAVED register and adds the prologue push/pop the original avoided the same failure,
103
- * one level smaller, that this module exists to fix. argbase.ts places by the same rule. */
164
+ /** The chain of statement indices leading to this use, read through `indexWithin` below — which
165
+ * owns the off-by-one against `path`. Used to place the hoist immediately before the FIRST
166
+ * statement that needs it rather than at the list head: a call between the assignment and the
167
+ * first use is exactly what forces the pointer into a CALLEE-SAVED register and adds the
168
+ * prologue push/pop the original avoided the same failure, one level smaller, that this module
169
+ * exists to fix. argbase.ts places by the same rule. */
104
170
  idx: number[];
105
171
  /** the use runs EVERY ITERATION of a loop whose body is not on `path` — a loop's own condition,
106
172
  * or a `for`'s increment. No scope reachable from `path` runs at that cadence, so a key with any
107
173
  * such use is refused outright rather than hoisted to a point that runs once. */
108
174
  perIteration: boolean;
175
+ /** the access node itself — what the rewrite repoints, so `collect` and the rewrite cannot
176
+ * disagree about which uses a plan entry owns */
177
+ node: Extract<Expr, { k: 'index' }>;
109
178
  }
110
179
 
111
- /** Set when the tree holds a shape `collect` and `rewriteStmt` would disagree about — see the
112
- * `for`-part note below. The pass then declines outright. */
113
- let compound = false;
180
+ /** WHERE, within the region statement list at `depth`, the statement holding this use sits.
181
+ *
182
+ * THE OFF-BY-ONE HAS ONE HOME AND THIS IS IT. `collect` starts `path` EMPTY and pushes a statement
183
+ * index for every list it descends, so `idx` carries one entry MORE than `path`: `idx[j + 1]` is
184
+ * the index within `path[j]`. A region is `path[depth - 1]`, so its own index is `idx[depth]` —
185
+ * and `idx[0]` for the depth-0 body region `perRegions` synthesizes, which is why `path` must NOT
186
+ * be seeded with `sfn.body`: seeding it would shift this read for every key in every function. */
187
+ const indexWithin = (u: Site, depth: number): number => u.idx[depth];
188
+
189
+ /** Everything one `collect` run carries that the descent does not change. Bundled rather than
190
+ * spelled as five more positional parameters, and the bundling is what makes the walk RE-ENTRANT:
191
+ * every field, `compound` included, is built per call, so nothing one run decides can reach the
192
+ * next. A refusal held in module scope would instead need a reset the caller cannot forget. */
193
+ interface CollectState {
194
+ readonly globals: ReadonlySet<string>;
195
+ /** key → its uses and the access the local's type is taken from, in first-appearance order */
196
+ readonly out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }> }>;
197
+ readonly rules: readonly Gate<AccessCtx>[];
198
+ /** every eligible node already visited — meeting one twice is one object at two tree positions */
199
+ readonly seenNodes: Set<Expr>;
200
+ /** the KEYS that sharing refuses, filled here and read in `planScopedBases`, where the argument
201
+ * for refusing per KEY rather than per function is written out */
202
+ readonly sharedKeys: Set<string>;
203
+ /** the tree holds a shape `collect` and `rewriteStmt` would disagree about — see the `for`-part
204
+ * note below. The pass then declines outright. */
205
+ compound: boolean;
206
+ }
114
207
 
115
208
  /** Walk every expression in the tree, recording each eligible access's key and its scope path. */
116
- function collect(
117
- body: Stmt[],
118
- globals: ReadonlySet<string>,
119
- out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }>,
120
- path: Stmt[][],
121
- loop: boolean[],
122
- idxPath: number[],
123
- ): void {
209
+ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[], st: CollectState): void {
124
210
  let at = 0;
125
211
  const visit = (e: Expr, perIteration: boolean): void => {
126
- const ix = eligible(e, globals);
212
+ const ix = eligible(e, st.globals, st.rules);
127
213
  if (ix) {
128
214
  const k = keyOf(ix);
129
- const rec = out.get(k) ?? { uses: [], sample: ix, constOff: new Map<number, number>() };
130
- rec.uses.push({ path, loop, perIteration, idx: [...idxPath, at] });
131
- if (ix.idx.k === 'const') {
132
- rec.constOff.set(ix.idx.value, (rec.constOff.get(ix.idx.value) ?? 0) + 1);
215
+ if (st.seenNodes.has(ix)) {
216
+ st.sharedKeys.add(k);
133
217
  }
134
- out.set(k, rec);
218
+ st.seenNodes.add(ix);
219
+ const rec = st.out.get(k) ?? { uses: [], sample: ix };
220
+ rec.uses.push({ path, loop, perIteration, idx: [...idxPath, at], node: ix });
221
+ st.out.set(k, rec);
135
222
  }
136
223
  mapExprChildren(e, (c) => {
137
224
  visit(c, perIteration);
@@ -158,8 +245,8 @@ function collect(
158
245
  // rather than grow a second recursion this REFUSES the whole function — loud decline over a
159
246
  // silently unreachable definition. Delete this when `stmtLists` makes collect/rewrite share
160
247
  // one traversal.
161
- if (childLists(s.init).length > 0 || childLists(s.inc).length > 0) {
162
- compound = true;
248
+ if (stmtLists(s.init).length > 0 || stmtLists(s.inc).length > 0) {
249
+ st.compound = true;
163
250
  }
164
251
  // `init` and `inc` are STATEMENTS, so their expressions are reached by neither `stmtExprs`
165
252
  // nor `childLists` — yet `rewriteStmt` rewrites them. Collect and rewrite MUST see the same
@@ -169,46 +256,31 @@ function collect(
169
256
  stmtExprs(s.init).forEach((e) => visit(e, false));
170
257
  stmtExprs(s.inc).forEach((e) => visit(e, true));
171
258
  }
172
- for (const child of childLists(s)) {
173
- collect(child, globals, out, [...path, child], [...loop, isLoop], [...idxPath, i]);
259
+ for (const child of stmtLists(s)) {
260
+ collect(child, [...path, child], [...loop, isLoop], [...idxPath, i], st);
174
261
  }
175
262
  }
176
263
  }
177
264
 
178
- /** The nested statement LISTS of a statement — the scopes a hoist could land in.
179
- *
180
- * Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
181
- * are single statements, not lists, and a hoist has nowhere legal to go in either (before the loop
182
- * changes when it runs, inside the body repeats it). A `for`'s body IS a list and is included. */
183
- function childLists(s: Stmt): Stmt[][] {
184
- switch (s.k) {
185
- case 'if':
186
- return [s.then, s.else];
187
- case 'while':
188
- case 'dowhile':
189
- case 'for':
190
- return [s.body];
191
- case 'switch':
192
- return [...s.cases.map((c) => c.body), ...(s.default ? [s.default] : [])];
193
- // Exhaustive on purpose — no `default`. A future Stmt kind carrying a nested list must be a
194
- // COMPILE error here, exactly as it is in `stmtChildren`: a silent `[]` would collect that
195
- // kind's uses at the wrong scope while `rewriteStmt`, which IS exhaustive, still rewrote them.
196
- case 'assign':
197
- case 'store':
198
- case 'exprstmt':
199
- case 'return':
200
- case 'break':
201
- case 'continue':
202
- return [];
203
- }
204
- }
205
-
206
265
  /** The innermost statement list common to every use, or null when they span the function body.
207
266
  *
208
267
  * Null is NOT a decline any more: the caller falls through to `deepestCluster`. Kept as a distinct
209
268
  * answer because "one scope holds everything" is the better shape when it exists — every use is
210
269
  * named, not just a cluster. The consolidation this file still owes would make both of these one
211
- * selector parameter over a single collected index. */
270
+ * selector parameter over a single collected index.
271
+ *
272
+ * A THIRD ANSWER TO THE SAME QUESTION now exists and is booked here rather than left for a reader
273
+ * to collide with: `l3/hoist.ts`'s `scopeSite` finds the innermost list holding every MENTION of a
274
+ * minted local, top-down, with no cluster fallback. The two are not merged, and the reason is the
275
+ * domain rather than the algorithm — this one partitions the ACCESSES of a base key it is about to
276
+ * repoint, that one places a statement whose local already exists, so a shared implementation
277
+ * would take the collected index this file owes anyway. THE ONE DIVERGENCE TO CARRY INTO THAT
278
+ * EXTRACTION is the `for` reading `collect` records above: this pass counts a `for`'s `init` at
279
+ * the enclosing cadence, `l3/basecse.ts`'s own census counts it in-loop, and only those two
280
+ * readings are pinned (test/addr-placement.test.ts). `scopeSite` is a THIRD reader of the same
281
+ * position and agrees with this pass — `init`/`inc` are statements no list holds, so a mention in
282
+ * either stops the descent (test/sinkinit.test.ts) — but by its own route, and nothing checks that
283
+ * the two keep agreeing. */
212
284
  function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
213
285
  const first = uses[0].path;
214
286
  let depth = 0;
@@ -218,13 +290,20 @@ function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
218
290
  return depth === 0 ? null : { scope: first[depth - 1], depth };
219
291
  }
220
292
 
221
- /** The DEEPEST statement list holding 2+ uses, with just those uses — the fallback when no single
222
- * scope holds them all.
293
+ /** `'whole'`'s fallback: the DEEPEST statement list holding 2+ uses, with just those uses.
223
294
  *
224
295
  * Ties are broken by first appearance, so emission stays deterministic. Returning a SUBSET is the
225
296
  * whole point: the uses outside the cluster keep their original spelling, which is exactly the
226
297
  * mixed form the compiler produces when it materializes an address in one arm and re-derives it
227
- * elsewhere. */
298
+ * elsewhere.
299
+ *
300
+ * DEEPEST with no size term, and that is a limitation rather than a model of the compiler: a scope
301
+ * with four uses enclosing a nested scope with two names the TWO and leaves the four re-deriving
302
+ * (pinned in test/scopebase.test.ts). Only ONE cluster is ever served here — serving all of them
303
+ * is what `'per-region'` does, under its own admission table.
304
+ *
305
+ * Its uses are RESIDUAL — a list's entry holds every use beneath it, not just its direct ones —
306
+ * which is why `regionsOf` builds its partition itself rather than reusing this. */
228
307
  function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site[] } | null {
229
308
  const byList = new Map<Stmt[], { depth: number; uses: Site[] }>();
230
309
  for (const u of all) {
@@ -243,117 +322,481 @@ function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site
243
322
  return best;
244
323
  }
245
324
 
246
- /** Does any use sit inside a LOOP nested below the chosen scope?
325
+ /** How a key's uses are cut into REGIONS the one dimension this pass varies. Names a `REGION_RULES`
326
+ * entry; it is the only field a production caller passes. */
327
+ export type RegionSelector = 'whole' | 'per-region';
328
+
329
+ interface Region {
330
+ scope: Stmt[];
331
+ depth: number;
332
+ uses: Site[];
333
+ }
334
+
335
+ /** `'whole'`'s partition: ONE region for the key — the innermost list holding every use, else the
336
+ * deepest cluster of two. `body` is unused; the signature is the RULE's, so both partitions are
337
+ * one type and the selector can be a value. */
338
+ function wholeRegion(all: Site[], _body: Stmt[]): Region[] {
339
+ const at = commonScope(all);
340
+ if (at) {
341
+ return [{ scope: at.scope, depth: at.depth, uses: all }];
342
+ }
343
+ const cluster = deepestCluster(all);
344
+ return cluster ? [cluster] : [];
345
+ }
346
+
347
+ /** `'per-region'`'s partition: one region per INNERMOST ENCLOSING LIST, and every partition is
348
+ * served. DIRECT uses only, and the FUNCTION BODY is a region like any other.
247
349
  *
248
- * OVER-REFUSES in two shapes, deliberately: a `do { } while (g[1]) ;` body head and a
249
- * `for (…; …; i = g[5])` body head both DO run at the flagged cadence, so a hoist there would be
250
- * legal. Refusing them costs a missed spelling and nothing else (bench: 0 lost, 0 gained), and the
251
- * precise rule needs the loop-DEPTH model an extraction would bring. Otherwise:
252
- * the hoist would be loop-invariant code motion to a point the original never had — the
253
- * register-pressure failure `basecse.ts`'s own `inLoop` gate refuses, and the reason that gate
254
- * exists. When EVERY use is inside the loop, the common scope IS the loop body: the assignment
255
- * then runs per iteration exactly as the inline spelling did, and there is nothing to refuse. */
256
- function underNestedLoop(uses: Site[], depth: number): boolean {
257
- return uses.some((u) => u.perIteration || u.loop.slice(depth).some(Boolean));
350
+ * Not the residual-subtree rule (a list's entry holding every use beneath it, which is what
351
+ * `deepestCluster` builds): under that rule the body region holds the arms' uses too, and the
352
+ * loop and offset rules then judge a region on uses that are not in it. Not an ANTICHAIN of
353
+ * scope-disjoint regions either the body list encloses both arms and is served anyway. What
354
+ * separates a region from the ones nested in it is only which uses are direct.
355
+ *
356
+ * The synthetic depth-0 entry is why `collect`'s `path` is NOT seeded with `sfn.body`
357
+ * `indexWithin` owns that invariant and states what re-seeding would cost. */
358
+ function perRegions(all: Site[], body: Stmt[]): Region[] {
359
+ const byList = new Map<Stmt[], Region>();
360
+ for (const u of all) {
361
+ const depth = u.path.length;
362
+ const scope = depth === 0 ? body : u.path[depth - 1];
363
+ const e = byList.get(scope) ?? { scope, depth, uses: [] };
364
+ e.uses.push(u);
365
+ byList.set(scope, e);
366
+ }
367
+ // insertion order — first appearance of each region, so emission stays deterministic
368
+ return [...byList.values()];
258
369
  }
259
370
 
260
- /**
261
- * The `/scopebase` re-spelling, or null when nothing qualifies (the caller then adds no candidate
262
- * rather than a duplicate of the primary).
263
- */
264
- export function hoistScopedBases(sfn: SFn): SFn | null {
265
- compound = false;
266
- // A name that is BOTH a declared global and a local/param is not safely a global here: `&g` would
267
- // take the address of the LOCAL, silently a different object. Excluded rather than assumed apart.
268
- const shadowed = new Set([...sfn.locals.map((l) => l.name), ...sfn.params.map((p) => p.name)]);
269
- const globals = new Set((sfn.globals ?? []).map((g) => g.name).filter((n) => !shadowed.has(n)));
270
- const found = new Map<
271
- string,
272
- { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }
273
- >();
274
- collect(sfn.body, globals, found, [], [], []);
275
- if (compound) {
276
- return null;
371
+ /** Is some literal offset reached twice among these uses? Tallied from the SITES, so the answer is
372
+ * scoped by whichever set the caller judges the key's, or one region's. */
373
+ function repeatsAConstOffset(uses: Site[]): boolean {
374
+ const seen = new Set<number>();
375
+ for (const u of uses) {
376
+ const i = u.node.idx;
377
+ if (i.k === 'const') {
378
+ if (seen.has(i.value)) {
379
+ return true;
380
+ }
381
+ seen.add(i.value);
382
+ }
383
+ }
384
+ return false;
385
+ }
386
+
387
+ /** The two loop facts, split apart because they are two different rules with two different
388
+ * arguments — see the gate table.
389
+ *
390
+ * `perIteration` OVER-REFUSES in two shapes, deliberately: a `do { … } while (g[1]) ;` body head
391
+ * and a `for (…; …; i = g[5])` body head both DO run at the flagged cadence, so a hoist there
392
+ * would be legal. Refusing them costs a missed spelling and nothing else (bench: 0 lost, 0
393
+ * gained), and the precise rule needs a loop-DEPTH model. When EVERY use is inside the loop the
394
+ * scope IS the loop body: the assignment then runs per iteration exactly as the inline spelling
395
+ * did, and `nestedLoop` is false — there is nothing to refuse. */
396
+ const runsPerIteration = (uses: Site[]): boolean => uses.some((u) => u.perIteration);
397
+ const underNestedLoop = (uses: Site[], depth: number): boolean => uses.some((u) => u.loop.slice(depth).some(Boolean));
398
+
399
+ /** One candidate REGION, as the admission rules see it. */
400
+ export interface RegionCtx {
401
+ /** how many uses the local would serve */
402
+ readonly uses: number;
403
+ /** some constant offset is reached twice through this base */
404
+ readonly repeatedConstOffset: boolean;
405
+ /** some use runs at a cadence no reachable scope has — a loop's own condition, a `for`'s inc */
406
+ readonly perIteration: boolean;
407
+ /** some use sits inside a loop nested BELOW the region */
408
+ readonly nestedLoop: boolean;
409
+ /** how many of this key's regions hold two or more direct uses */
410
+ readonly siblingRegions: number;
411
+ }
412
+
413
+ /** The rules judged over a POPULATION OF USES — the half the region rule re-reads. Split out
414
+ * because that is what `perRegionReading` below renames, so a fourth counting rule is renamed by
415
+ * construction instead of by remembering to extend a list of ids. */
416
+ const COUNTING_RULES: readonly Gate<RegionCtx>[] = [
417
+ {
418
+ id: 'single-use',
419
+ why: 'a base accessed once is as cheap to load again as to hold in a named local',
420
+ sound: false,
421
+ rejects: (c) => c.uses < 2,
422
+ },
423
+ {
424
+ id: 'repeated-const-offset',
425
+ why: 'a fixed offset read and then written is one scalar update, and the compiler loads its address again for it',
426
+ sound: false,
427
+ rejects: (c) => c.repeatedConstOffset,
428
+ },
429
+ ];
430
+
431
+ /** The rules judged over the REGION's own loop facts, which every region rule reads the same way
432
+ * (`RegionRule.judged` says why: no reachable scope answers for a use outside the region). */
433
+ const LOOP_RULES: readonly Gate<RegionCtx>[] = [
434
+ {
435
+ id: 'per-iteration-use',
436
+ why: 'no scope reachable from the use runs as often as a loop condition or a `for` increment',
437
+ sound: false,
438
+ rejects: (c) => c.perIteration,
439
+ },
440
+ {
441
+ id: 'nested-loop-use',
442
+ why: 'hoisting out of a nested loop is code motion to a point the original never had',
443
+ sound: false,
444
+ rejects: (c) => c.nestedLoop,
445
+ },
446
+ ];
447
+
448
+ /** The admission rules. NONE is sound: a wrong decision here names the same address in a different
449
+ * place, so it costs bytes and a match, never meaning — the eligibility table above is where
450
+ * meaning is at stake, and `rank.ts` keeps the un-hoisted spelling beside every candidate.
451
+ *
452
+ * `repeated-const-offset` and `nested-loop-use` are inherited from `BASECSE_GATES`, which learned
453
+ * them by losing the ProcessHBlankWait match and by forcing a callee-saved register across a loop.
454
+ * `per-iteration-use` is the half of the loop question basecse never faces: this pass places into
455
+ * a NESTED list, and no reachable list runs at a loop condition's cadence.
456
+ *
457
+ * `repeated-const-offset` is an EXTRAPOLATION on half this pass's input, and honestly so: the
458
+ * evidence is a `const` MMIO address, and the `var` (array-global) half is input basecse never
459
+ * saw. It also SLIPS on a fixed offset not spelled as a literal — two identical `g[i]` accesses
460
+ * are not tallied. rank's `/livebase` takes the OPPOSITE side, ablating the same rule in
461
+ * `LIVEBASE_GATES` for the poll shapes it mispredicts, and the differ arbitrates.
462
+ *
463
+ * MEASURED REACH, so a reader prices these from evidence rather than from the table's existence.
464
+ * Instrumented census over the klonoa corpus with no symbol map (469 `.s`, 257 lifting functions),
465
+ * every rule evaluated on every admission context the pass builds. `any` counts the contexts a
466
+ * rule rejects; `first` the ones where it is the DECIDING rejection, which is the only column that
467
+ * prices it — `firstRejection` short-circuits, so a rule that is never first changes no decision.
468
+ * `relaxed` re-asks `first` with the counting rules dropped, which is what separates a rule that
469
+ * decides nothing from one MASKED by the two that precede it:
470
+ *
471
+ * table contexts rule any first relaxed
472
+ * SB 79880 single-use 24268 24268 —
473
+ * repeated-const-offset 54120 54120 —
474
+ * per-iteration-use 8784 0 8784
475
+ * nested-loop-use 6560 656 4768
476
+ * RB 512760 region-single-use 393153 393153 —
477
+ * region-repeated-const-offset 94857 94857 —
478
+ * per-iteration-use 32586 0 32586
479
+ * regions-degenerate 353352 846 331530
480
+ *
481
+ * So `per-iteration-use` decides nothing on this corpus in either table, and ablating it moves no
482
+ * gating row. It is kept, and the `relaxed` column is why: every context it rejects it would
483
+ * decide, the moment a counting rule stopped rejecting first. Dropping a masked rule is a change
484
+ * one corpus licenses; `nested-loop-use` left the region table on a proof that it CANNOT fire
485
+ * there, which is a different standard and the one this file holds.
486
+ *
487
+ * A SECOND POPULATION, AT A DIFFERENT GRAIN, agrees on the masked rule. Over the artifact's 404
488
+ * agbcc rows — `decompile()`'s default structuring, map-LESS, one tree per row — read per KEY
489
+ * through `planScopedBases(...).refusals` rather than per admission context: 201 keys reach the
490
+ * tables. Under `'whole'` the deciding refusal is `repeated-const-offset` on 57 keys and
491
+ * `single-use` on 52 (92 keys served); under `'per-region'` it is `region-single-use` on 133,
492
+ * `region-repeated-const-offset` on 50 and `regions-degenerate` on 8 (10 served).
493
+ * `per-iteration-use` is the deciding refusal for ZERO keys in either table, and `nested-loop-use`
494
+ * for zero of the `'whole'` ones.
495
+ *
496
+ * BOTH CENSUSES ARE MAP-LESS, and neither says anything about the arm the real tier runs on: under
497
+ * a symbol map an absolute pool constant lifts to a `gaddr`, so the base population this pass is
498
+ * handed is a different one. */
499
+ export const SCOPEBASE_GATES: readonly Gate<RegionCtx>[] = [...COUNTING_RULES, ...LOOP_RULES];
500
+
501
+ /** A counting rule's PER-REGION reading. Same predicate, different POPULATION — under `'whole'` it
502
+ * is judged over the KEY's uses (the cluster fallback serves a SUBSET of them, so the two really
503
+ * do differ), under `'per-region'` over one region's direct uses. One id naming two predicates
504
+ * makes `without(table, id)` two different ablations and a price table ambiguous about which
505
+ * reading it priced, so the per-region reading gets its own id. */
506
+ const perRegionReading = (g: Gate<RegionCtx>): Gate<RegionCtx> => ({
507
+ ...g,
508
+ id: `region-${g.id}`,
509
+ why: `counted over one region’s own uses: ${g.why}`,
510
+ });
511
+
512
+ /** `/regionbase`'s admission (rank.ts): the per-region readings of `SCOPEBASE_GATES`, MINUS the one
513
+ * rule the region rule makes vacuous, plus the one that exists only once a key can hold MORE THAN
514
+ * ONE local.
515
+ *
516
+ * `nested-loop-use` CANNOT FIRE under `'per-region'` and is dropped rather than left in the table
517
+ * reading as safety. `perRegions` sets a region's `depth` to `u.path.length`, and a region is
518
+ * exactly the uses whose innermost enclosing list IS that region — so `u.loop.slice(depth)` is
519
+ * the empty slice for every use it judges, and "a use under a loop BELOW the region" is a shape
520
+ * the partition cannot produce. A use inside a nested loop is its own region, at its own depth.
521
+ * Measured on the PREDICATE, not on table membership, since a rule the table no longer holds
522
+ * cannot be censused through it: over the corpus census above, `underNestedLoop` is true on 0 of
523
+ * 512760 `'per-region'` contexts and on 6560 of 79880 `'whole'` ones, where the rule stays in
524
+ * `SCOPEBASE_GATES` and is load-bearing.
525
+ *
526
+ * `regions-degenerate` is a FAN SAVING, not a codegen model: it counts regions holding two or
527
+ * more DIRECT uses, which is `single-use` applied region-wise and so is computable before any
528
+ * gate runs. Its saving is USUALLY a duplicate — one such region is the function-top question
529
+ * `basecse`/`/livebase`/`/scopebase` already answer — but not always, and this pass produces the
530
+ * counterexample: one arm with three direct uses plus a nested loop holding a fourth is refused
531
+ * under `'whole'` by `nested-loop-use` and here by this rule, and ablating it alone is the only
532
+ * way to reach the hoist (test/regionbase.test.ts). A heuristic, and the differ referees what it
533
+ * admits.
534
+ *
535
+ * ONE RULE HERE IS PRICED BY A ROW; three are not. Ablating `region-single-use` moves
536
+ * `synthetic:dmascope` — the variation's own row — while `region-repeated-const-offset`,
537
+ * `per-iteration-use` and `regions-degenerate` each leave all five gating rows exactly where they
538
+ * stand. NO SCORE PAIR IS QUOTED for that move: `dmascope` is MATCH in the committed artifact, so
539
+ * a pair whose unablated endpoint is a nonmatch score describes a corpus state that no longer
540
+ * exists. Re-run the ablation before writing one back. The OVER-SCOPING controls
541
+ * (`synthetic:dmascope1`,
542
+ * `synthetic:offhi_fused`) must stay MATCH but can price nothing here: censused on their own
543
+ * disassembly, `dmascope1` enumerates 6 candidates with 0 carrying `/regionbase` and
544
+ * `offhi_fused` 12 with 0. The other three are guarded by unit fixtures in test/regionbase.test.ts
545
+ * and by the reach census above. */
546
+ export const REGIONBASE_GATES: readonly Gate<RegionCtx>[] = [
547
+ ...COUNTING_RULES.map(perRegionReading),
548
+ ...ablateHeuristic(LOOP_RULES, 'nested-loop-use'),
549
+ {
550
+ id: 'regions-degenerate',
551
+ why: 'a single region is the function-top local that the default hoist and `livebase` already offer',
552
+ sound: false,
553
+ rejects: (c) => c.siblingRegions < 2,
554
+ },
555
+ ];
556
+
557
+ /** THE REGION RULE, as a value. A third rule is one entry here — a partition, a gate table, and
558
+ * the population its counting rules are judged over — rather than three hand-edited branches in
559
+ * three functions, which is the same doctrine `rank-variations.ts` states for its hoist roster ("one entry
560
+ * here, one gate table, and that table's line in the gate-contract roster — not nine hand-edited
561
+ * sites that can drift"). */
562
+ export interface RegionRule {
563
+ readonly id: RegionSelector;
564
+ /** how a key's uses are cut into regions */
565
+ readonly partition: (all: Site[], body: Stmt[]) => Region[];
566
+ /** the admission table `hoistScopedBases` uses when no ablation is passed */
567
+ readonly gates: readonly Gate<RegionCtx>[];
568
+ /** the uses the COUNTING rules (`uses`, `repeatedConstOffset`) are tallied over. The loop facts
569
+ * are always the region's own — no reachable scope answers for a use outside it. */
570
+ readonly judged: (region: Region, key: Site[]) => Site[];
571
+ }
572
+
573
+ export const REGION_RULES: Record<RegionSelector, RegionRule> = {
574
+ // `'whole'` judges the count and the offset tally over the KEY and the loop facts over the
575
+ // chosen region — the scoping this pass has always used, and the cluster case is why: its region
576
+ // is a SUBSET of the key's uses.
577
+ whole: { id: 'whole', partition: wholeRegion, gates: SCOPEBASE_GATES, judged: (_r, key) => key },
578
+ // `'per-region'` judges every rule over the region's own direct uses — a REFINEMENT and not a
579
+ // relaxation: an offset repeated INSIDE one region is still repeated.
580
+ 'per-region': { id: 'per-region', partition: perRegions, gates: REGIONBASE_GATES, judged: (r) => r.uses },
581
+ };
582
+
583
+ /** `regions` picks the region rule and is the only field a caller passes in production
584
+ * (`'per-region'` is `/regionbase`). The other three are for ABLATION and INJECTION — the
585
+ * differentials in test/scopebase.test.ts re-run the real pass with one rule dropped, and the
586
+ * ownership contract below is shown load-bearing by a `rule` no production caller passes. All
587
+ * three default to what the selector implies. */
588
+ export interface ScopeBaseOpts {
589
+ readonly regions?: RegionSelector;
590
+ readonly eligibility?: readonly Gate<AccessCtx>[];
591
+ readonly gates?: readonly Gate<RegionCtx>[];
592
+ readonly rule?: RegionRule;
593
+ }
594
+
595
+ /** One admitted region, as the applier and a gating caller both read it. `uses` are the ACCESS
596
+ * NODES the entry owns — the same set the counting rules were judged over, so the planner and the
597
+ * rewrite cannot disagree about them. */
598
+ export interface ScopedBaseEntry {
599
+ readonly scope: Stmt[];
600
+ readonly key: string;
601
+ readonly name: string;
602
+ readonly type: IrType;
603
+ readonly base: LeafBase;
604
+ /** index within `scope` the init is spliced at — the first statement that (transitively) uses it */
605
+ readonly before: number;
606
+ readonly uses: readonly Expr[];
607
+ }
608
+
609
+ /** What the pass DECIDED, before anything is applied. `applyScopedBasePlan` is the other half: a
610
+ * caller that has to both COUNT what a key got and rewrite the tree (l3/homesplit.ts) plans once
611
+ * and applies that same plan, rather than re-deriving the decision from the applied tree. */
612
+ export interface ScopedBasePlan {
613
+ /** every eligible key `collect` found, in first-appearance order */
614
+ readonly keys: readonly string[];
615
+ readonly entries: readonly ScopedBaseEntry[];
616
+ /** access node → the local that replaces its base */
617
+ readonly repoint: ReadonlyMap<Expr, string>;
618
+ /** for a key NO region admitted, the id of the rule that refused it first — `'shared-node'` for
619
+ * the pre-gate refusal `sharedKeys` makes. A key with an entry is absent. */
620
+ readonly refusals: ReadonlyMap<string, string>;
621
+ /** the tree holds the `for`-part shape collect and rewrite disagree about: the pass declines */
622
+ readonly compound: boolean;
623
+ }
624
+
625
+ /** THE OWNERSHIP CONTRACT, and it is deliberately not a `Gate`: it is a property of the whole plan,
626
+ * decided after every `RegionCtx` has been judged, so there is no admission context to attach it
627
+ * to — the same reason `sharedKeys` and `compound` are not gates either.
628
+ *
629
+ * Two properties, one failure each, and NEITHER is a compile error downstream:
630
+ *
631
+ * • an access node claimed by two entries. `repoint` is a `Map`, so the second `set` WINS and the
632
+ * access is silently repointed at a local whose assignment need not dominate it — C that
633
+ * compiles, scores, and names a different variable.
634
+ * • a minted name that is not fresh. The applier appends `entries`' names to `sfn.locals`
635
+ * wholesale, so a duplicate emits a duplicate declaration — and freshness is `takenNames`'
636
+ * reading of it, which is what `nameAllocator` mints against: a name that only shadows a
637
+ * PARAMETER, or a body assignment no declaration list carries, is a different variable rather
638
+ * than a duplicate declaration, and that is the failure this contract exists for.
639
+ *
640
+ * Both fire ZERO times under either shipped region rule (the partitions are node-disjoint, and
641
+ * `nameAllocator` re-derives its taken names from the tree it is handed — including across the
642
+ * rank.ts pipe, where the second pass sees the first's mints as taken). They are checks rather
643
+ * than arguments because a future rule, or a merge of two independently-planned runs, breaks
644
+ * either one without breaking a type. */
645
+ export function assertPlanOwnership(
646
+ sfn: SFn,
647
+ entries: readonly { name: string; key: string; uses?: readonly Expr[] }[],
648
+ ): void {
649
+ const taken = takenNames(sfn);
650
+ const claimed = new Set<Expr>();
651
+ for (const e of entries) {
652
+ if (taken.has(e.name)) {
653
+ throw new Error(`scopebase: the plan mints \`${e.name}\`, a name the tree already carries (key ${e.key})`);
654
+ }
655
+ taken.add(e.name);
656
+ for (const u of e.uses ?? []) {
657
+ if (claimed.has(u)) {
658
+ throw new Error(`scopebase: an access is claimed by two plan entries (key ${e.key}, local ${e.name})`);
659
+ }
660
+ claimed.add(u);
661
+ }
662
+ }
663
+ }
664
+
665
+ /** What the pass DECIDES for `sfn` under `opts`, with nothing applied. `applyScopedBasePlan` is the
666
+ * applier and `hoistScopedBases` the two of them in order; a caller that gates a PAIRING on "how
667
+ * many locals did this key get?" reads the plan and applies THAT one (l3/homesplit.ts). */
668
+ export function planScopedBases(sfn: SFn, opts: ScopeBaseOpts = {}): ScopedBasePlan {
669
+ const rules = opts.eligibility ?? SCOPEBASE_ELIGIBILITY;
670
+ const rule = opts.rule ?? REGION_RULES[opts.regions ?? 'whole'];
671
+ const gates = opts.gates ?? rule.gates;
672
+ const globals = addressableGlobals(sfn);
673
+ const found = new Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }> }>();
674
+ /** The KEYS whose tree holds one `index` OBJECT at two positions. The rewrite repoints by node
675
+ * identity, so a shared node is one plan entry claiming two uses it need not dominate.
676
+ *
677
+ * PER KEY, not per function, and the difference is not hypothetical. Nothing in the L3 contract
678
+ * forbids the sharing — `l3/pollguard.ts` already emits it (`{ k: 'if', cond: s.cond, then: [s] }`
679
+ * puts one `cond` object at two tree positions), and it is harmless today only because the shapes
680
+ * are derived AFTER this variation in `rank.ts`, an ordering nothing pins. A whole-function decline
681
+ * would make a future producer that shares one node silently delete every base this pass names —
682
+ * including, until that row was retired (2026-09-13), `kleod:UpdateHUDCounterDisplay`'s match,
683
+ * which returning `null` cost. Refusing the
684
+ * key that actually shares costs that key's spelling and nothing else, and the differ still has
685
+ * every other spelling in the list.
686
+ *
687
+ * Not a `Gate`: it is decided during `collect`, before a `RegionCtx` exists. */
688
+ const sharedKeys = new Set<string>();
689
+ const st: CollectState = { globals, out: found, rules, seenNodes: new Set(), sharedKeys, compound: false };
690
+ collect(sfn.body, [], [], [], st);
691
+ if (st.compound) {
692
+ return { keys: [...found.keys()], entries: [], repoint: new Map(), refusals: new Map(), compound: true };
277
693
  }
278
694
 
279
695
  const fresh = nameAllocator(sfn);
280
- // key (scope list identity, local name)
281
- const plan: { scope: Stmt[]; key: string; name: string; type: IrType; base: LeafBase; before: number }[] = [];
696
+ // one entry per (key, admitted region) several for one key under `'per-region'`
697
+ const entries: ScopedBaseEntry[] = [];
698
+ // INSTRUMENTATION, with no production reader — the only thing that reads it is
699
+ // test/regionbase.test.ts's `…and a key it serves nowhere names the DECIDING rule rather than
700
+ // vanishing`. The caller that gates on this pass counts the ENTRIES a key got, and a decline
701
+ // tells it only `not split`. Recorded because the id separates a region that held too few uses
702
+ // from a shape the pass refuses outright, which is what a probe of this pass has to know.
703
+ // `firstRejection` short-circuits, so this is the DECIDING rule.
704
+ const refusals = new Map<string, string>();
282
705
  for (const [key, rec] of found) {
283
- if (rec.uses.length < 2) {
284
- continue; // one access re-materializes as cheaply as a named local
285
- }
286
- // A constant offset touched 2+ times is a SCALAR re-access at one fixed location (an MMIO
287
- // read-modify-write, a repeated `*p`), which the compiler re-materializes rather than
288
- // register-holds. basecse.ts learned this by LOSING the ProcessHBlankWait match to it. Inherited
289
- // here rather than re-lost — but honestly: the evidence is a `const` MMIO address, and it
290
- // applies cleanly only to the `addr`/`const` half of this pass's input, which is exactly what
291
- // basecse refused and left behind. For the `var` (array-global) half basecse never ran, so this
292
- // is an EXTRAPOLATION, not an inheritance. Conservative direction, so the cost is a missed
293
- // hoist rather than a wrong one. It also SLIPS on a fixed offset not spelled as a literal —
294
- // two identical `g[i]` accesses are not tallied — which basecse acknowledges in its own comment
295
- // and which this pass is MORE exposed to, since it deliberately admits loop-body uses, exactly
296
- // the input basecse's `inLoop` gate kept away from that hole.
297
- if ([...rec.constOff.values()].some((n) => n >= 2)) {
706
+ if (sharedKeys.has(key)) {
707
+ refusals.set(key, 'shared-node'); // one node at two positions see `sharedKeys`
298
708
  continue;
299
709
  }
300
- let at = commonScope(rec.uses);
301
- let uses = rec.uses;
302
- if (!at) {
303
- // The uses span the FUNCTION BODY, so no single scope holds them. Rather than decline, take a
304
- // scope that holds two or more and name the base for THOSE only, leaving the rest as they
305
- // were.
306
- //
307
- // The selection rule is DEEPEST, with no size term, and that is a real limitation rather than
308
- // a model of the compiler: a scope with four uses enclosing a nested scope with two will name
309
- // the TWO and leave the four re-deriving the address. Only ONE cluster is ever served, and
310
- // when two siblings tie on depth the first-appearing wins — arbitrary, not principled.
311
- // Largest-cluster-with-deepest-as-tie-break is the better rule; it is a behaviour change and
312
- // belongs with the placement-selector consolidation, not bolted on here.
313
- //
314
- // NOTE this fires for an `addr`/`const` base too — nothing here tests the base kind. That is
315
- // not a duplicate of basecse's hoist: basecse runs FIRST (see the ordering note in the file
316
- // header), so any `addr`/`const` base reaching this pass is one basecse already REFUSED.
317
- const cluster = deepestCluster(rec.uses);
318
- if (!cluster) {
710
+ const regions = rule.partition(rec.uses, sfn.body);
711
+ const siblingRegions = regions.filter((r) => r.uses.length >= 2).length;
712
+ let served = false;
713
+ for (const r of regions) {
714
+ // WHICH USES the counting rules are tallied over is the RULE's answer, not a branch here
715
+ // see `REGION_RULES`. Nothing here tests the base kind: an `addr`/`const` base reaching this
716
+ // pass is one basecse already REFUSED (see the ordering note in the file header).
717
+ const judged = rule.judged(r, rec.uses);
718
+ const refused = firstRejection(gates, {
719
+ uses: judged.length,
720
+ repeatedConstOffset: repeatsAConstOffset(judged),
721
+ perIteration: runsPerIteration(r.uses),
722
+ nestedLoop: underNestedLoop(r.uses, r.depth),
723
+ siblingRegions,
724
+ });
725
+ if (refused !== null) {
726
+ if (!refusals.has(key)) {
727
+ refusals.set(key, refused);
728
+ }
319
729
  continue;
320
730
  }
321
- at = { scope: cluster.scope, depth: cluster.depth };
322
- uses = cluster.uses;
731
+ const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
732
+ // the earliest statement of the region list that (transitively) holds one of its uses
733
+ // `indexWithin` carries the `idx`/`path` off-by-one this reads through.
734
+ const before = Math.min(...r.uses.map((u) => indexWithin(u, r.depth)));
735
+ served = true;
736
+ entries.push({
737
+ scope: r.scope,
738
+ key,
739
+ name: fresh(),
740
+ type,
741
+ base: rec.sample.base as LeafBase,
742
+ before,
743
+ uses: r.uses.map((u) => u.node),
744
+ });
323
745
  }
324
- if (underNestedLoop(uses, at.depth)) {
325
- continue;
746
+ if (served) {
747
+ refusals.delete(key);
326
748
  }
327
- const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
328
- // the earliest statement of the scope list that (transitively) holds a use
329
- // `path` starts EMPTY, so `idx` carries one entry more than `path`: idx[j+1] is the index
330
- // within path[j]. The scope is path[depth-1], so its index is idx[depth].
331
- const before = Math.min(...uses.map((u) => u.idx[at.depth]));
332
- plan.push({ scope: at.scope, key, name: fresh(), type, base: rec.sample.base as LeafBase, before });
333
749
  }
334
- if (plan.length === 0) {
750
+ assertPlanOwnership(sfn, entries);
751
+ // access node → the local that replaces its base. Built from the SITES a plan entry was judged
752
+ // on, so the set the planner counted and the set the rewrite repoints are the same set by
753
+ // construction rather than by a second predicate that could disagree.
754
+ const repoint = new Map<Expr, string>();
755
+ for (const e of entries) {
756
+ e.uses.forEach((u) => repoint.set(u, e.name));
757
+ }
758
+ return { keys: [...found.keys()], entries, repoint, refusals, compound: false };
759
+ }
760
+
761
+ /**
762
+ * The re-spelling `regions` asks for — `/scopebase` or `/regionbase` — or null when nothing
763
+ * qualifies (the caller then adds no candidate rather than a duplicate of the default).
764
+ */
765
+ export const hoistScopedBases = (sfn: SFn, opts: ScopeBaseOpts = {}): SFn | null =>
766
+ applyScopedBasePlan(sfn, planScopedBases(sfn, opts));
767
+
768
+ /**
769
+ * `plan` applied to the tree it was planned over — null when it decided nothing (the caller then
770
+ * adds no candidate rather than a duplicate of the default).
771
+ *
772
+ * IDENTITY-BOUND to that tree, and not by the type: `scope` is matched against statement LISTS and
773
+ * `repoint` against access NODES, both by reference. A plan from a DIFFERENT tree therefore splices
774
+ * nothing and repoints nothing while still declaring its locals; `assertLocalsWritten` (rank.ts's
775
+ * `respell`) is what makes that loud rather than a silently wrong candidate.
776
+ */
777
+ export function applyScopedBasePlan(sfn: SFn, { entries: plan, repoint, compound: bad }: ScopedBasePlan): SFn | null {
778
+ if (bad || plan.length === 0) {
335
779
  return null;
336
780
  }
781
+ // THE TWO FIELDS MUST AGREE, and they arrive as independent data. `assertPlanOwnership` runs in
782
+ // the planner, so it cannot see an edit made between plan and apply — which is exactly the seam
783
+ // exporting this applier opened. A `repoint` naming a local no entry mints is neither declared
784
+ // nor assigned, and both boundary contracts pass it: `assertResolved` looks for absent names, not
785
+ // for unwritten ones. Checked here, where the two halves meet.
786
+ const minted = new Set(plan.map((p) => p.name));
787
+ for (const name of repoint.values()) {
788
+ if (!minted.has(name)) {
789
+ throw new Error(`scopebase: the plan repoints an access to \`${name}\`, which no entry mints`);
790
+ }
791
+ }
337
792
 
338
- // A plan entry may own only a SUBSET of its key's uses (see deepestCluster), so repointing is
339
- // scoped: a key becomes active when the rewrite enters its scope and inactive on the way out.
340
- // Repointing by key alone would rewrite uses the hoist does not dominate.
341
- // SAFE ONLY because `plan` holds at most one entry per key, so `delete` on the way out cannot
342
- // discard an outer binding. Serving a second cluster for one key — the obvious next step — makes
343
- // that false, and an inner delete would silently unbind the outer one for the rest of its scope:
344
- // a use of an unassigned pointer, the defect class this module has already shipped twice. Switch
345
- // to save/restore (or pass the bindings as an argument) before serving more than one cluster.
346
- const active = new Map<string, string>();
347
793
  const point = (e: Expr): Expr => {
348
- const ix = eligible(e, globals);
349
- if (ix) {
350
- const nm = active.get(keyOf(ix));
351
- if (nm) {
352
- // `lead` is DROPPED the local already points at the object start, and `eligible` has
353
- // established every leading subscript is 0.
354
- const { lead: _drop, ...rest } = ix;
355
- return { ...rest, base: { k: 'var', name: nm }, idx: point(ix.idx) };
356
- }
794
+ const nm = repoint.get(e);
795
+ if (nm) {
796
+ // `lead` is DROPPED — the local already points at the object start, and `nonzero-lead` has
797
+ // established every leading subscript is 0.
798
+ const { lead: _drop, ...rest } = e as Extract<Expr, { k: 'index' }>;
799
+ return { ...rest, base: { k: 'var', name: nm }, idx: point(rest.idx) };
357
800
  }
358
801
  return mapExprChildren(e, point);
359
802
  };
@@ -363,28 +806,12 @@ export function hoistScopedBases(sfn: SFn): SFn | null {
363
806
  // fresh tree in one pass — a two-pass version would compare rebuilt lists that no longer match.
364
807
  const rewriteList = (list: Stmt[]): Stmt[] => {
365
808
  const here = plan.filter((p) => p.scope === list);
366
- // SAVE/RESTORE, not set/delete. A plain delete on the way out is correct only while `plan`
367
- // holds one entry per key; the moment a second cluster for one key is served, an inner exit
368
- // would unbind an OUTER hoist for the rest of its scope — under-repointing silently. Restoring
369
- // makes the nesting correct by construction instead of by an unguarded invariant.
370
- const saved = here.map((p) => [p.key, active.get(p.key)] as const);
371
- for (const p of here) {
372
- active.set(p.key, p.name);
373
- }
374
809
  const rewritten = list.map(rewriteStmt);
375
- for (const [key, prev] of saved) {
376
- if (prev === undefined) {
377
- active.delete(key);
378
- } else {
379
- active.set(key, prev);
380
- }
381
- }
382
810
  // Insert each hoist immediately before the first statement that uses it. Descending by index so
383
811
  // earlier insertions do not shift the positions later ones were computed against. NOTE that two
384
812
  // hoists sharing a `before` come out REVERSED relative to `plan` order — the sort is stable and
385
813
  // descending, so both splice at the same index and the later one ends up first. Deterministic
386
- // and semantically irrelevant, but it is not first-appearance order, which this comment used to
387
- // claim.
814
+ // and semantically irrelevant, but it is not first-appearance order.
388
815
  for (const p of [...here].sort((a, b) => b.before - a.before)) {
389
816
  rewritten.splice(p.before, 0, {
390
817
  k: 'assign',
@@ -436,5 +863,7 @@ export function hoistScopedBases(sfn: SFn): SFn | null {
436
863
  // Declared from `plan`, one per hoist — NOT accumulated inside `rewriteList`, which would emit a
437
864
  // duplicate declaration (non-compiling C) if a `Stmt[]` were ever structurally shared by two tree
438
865
  // positions.
439
- return { ...sfn, body, locals: [...sfn.locals, ...plan.map((p) => ({ name: p.name, type: p.type }))] };
866
+ const out = { ...sfn, body, locals: [...sfn.locals, ...plan.map((p) => ({ name: p.name, type: p.type }))] };
867
+ assertHoistsDominate(out, new Set(plan.map((p) => p.name)));
868
+ return out;
440
869
  }