@asmlift/core 0.5.0 → 0.6.0

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