@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
package/src/rank.ts CHANGED
@@ -8,111 +8,105 @@
8
8
  // scorer (that stays out of @asmlift/core, which is browser-pure). `rankBy` takes an INJECTED
9
9
  // scoreFn, so the same enumeration feeds the cli's Node/objdiff scorer and the webapp's
10
10
  // wasm/objdiff scorer alike.
11
+ //
12
+ // THREE SIBLING FILES, one job each — never a `rank/` directory, which beside `rank.ts` is a
13
+ // resolver trap:
14
+ // rank.ts this file: the enumeration DRIVER and the two ranking drivers over it.
15
+ // rank-axes.ts the TABLES the driver walks — structuring axes, shape/pre-fan products, the
16
+ // base-CSE admission rosters. Their DECLARATION ORDER is published behaviour
17
+ // (`compareScored` breaks a score tie by enumeration order), so reordering one
18
+ // is a behaviour change and never a tidy-up.
19
+ // rank-declare.ts the DECLARATION half: what a candidate's own asm says about the globals it
20
+ // names, and which of those names a declaration must refuse to claim.
11
21
  import { cBackend } from './backend/c';
12
- import { assertDerefsTyped, assertResolved } from './contracts';
22
+ import { assertDerefsTyped, assertLocalsWritten, assertPlacementSurvives, assertResolved } from './contracts';
13
23
  import type { AsmData } from './frontend/asmdata';
14
24
  import { frontendFor } from './frontend/registry';
15
- import { globalCellOf } from './ir/alias';
16
- import { Fn, type Value, defOpMap } from './ir/core';
25
+ import { hasSetupArgsNarrowing, narrowToSetupArgs } from './frontend/ssa';
26
+ import { Fn, defOpMap } from './ir/core';
17
27
  import { T } from './ir/types';
18
28
  import { verify } from './ir/verify';
19
29
  import { materializeArgBases } from './l3/argbase';
20
30
  import type { LanguageBackend, SFn } from './l3/ast';
21
- import { coalesceCandidates } from './l3/coalesce';
22
- import { registerishSpellings } from './l3/regspell';
31
+ import { type BaseKey, admittedBases, hoistBaseLocals } from './l3/basecse';
32
+ import { armDisjointCandidates, coalesceCandidates } from './l3/coalesce';
33
+ import type { Gate } from './l3/gates';
34
+ import type { HoistPlacement } from './l3/hoist';
35
+ import { homeSplitTag, homeSplitWithholds, splitHomeBases } from './l3/homesplit';
36
+ import { inlinableConstBases, inlineConstBases } from './l3/inlinebase';
37
+ import { mulFirstSums } from './l3/mulfirst';
38
+ import { nearBaseClusters } from './l3/nearbase';
39
+ import { spellOperandMembers } from './l3/offmember';
40
+ import { parkParamsFirst } from './l3/parkfirst';
41
+ import { pointerFields } from './l3/ptrfield';
42
+ import { type RegcopyTail, registerishSpellings } from './l3/regspell';
23
43
  import { reindexWalks } from './l3/reindex';
24
44
  import { hoistScopedBases } from './l3/scopebase';
25
- import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
45
+ import { sinkInitsToFirstUse } from './l3/sinkinit';
46
+ import type { SymbolRef } from './l3/symbol-refs';
47
+ import { type UnreduceResult, unreduceAccumulators } from './l3/unreduce';
48
+ import { deviceVolatileClaims, volatilePtrLocals, volatileSubsetCandidates } from './l3/volatileptr';
49
+ import { volatileValueLocals } from './l3/volatileval';
50
+ import { volatileDeviceStores } from './l3/volstore';
51
+ import { zeroSubNegates } from './l3/zerosub';
26
52
  import { RewritePattern } from './pattern/engine';
27
53
  import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
28
54
  import { type Prototypes, prototypesFromSymbols } from './proto';
55
+ import { inferGlobalArrays, orderLicensedGlobals, sameDerivedShape } from './raise/globalshape';
29
56
  import { runPreRecovery } from './raise/pre-recovery';
30
57
  import { recoverTypes } from './raise/recover';
31
- import { type SymbolMap, symbolsByName } from './symbols';
58
+ import {
59
+ BASEFOLD_ADMISSIONS,
60
+ type BaseAdmission,
61
+ LIVEBASE_ADMISSIONS,
62
+ NO_PIN_KINDS,
63
+ ORDERBASE_ADMISSIONS,
64
+ PRE_FAN_PRODUCTS,
65
+ SHAPE_SUBSETS,
66
+ SIGN_CANDS,
67
+ STRUCTURING_AXES,
68
+ type StructuringAxis,
69
+ UNFOLDED_ADMISSIONS,
70
+ applyShapes,
71
+ createdLocals,
72
+ sameBases,
73
+ } from './rank-axes';
74
+ import {
75
+ type RefusedDeclarationReason,
76
+ bareGlobalAccessFacts,
77
+ bareGlobalSymbols,
78
+ makeRefCollector,
79
+ } from './rank-declare';
80
+ import { type SymbolInfo, type SymbolMap, arrayInnerExtents, isPtrField, symbolsByName } from './symbols';
32
81
  import { type TargetDescription, structureOptionsFor } from './target';
33
82
 
34
- /** The signedness of the entry parameters the classic ambiguity asm cannot resolve.
83
+ /** Pin every SCALAR entry param (index not in `ptrIdx`) to the candidate signedness, before
84
+ * recovery. Answers whether any param was PINNABLE — not whether its type moved: which of the
85
+ * two passes writes first is an accident of enumeration order, and the arms differ exactly where
86
+ * a param can be written at all.
35
87
  *
36
- * Struct LAYOUT is recovered structurally (raise/structs.ts), not as a scored axis here:
37
- * `->field_N` and `[idx]` compile identically, so the differ cannot referee between them. */
38
- const SIGN_CANDS = [
39
- { label: 'unsigned', signed: false },
40
- { label: 'signed', signed: true },
41
- ];
42
-
43
- // A recovered POINTER/aggregate param must NOT be signedness-pinned: pinning a still-`unknown`
44
- // pointer param to a scalar int BEFORE recovery blocks pointer recovery and emits uncompilable
45
- // `*(s32)`. Only genuine scalars carry the signedness axis.
46
- const NO_PIN_KINDS = new Set(['ptr', 'struct', 'array']);
47
-
48
- /** Pin every SCALAR entry param (index not in `ptrIdx`) to the candidate signedness, before recovery. */
49
- function pinScalarParams(fn: Fn, signed: boolean, ptrIdx: Set<number>): void {
88
+ * A param NARROWED by raise/paramwidth.ts is not pinnable: the extension it was narrowed at states
89
+ * the signedness as well as the width agbcc's shift pair by its `asr`/`lsr`, PPC's `extsb`/`extsh`
90
+ * by the opcode — so there is no question for the axis to put to the differ, and pinning would
91
+ * widen it back to 32 bits. */
92
+ function pinScalarParams(fn: Fn, signed: boolean, ptrIdx: Set<number>): boolean {
93
+ let pinnable = false;
50
94
  fn.blocks[0].params.forEach((p, i) => {
51
95
  if (ptrIdx.has(i)) {
52
96
  return;
53
97
  }
54
- if (p.type.kind === 'unknown' || p.type.kind === 'int') {
98
+ if (p.type.kind === 'unknown' || (p.type.kind === 'int' && p.type.width === 32)) {
99
+ pinnable = true;
55
100
  p.type = signed ? T.s(32) : T.u(32);
56
101
  }
57
102
  });
103
+ return pinnable;
58
104
  }
59
105
 
60
- /** Bare-global ACCESS FACTS for name-only map symbols — the width/signedness authority the
61
- * declaration synthesis (declare.ts) uses when the map has no shape. The map knows only the
62
- * NAME (symtab-only projects: marioparty3); the candidate's own IR knows exactly how the cell
63
- * is accessed, and the bare `gSym = v` / `x = gSym` spelling compiles to those bytes only
64
- * under a decl of that exact width (`extern u16 g;` is `sh` where a guessed u32 is `sw`).
65
- * Mirrors structure()'s scalar-global rule: a fact is recorded only for a symbol accessed
66
- * EXCLUSIVELY at offset 0 with ONE width and ONE load signedness — anything else (interior
67
- * offsets, address arithmetic, width or sign conflicts) records nothing, because those
68
- * spellings go through `&gSym` casts where every object decl is address-identical. */
69
- function bareGlobalAccessFacts(fn: Fn): Map<string, { width: number; signed: boolean }> {
70
- const defs = defOpMap(fn);
71
- const symOf = (v: Value): string | null => {
72
- const d = defs.get(v);
73
- return d?.opcode === 'gaddr' && d.attrs.code !== true ? (d.attrs.sym as string) : null;
74
- };
75
- const acc = new Map<string, { widths: Set<number>; signs: Set<boolean>; interior: boolean }>();
76
- const get = (s: string) => acc.get(s) ?? acc.set(s, { widths: new Set(), signs: new Set(), interior: false }).get(s)!;
77
- for (const b of fn.blocks) {
78
- for (const op of b.ops) {
79
- if (op.opcode === 'load' || op.opcode === 'store') {
80
- const s = symOf(op.operands[0]);
81
- if (s) {
82
- const a = get(s);
83
- if ((op.attrs.off as number) !== 0) {
84
- a.interior = true;
85
- } else {
86
- a.widths.add(op.attrs.width as number);
87
- if (op.opcode === 'load') {
88
- a.signs.add(((op.attrs.signed as boolean) ?? false) && (op.attrs.width as number) < 4);
89
- }
90
- }
91
- }
92
- } else if (op.opcode === 'aload' || op.opcode === 'astore') {
93
- const s = symOf(op.operands[0]);
94
- if (s) {
95
- get(s).interior = true;
96
- }
97
- } else {
98
- // any other use of the address (arithmetic, a call arg, a comparison) is interior/escape
99
- for (const o of op.operands) {
100
- const s = symOf(o);
101
- if (s) {
102
- get(s).interior = true;
103
- }
104
- }
105
- }
106
- }
107
- }
108
- const out = new Map<string, { width: number; signed: boolean }>();
109
- for (const [s, a] of acc) {
110
- if (!a.interior && a.widths.size === 1 && a.signs.size <= 1) {
111
- out.set(s, { width: [...a.widths][0], signed: a.signs.has(true) });
112
- }
113
- }
114
- return out;
115
- }
106
+ /** Re-exported so `@asmlift/core/rank` keeps its published surface: `onRefusedDeclaration`'s
107
+ * reason type is declared beside the refusals themselves (rank-declare.ts) and consumed from
108
+ * here. */
109
+ export type { RefusedDeclarationReason };
116
110
 
117
111
  export interface EnumerateOptions {
118
112
  patterns?: RewritePattern[];
@@ -126,6 +120,31 @@ export interface EnumerateOptions {
126
120
  * spelling is unaffected — but a lever that never fires because it always throws is a defect, and
127
121
  * without this it looks identical to a lever that correctly declined. */
128
122
  onLeverError?: (label: string, error: string) => void;
123
+ /** Called once per (name, reason) when the declaration synthesis REFUSES a name the tree
124
+ * references (see `RefusedDeclarationReason`). The name then stays undeclared and the
125
+ * candidate fails loudly in a self-declared world — this is what lets the consumer say which
126
+ * undeclared name was asmlift's own refusal rather than a symbol it never saw. */
127
+ onRefusedDeclaration?: (name: string, reason: RefusedDeclarationReason) => void;
128
+ /** Called with the axis suffix each time a STRUCTURING axis's shared probe gate says this
129
+ * function has no inhabitant for it, so the arm is never enumerated.
130
+ *
131
+ * The two callbacks below report the enumeration's two SILENT candidate-deleting sites, and
132
+ * they exist for `onLeverError`'s reason read one level up: a candidate that was never
133
+ * enumerated is indistinguishable, from outside, from one the differ simply did not pick, and
134
+ * nothing else in the pipeline reports it. A gate that has stopped firing and a gate that
135
+ * correctly declines on every corpus row look identical without this.
136
+ *
137
+ * They ride `EnumerateOptions` rather than `RankedResult` deliberately: these are facts about
138
+ * the enumeration's INTERNALS, and `RankedResult` is the published candidate set.
139
+ *
140
+ * Nothing shipped passes either one, so a channel that had stopped firing would be invisible in
141
+ * exactly the way the channel exists to prevent. `test/enumerate-signals.test.ts` pins that both
142
+ * reach a caller. */
143
+ onAxisGated?: (suffix: string) => void;
144
+ /** Called once per axis point whose structured tree an earlier point already spelled — the tree
145
+ * dedup, which is where most of the cross's factors of two go. See `onAxisGated` for why both
146
+ * are here rather than on the result. */
147
+ onTreeDeduped?: () => void;
129
148
  }
130
149
 
131
150
  /** One distinct candidate spelling — a point in the axis cross (signedness × branch sense ×
@@ -145,14 +164,38 @@ export interface Candidate {
145
164
  * comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
146
165
  * construction, trading named struct fields for anonymous byte offsets. */
147
166
  group: number;
148
- /** the map-derived VALUE references this candidate's tree contains — what the scoring
149
- * layer's declaration synthesis renders. DERIVED, never carried: computed once from the
150
- * exact tree this candidate's source was emitted from, at the moment the candidate is
151
- * finalized (l3/symbol-refs.ts — no pipeline stage caches refs, so they cannot go stale).
152
- * Present on EVERY spelling variant that names mapped symbols — including '/raw-globals',
153
- * whose tree still names pool/reloc-derived globals (it only drops the map's shaped
154
- * SPELLINGS). Absent without a map — synthesis then has nothing to do. */
167
+ /** the DECLARABLE VALUE references this candidate's tree contains — what the scoring layer's
168
+ * declaration synthesis renders. DERIVED, never carried: computed once from the exact tree
169
+ * this candidate's source was emitted from, at the moment the candidate is finalized
170
+ * (l3/symbol-refs.ts — no pipeline stage caches refs, so they cannot go stale). Present on
171
+ * EVERY spelling variant that names such symbols — including '/raw-globals', whose tree still
172
+ * names pool/reloc-derived globals (it only drops the map's shaped SPELLINGS).
173
+ *
174
+ * PRESENT WITHOUT A MAP TOO: a name is read out of the asm's own literal pool or relocation,
175
+ * so "a candidate only names symbols the map knows" is false. Where a map DOES know the name
176
+ * its facts win; the rest are
177
+ * synthesized name-only symbols (`bareGlobalSymbols`) and carry `synthesized: true` — a
178
+ * consumer publishing a byte-exact verdict must show those declarations, because they were
179
+ * fitted to the same asm the verdict is about (see SymbolRef.synthesized). */
155
180
  symbolRefs?: SymbolRef[];
181
+ /** `volatile` claims this spelling makes on one of the target's device registers — the
182
+ * volatility tie-break's input (compareScored). DERIVED from the tree the source was emitted
183
+ * from, like `symbolRefs`, because the qualifier and the address it applies to are often two
184
+ * statements apart and the rendered text cannot pair them. */
185
+ deviceVolatile?: number;
186
+ /** PUBLISHABLE ONLY WHERE THE DIFFER PROVES IT — a byte-exact score, nothing else.
187
+ *
188
+ * The third admission ground, and the narrowest. A lever must preserve semantics by
189
+ * construction (the POLICY note at the respell site), because on a nonmatch row the best
190
+ * spelling is what the user is shown. One spelling cannot meet that bar from inside the pass:
191
+ * `l3/unreduce.ts` moves a memory read into a loop whose stores are all device registers, and
192
+ * on this board a device store can make the DEVICE write ordinary memory (a DMA trigger), which
193
+ * no gate over the C can rule out. What settles it instead is the object: a candidate that
194
+ * assembles to the target's own bytes IS the program, whatever a gate could have proved. So the
195
+ * spelling is offered, scored, and then either wins on proof or is WITHHELD — never shown as a
196
+ * best-effort answer. Both ranking drivers ask `withheldReason`, so neither can publish what the
197
+ * other would not. */
198
+ matchOnly?: true;
156
199
  }
157
200
  /** A candidate paired with its score `S` (the injected scorer's result shape — must carry `.score`). */
158
201
  export interface Scored<S> extends Candidate {
@@ -168,17 +211,111 @@ export interface DroppedCandidate {
168
211
  error: string;
169
212
  }
170
213
 
214
+ /** A candidate that BUILT and SCORED and was then withheld for want of proof (`Candidate.
215
+ * matchOnly`). Kept apart from `dropped`, which means "the scorer refused it": a spelling that
216
+ * compiled fine and simply did not earn publication is a different fact, and folding the two
217
+ * would make the `[dropped]` line report compile failures that never happened. */
218
+ export interface WithheldCandidate {
219
+ label: string;
220
+ score: number;
221
+ /** one line: why publication needed a proof this score did not supply */
222
+ why: string;
223
+ }
224
+
171
225
  export interface RankedResult<S> {
172
226
  best: Scored<S>; // lowest score
173
227
  candidates: Scored<S>[]; // sorted best (lowest) first
174
228
  /** candidates whose scoreFn threw — empty when every spelling built */
175
229
  dropped: DroppedCandidate[];
230
+ /** candidates withheld for want of a byte-exact proof — empty unless a `matchOnly` lever fired */
231
+ withheld: WithheldCandidate[];
232
+ }
233
+
234
+ /** THE publication rule for a `matchOnly` spelling, in one place because there are TWO ranking
235
+ * drivers over one enumeration (this module's sync `rankBy` and the webapp's async await-loop),
236
+ * and a filter written twice is how they come to publish different answers. Null ⇒ publish.
237
+ *
238
+ * `score === 0` is objdiff's byte-exact match (cli objdiff.ts states the equivalence), which is
239
+ * why a bare `.score` suffices and the generic needs no `match` field. */
240
+ export function withheldReason<S extends { score: number }>(c: Candidate, score: S): string | null {
241
+ return c.matchOnly === true && score.score !== 0
242
+ ? 'this spelling rests on a device-behaviour fact no gate over the C can settle; only a byte-exact score proves it'
243
+ : null;
244
+ }
245
+
246
+ /** What a re-spelling lever hands `respell`: its tree, or — when the lever cannot establish the
247
+ * candidate's semantics from inside the pass — the tree paired with that fact. `undefined`/`null`
248
+ * is a decline. */
249
+ type LeverResult = SFn | { sfn: SFn; needsProof: boolean } | null | undefined;
250
+
251
+ /** REQUIRE-ALL composition of re-spelling levers, and the ONE place a proof obligation crosses
252
+ * from one lever to the next.
253
+ *
254
+ * `LeverResult` is a union, so a hand-written composition can spell the obligation away by
255
+ * accident and stay type-correct: `return pointerFields(u.sfn);` in place of
256
+ * `return { sfn: t, needsProof: u.needsProof };` compiles, passes tsc and passes every suite,
257
+ * and publishes as asmlift's answer a spelling that was supposed to be withheld unless byte-exact.
258
+ * Composing through here makes dropping it INEXPRESSIBLE — a caller lists the stages and never
259
+ * touches the flag.
260
+ *
261
+ * The obligation is MONOTONE, which is what lets it be an `or`: it says "no gate over this C can
262
+ * settle the fact this spelling rests on", and a later re-spelling cannot settle a fact about an
263
+ * earlier one. `/ptr-field` re-types a field and never moves a read, so it carries `/unreduce`'s
264
+ * obligation through unchanged rather than discharging it.
265
+ *
266
+ * REQUIRE-ALL, never skip-on-decline: one declining stage declines the whole composition, so the
267
+ * label always names exactly the levers that fired. That is the property the pairing site turns
268
+ * on, and the reason it rejects `applyShapes` — see the POLICY note there. */
269
+ export function composeLevers(sfn: SFn, stages: readonly ((s: SFn) => LeverResult)[]): LeverResult {
270
+ let cur = sfn;
271
+ let needsProof = false;
272
+ for (const stage of stages) {
273
+ const made = stage(cur);
274
+ if (!made) {
275
+ return null;
276
+ }
277
+ cur = 'sfn' in made ? made.sfn : made;
278
+ needsProof = needsProof || ('sfn' in made && made.needsProof);
279
+ }
280
+ return needsProof ? { sfn: cur, needsProof } : cur;
281
+ }
282
+
283
+ /** What one `fanOut` call produced: its spellings, plus the PRIMARY emit's refusal where the
284
+ * backend declined the tree it was handed.
285
+ *
286
+ * RETURNED rather than written to the enumeration's shared `lastEmitError`, because only ONE
287
+ * caller may record one. `fanOut` runs over the row's own tree and over each PRE-FAN product's
288
+ * REWRITTEN tree, and a backend refusal of a rewrite is not a refusal of the row's spelling —
289
+ * letting it reach `lastEmitError` would put the wrong cause on the row's "no spellable
290
+ * candidate" throw. With the value returned, the primary caller records and the pre-fan caller
291
+ * does not, which is the rule made structural instead of saved and restored around the call.
292
+ *
293
+ * A DISCRIMINATED FIELD, not a nullable error: a lever that throws a falsy value is still
294
+ * recorded, where `?? ` would read it as "nothing was thrown". */
295
+ interface FanResult {
296
+ spellings: Spelling[];
297
+ emit?: { error: unknown };
298
+ }
299
+
300
+ /** One emitted spelling of a structured tree: the label suffix naming the lever that produced it,
301
+ * the rendered source, and the tree-derived facts `compareScored` ranks by. */
302
+ interface Spelling {
303
+ suffix: string;
304
+ source: string;
305
+ symbolRefs?: SymbolRef[];
306
+ deviceVolatile?: number;
307
+ /** see `Candidate.matchOnly` — set by a lever that cannot establish its own semantics */
308
+ matchOnly?: true;
176
309
  }
177
310
 
178
311
  /** Emit the DISTINCT type/branch-sense candidate spellings for `name` — PURE, no scoring.
179
- * The ONE difference from `decompile()` is the signedness pin, injected between pre-recovery and
180
- * recoverTypes via the `beforeRecover` hook. Duplicate sources are collapsed so the scorer never
181
- * recompiles an identical spelling. */
312
+ * It differs from `decompile()` in exactly two arguments to the shared spine the signedness
313
+ * pin, injected between pre-recovery and recoverTypes via the `beforeRecover` hook, and the
314
+ * `pre.shortCircuit` connective owner (the raiseRecovered call below states both).
315
+ * Duplicate sources are collapsed so the scorer never
316
+ * recompiles an identical spelling, and the fan runs once per distinct STRUCTURED TREE rather
317
+ * than once per axis point — an axis inert on this function reaches a tree an earlier point
318
+ * already spelled, and every re-spelling is a pure function of that tree. */
182
319
  export function enumerateCandidates(
183
320
  name: string,
184
321
  asm: string,
@@ -186,12 +323,19 @@ export function enumerateCandidates(
186
323
  opts: EnumerateOptions = {},
187
324
  ): Candidate[] {
188
325
  const backend = opts.backend ?? cBackend;
326
+ /** The last refusal from a backend asked to spell a tree — what the empty-enumeration check
327
+ * below reports, so "this backend can spell nothing here" names its reason. */
328
+ let lastEmitError: unknown = null;
189
329
  // Same merge as `decompile`: the project's DWARF signatures fill in what the caller did not
190
330
  // state, so both the annotate pass and the ranked candidates reason about one prototype table.
191
331
  const prototypes = prototypesFromSymbols(opts.symbols, opts.prototypes ?? {});
192
332
  const frontend = frontendFor(target);
193
333
  const baseOpts = {
194
334
  ...structureOptionsFor(target, prototypes[name]?.returnsVoid ?? false),
335
+ // See the same line in pipeline.ts: a backend that cannot print switch fall-through must not
336
+ // be handed a tree carrying one, because its refusal costs the whole candidate (and, when
337
+ // every candidate carries it, the whole row).
338
+ spellSwitchFallthrough: backend.spellsSwitchFallthrough,
195
339
  ...(opts.symbols ? { symbols: symbolsByName(opts.symbols) } : {}),
196
340
  };
197
341
  // Branch-sense is a differ-ranked LEVER, the same class as param signedness: a divergent `if`
@@ -207,11 +351,50 @@ export function enumerateCandidates(
207
351
  // ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
208
352
  // (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
209
353
  // below collapses every variant the anchoring left unchanged.
354
+ //
355
+ // `/defsite/loop-entry` widens it to a LOOP HEADER's entry const (`int s = 0;` above the guard
356
+ // rather than on the edge into the loop). Its own point rather than a widening of `/defsite`
357
+ // because it is a SECOND placement decision: a function carrying both kinds of anchorable const
358
+ // has THREE spellings, and folding the two decisions into one boolean would delete the middle
359
+ // one — measured on klonoa's TransitionSelfRemoveFadeIn, where 448 of the 896 sources `/defsite`
360
+ // reaches became unreachable. Enumerated as a CHAIN (none ⊂ plain ⊂ plain + entry) rather than a
361
+ // 2×2 cross: the fourth point costs another quarter of the whole fan — the anchor dimension
362
+ // multiplies everything below it — and no row has been shown to need it.
363
+ //
364
+ // The four spelling booleans that PREDATE `STRUCTURING_AXES` and are still hand-carried
365
+ // (`bitfields`, `ptrElems`, `declRank` and the anchor pair) start from one record, so a base
366
+ // point's default lives in one place instead of six literals that can disagree. Each entry
367
+ // states only what it VARIES — which is the whole content of the chain above.
368
+ const SPELLING_DEFAULTS = { anchor: false, entry: false, bitfields: true, ptrElems: true, declRank: true };
369
+ const senseAnchor = [
370
+ { ...SPELLING_DEFAULTS, suffix: '', sense: defSense },
371
+ { ...SPELLING_DEFAULTS, suffix: '/flip-branch', sense: !defSense },
372
+ { ...SPELLING_DEFAULTS, suffix: '/defsite', sense: defSense, anchor: true },
373
+ { ...SPELLING_DEFAULTS, suffix: '/flip-branch/defsite', sense: !defSense, anchor: true },
374
+ { ...SPELLING_DEFAULTS, suffix: '/defsite/loop-entry', sense: defSense, anchor: true, entry: true },
375
+ { ...SPELLING_DEFAULTS, suffix: '/flip-branch/defsite/loop-entry', sense: !defSense, anchor: true, entry: true },
376
+ ];
377
+ // `/flip-join` — the JOINED-if sibling of `/flip-branch` (structure.ts
378
+ // negateJoinedBranchSense): a reconverging two-armed if reads the same fall-through-is-then
379
+ // layout evidence the divergent case does, so the DEFAULT sense is the divergent one's and
380
+ // this axis emits the other. Read off the TARGET's sense, not this candidate's `s.sense`, so
381
+ // `/flip-branch` still moves only divergent ifs and the two axes stay independent. The suffix
382
+ // therefore names a sense RELATIVE to the target's default: a label quoted from a log identifies
383
+ // a spelling only together with the tree that produced it, which is what the `[asmlift source
384
+ // <commit>]` stamp on the `[ranked]` line is for (docs/ranked-repro.md).
385
+ // Crossed with the pair above. The two senses are two different sources wherever a two-armed
386
+ // joined `if` exists at all — agbcc emits different bytes for the arms-swapped spelling — and
387
+ // all three things that invert the polarity are per-SITE where this lever is per-function, so no
388
+ // per-function predicate decides it: a short-circuit fold choosing the orientation, a
389
+ // conditional branch relayed past Thumb's ±256-byte reach, and a rotated loop's zero-trip guard,
390
+ // where the `if` is the compiler's own and no source sense exists to be faithful to. The third
391
+ // is what keeps the residue on targets that have neither: rows still win on the axis under
392
+ // gcc2.7.2 / gcc2.7.2kmc / mwcc, with no `short-circuit` tag and no Thumb branch range to
393
+ // explain them, and most of those carry `loop`. A function with no two-armed joined if emits identical
394
+ // source and the dedup collapses it before any compile.
210
395
  const baseSense = [
211
- { suffix: '', sense: defSense, anchor: false, bitfields: true },
212
- { suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
213
- { suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
214
- { suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
396
+ ...senseAnchor.map((s) => ({ ...s, join: false })),
397
+ ...senseAnchor.map((s) => ({ ...s, suffix: `${s.suffix}/flip-join`, join: true })),
215
398
  ];
216
399
  // `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
217
400
  // The named read recompiles at the DECLARATION's access width; where that diverges from the
@@ -227,50 +410,1044 @@ export function enumerateCandidates(
227
410
  const bitfieldCands = mapHasBitfields
228
411
  ? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
229
412
  : baseSense;
413
+ // `/connective`'s enumeration gate, read off the pass's OWN refusal rather than from a second
414
+ // copy of its matcher: the fold reports every site where the PAIRWISE comparison-tree refusal is
415
+ // the ONE thing stopping it — asked after `sameArgs` and the negatability check, so a report
416
+ // means a candidate that DIFFERS, not a refusal merely reached — and a function with none has no
417
+ // inhabitant for the axis.
418
+ //
419
+ // PER SYMBOL VARIANT, on a lift of its OWN, for the reason the `/setup-args` gate below states
420
+ // for itself: no lift may be governed by a fact measured on a different one. The pin and
421
+ // `/setup-args` cannot move this answer — neither a parameter's type nor a call's argument list
422
+ // moves a `cond_br` — but a SYMBOL MAP can, by lifting a pool-loaded comparison constant as a
423
+ // `gaddr` the const-test test then does not read. Measured once and NOT re-derived since: over
424
+ // the real rows that lifted, 21 sites mapped and 21 raw with no per-row divergence. Read that as
425
+ // the REASON the gate is asked per variant, not as a fact about today's corpus — it carries no
426
+ // commit stamp. The SYNTHETIC tier is inside that comparison rather than exempt from it: 9 of
427
+ // its 770 rows carry a map (`SynthSpec.symbols`) in the committed artifact, and that count moves
428
+ // every time a map row is added, so re-derive it rather than carrying this one forward. So this
429
+ // buys no candidate; what
430
+ // it buys is that a lift-time change which splits them enumerates both arms rather than
431
+ // silently dropping one, the failure nothing reports.
432
+ let probeTreeOwned = false;
230
433
  // Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
231
434
  // so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
232
435
  // compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
233
436
  // the totality contract / return-sinking of the full spine are not run on it.)
234
437
  const probe = frontend.lift(name, asm, target, prototypes, opts.asmData, opts.symbols);
235
438
  verify(probe);
439
+ // The ARRAY SHAPES the input assembly evidences (raise/globalshape.ts), for the DECLARATION
440
+ // half. Read off the probe's LIFTED form — before the fold below and the tower rewrite it —
441
+ // because the base-materialization order the derivation's licence reads does not survive them.
442
+ // Probe-derived like `accessFacts` beside it, and for the same reason: it is a lift-time fact.
443
+ // The candidate half reads its OWN lift (each symbol variant lifts differently), just as the
444
+ // spelling axes do.
445
+ const probeShapes = inferGlobalArrays(probe, target);
236
446
  applyIdiomPatterns(probe, target, opts.patterns);
237
- runPreRecovery(probe, target, () => verify(probe));
447
+ runPreRecovery(probe, target, () => verify(probe), prototypes[name], {
448
+ shortCircuit: {
449
+ onTreeOwned: () => {
450
+ probeTreeOwned = true;
451
+ },
452
+ },
453
+ });
238
454
  recoverTypes(probe);
239
455
  const ptrIdx = new Set<number>(probe.blocks[0].params.flatMap((p, i) => (NO_PIN_KINDS.has(p.type.kind) ? [i] : [])));
240
456
  // Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
241
457
  // from the probe: widths/offsets are lift-time facts, identical across every candidate.
242
- const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
243
- // `/reread-globals` the VALUE-HOME axis (structure/analysis.ts AnalyzeOptions). Whether the
244
- // source read a global once into a variable or re-read it at each use is not derivable from asm:
245
- // the compiler CSEs the second spelling back into one load, and the round-5 dogfood watched agbcc
246
- // land on both sides inside a single function (its highest-cost defect, 25 of 27 points on one
247
- // klonoa function and 35/50 both ways on another). So both spellings are emitted and the differ
248
- // referees — the same footing as signedness and branch sense, and never a default: the cached
249
- // spelling stays the primary, so this can only ever ADD a winner.
458
+ // Ungated on `opts.symbols`: map-less candidates now carry name-only refs too (see
459
+ // `bareGlobalSymbols`), and these facts are their declarations' WIDTH AUTHORITY — without them
460
+ // every map-less decl would be the `extern u32` fallback and a bare `gCell = x` would compile
461
+ // to `str` where the target says `strh`. One IR walk; on a function with no `gaddr` at all
462
+ // (every synthetic corpus row) it returns the same empty map the gate used to hand back.
463
+ const accessFacts = bareGlobalAccessFacts(probe);
464
+ //
465
+ // The MAPPED variant reads it off the probe below, itself a lift in exactly that configuration
466
+ // reuse, not inheritance. Only a variant lifting under DIFFERENT symbols pays a lift of its own,
467
+ // so the price is one per `/raw-globals` arm and zero on a map-less row, never per candidate.
468
+ //
469
+ // DECLARED AFTER THE PROBE'S OWN `runPreRecovery` ON PURPOSE, and that placement is the memo's
470
+ // precondition: the map arm returns `probeTreeOwned`, which is only the answer once the probe's
471
+ // `onTreeOwned` hook has had its chance to fire. Called any earlier it would report a confident
472
+ // `false` for a function that owns a tree. As a `const` below that call, an early call is a TDZ
473
+ // ReferenceError instead — a wrong answer traded for a loud one.
474
+ const treeOwnedIn = (symbols: typeof opts.symbols): boolean => {
475
+ if (symbols === opts.symbols) {
476
+ return probeTreeOwned;
477
+ }
478
+ const p = frontend.lift(name, asm, target, prototypes, opts.asmData, symbols);
479
+ verify(p);
480
+ applyIdiomPatterns(p, target, opts.patterns);
481
+ let seen = false;
482
+ runPreRecovery(p, target, () => verify(p), prototypes[name], {
483
+ shortCircuit: {
484
+ onTreeOwned: () => {
485
+ seen = true;
486
+ },
487
+ },
488
+ });
489
+ return seen;
490
+ };
491
+ // `/no-ptr-elem` — keep the honest byte arithmetic where the map would spell a whole-element
492
+ // subscript through a pointer MEMBER (`gBg.pMap[i + 157]`). The two are the same address and
493
+ // DIFFERENT objects — measured against agbcc, they differ in which register the `add` targets at
494
+ // every constant tested — so which side matches is per-function knowledge the asm does not
495
+ // carry, and the differ referees it exactly as it referees `/no-bitfield`.
496
+ //
497
+ // THE CROSS IS EXPENSIVE AND THE GATE IS WHAT BOUNDS IT, so the gate is asked of THIS FUNCTION,
498
+ // not of the map: a pointer member is only ever spelled off a container the function names, and
499
+ // every named global reaches the IR as a `gaddr`. A map-wide `some` would charge the cross to
500
+ // every function lifted alongside such a symbol, which is co-occurrence, not reach. The map must
501
+ // still declare a pointee WIDTH of 1, 2 or 4 — nothing else is an element — and `isPtrField` is
502
+ // the shared two-fact test, so this gate and the rule it gates cannot disagree about what a
503
+ // pointer member is.
504
+ //
505
+ // (`/no-bitfield` above still asks the MAP rather than the function; narrowing it would be this
506
+ // same edit against a different measurement. Its cross is censused below beside this one.)
507
+ //
508
+ // Where it DOES reach, the cross is the honest price of an arm the differ has to referee, and on
509
+ // the corpus's largest fan it is large: `kleod:ProcessInputAndUpdateEntities` enumerates 58,752
510
+ // candidates of which 23,040 carry this arm, so removing it leaves 35,712 — a factor of 1.65,
511
+ // not a doubling. A ROUNDER NUMBER IS NOT A SAFER ONE: re-measure rather than reaching for a
512
+ // vaguer word. The instrument is `decompileRanked`'s own enumeration, and a direct
513
+ // `enumerateCandidates` call from a standalone script is NOT it (an ESM/CJS duplicate of this
514
+ // module answers 544 where the harness answers 952 on `SetupBG3WindowOverlay`).
515
+ //
516
+ // The two arms are also NESTED rather than independent — `ptrElemCands` is built by doubling
517
+ // `bitfieldCands`, so this arm's candidates include the `/no-bitfield` ones and adding the two
518
+ // families' counts double-counts the overlap: on that same row 12,672 of the 23,040 carry BOTH
519
+ // tokens, which is half of `/no-bitfield`'s own 25,344. A per-family price read off either
520
+ // label alone therefore double-counts more than half of this row's cross.
521
+ //
522
+ // EXACTLY ONE WINNING LABEL IN THE ARTIFACT CARRIES `/no-ptr-elem` — `synthetic:ptrelem:agbcc`,
523
+ // match at 0. READ THAT ONE, NOT A ZERO: the axis is two-sided where it fires. Compile the byte
524
+ // spelling and the element spelling of the SAME address with the klonoa checkout's own agbcc,
525
+ // lift each back with that project's own map, and the arm is the ONLY candidate that matches the
526
+ // byte target while the default is the only one that matches the element target — on a constant
527
+ // element offset, at one element in, at a pointee width of 1, and on a STORE.
528
+ // `cli/test/matching/ptr-elem-axis.test.ts` is that measurement, and deleting the `ptrElemCands`
529
+ // cross turns 8 of its 13 assertions red — the four BYTE-target ones (each scoring 1
530
+ // rather than 0) and the four that check the arm is enumerated at all — while its four
531
+ // ELEMENT-target ones stay green, which is the two-sidedness itself. A low count over the REAL
532
+ // tier counts something else: klonoa's map declares a sized pointer member at ONE address, and
533
+ // every decompiled caller of it happens to be written in the element form.
534
+ //
535
+ // WHERE IT GENUINELY DOES NOT REACH, measured on the same probes: at element offset ZERO the two
536
+ // arms emit the IDENTICAL source (`((u16 *)gSym.pMap)[a0]`, because with no constant left there
537
+ // is nothing for the byte form to spell differently), the tree dedup collapses the pair — 10
538
+ // candidates, not 12 — and neither arm matches a byte-form target. That is an open gap in the
539
+ // spelling, not a refusal of this axis.
540
+ //
541
+ // STATE THE DENOMINATOR, AND DERIVE IT FROM REACH RATHER THAN FROM CO-OCCURRENCE — the same
542
+ // distinction the paragraph above draws about the gate, applied to the gate's own price.
543
+ // "Offered only where a symbol map exists" is true and useless: all 252 real rows carry a map,
544
+ // so that framing hands back 151 labelled rows, which is the map-wide `some` this gate was
545
+ // written to avoid. The gate is per-FUNCTION, so census the FUNCTIONS. Enumerating every real
546
+ // case (candidates only, `ASMLIFT_CANDCACHE=0`, the harness's own inputs) and counting rows with
547
+ // any surviving `/no-ptr-elem` candidate: TWO — `kleod:ProcessInputAndUpdateEntities` (23040 of
548
+ // its 58752) and `kleod:SetupBG3WindowOverlay` (128 of 952), and only the first carries a
549
+ // winning label at all, the second being `noncompile`. THE TWO-ROW REACH IS STABLE AND THE
550
+ // COUNTS ARE NOT — they move with every fan-widening axis, so re-run the census rather than
551
+ // quoting these. So the REAL tier's "0 winning labels" is 0 of ONE here, not 0 of 151 and not
552
+ // 0 of the artifact's row count. That census enumerates 155 of the 252 real rows — the
553
+ // 151 the artifact labels plus its 4 `noncompile` rows — and the 97 it cannot enumerate are
554
+ // exactly the rows the artifact declines. What makes "no row is LOST" a proof rather than a
555
+ // sample is the soundness rule instead: this axis only ADDS candidates, so a row whose winner
556
+ // does not carry it cannot move when it is removed. The same census prices `/no-bitfield`: it
557
+ // survives dedup on FIVE real rows — `ProcessInputAndUpdateEntities` 25344, `CountCollectedGems`
558
+ // 192, `UpdateWorldMapNodeAnim` 168, `UpdateHUDCounterDisplay` 96, `CopyBGScrollTiles` 4 — every
559
+ // one of them a row the artifact labels, and none of the five wins under it. So its map-wide
560
+ // enumeration gate buys a candidate cross on 5 functions and the dedup collapses it everywhere
561
+ // else.
562
+ //
563
+ // WHERE THE WINNING LABELS ARE, since the REAL tier has none for either arm: the SYNTHETIC tier
564
+ // carries rows that hand asmlift a map (`SynthSpec.symbols`), and both arms win on one —
565
+ // `/no-bitfield` on `bfwordread` and `bfwordwrite`, `/no-ptr-elem` on `ptrelem`, each a match at
566
+ // 0 that becomes a NONMATCH when its own arm is ablated. A LABEL CENSUS IS SCOPED TO ITS TIER
567
+ // AND ITS COMMIT: say which tier a count is over, and re-derive it rather than carrying it
568
+ // forward.
569
+ // The name-keyed map `baseOpts` already built, not a second `symbolsByName` walk over the same
570
+ // input: the function is deterministic and unmemoized, nothing in packages/core mutates a
571
+ // SymbolMap or a map it returns, and `baseOpts` is never reassigned — so this is the same map,
572
+ // 25-47 ms cheaper on the large vendored ones. The same idiom `mapSymbols` below uses.
573
+ const byName = baseOpts.symbols;
574
+ const fnHasSizedPtrFields =
575
+ byName !== undefined &&
576
+ [...bareGlobalSymbols(probe).keys()].some((n) => {
577
+ const i = byName.get(n);
578
+ return (
579
+ i !== undefined &&
580
+ [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some(
581
+ (f) => isPtrField(f) && (f.pointeeSize === 1 || f.pointeeSize === 2 || f.pointeeSize === 4),
582
+ )
583
+ );
584
+ });
585
+ const ptrElemCands = fnHasSizedPtrFields
586
+ ? [...bitfieldCands, ...bitfieldCands.map((s) => ({ ...s, suffix: `${s.suffix}/no-ptr-elem`, ptrElems: false }))]
587
+ : bitfieldCands;
588
+ // `/flat-rank` — spell a multidimensional global's access as the FLAT byte arithmetic
589
+ // (`*(u16 *)((r << 11) + (i << 1) + (u32)&g)`) where the default recovers the map's declared
590
+ // subscripts (`g[r][i]`). The recovery's evidence is a term at the declared ROW stride, and that
591
+ // is evidence the residual carries a row — NOT evidence about which of the two spellings that
592
+ // both produce it was written. Compiled (structure.ts `spellDeclaredSubscripts` carries the
593
+ // table): the two differ only in where the pool load sits under agbcc, kmc and mwcc, and are
594
+ // BYTE-IDENTICAL under IDO, which also distributes the flat sum into the same separate scales.
595
+ // So the asm underdetermines it on every compiler measured, and the differ referees — the same
596
+ // posture as `/no-ptr-elem` and `/no-bitfield`.
250
597
  //
251
- // Gated on the function having a load that resolves to a named global at all — the only thing the
252
- // axis can change. The dedup below collapses the pair wherever it changed nothing.
598
+ // THE GATE IS ASKED OF THIS FUNCTION, not of the map, for `/no-ptr-elem`'s reason: a declared
599
+ // subscript is only ever recovered off a global the function NAMES, and every named global
600
+ // reaches the IR as a `gaddr`. `arrayInnerExtents` is the recovery's own rank test, called here
601
+ // rather than re-spelled, so the gate cannot be narrower than the rule it gates. It is still a
602
+ // superset — it does not know the access WIDTH, and it cannot know whether any residual carries
603
+ // a row term — so where the axis changes nothing the tree dedup below collapses the pair and the
604
+ // fan does not grow. OVER THE ARTIFACT'S 957 ROWS: 10 name such a symbol at all — 9 of them in
605
+ // their winning `symbolsUsed`, the tenth (`kleod:SetupBG3WindowOverlay`) in a source its row
606
+ // cannot compile, which is why the count is taken off the emitted sources and not off
607
+ // `symbolsUsed`, where a row with no winner is invisible. RE-DERIVE THIS PAIR RATHER THAN
608
+ // RE-ANCHORING IT: adding one map-bearing row moves it, and one of the nine is exactly that —
609
+ // `synthetic:sbscope:agbcc`, whose map declares `dims: [4, 1024]`.
610
+ //
611
+ // THE GATE READS THE MAP **OR** THE DERIVED SHAPES, and the map half alone was a live bug: since
612
+ // raise/globalshape.ts, `structure()` builds the symbol render context from the UNION of the
613
+ // project map and the shapes the asm evidences, so a MAP-LESS function whose own strides nest
614
+ // (`synthetic:tblrank2:agbcc`) now spells `gPtrTbl[a0][a1]` by default while its flat sibling
615
+ // `*(s32 *)((a1 << 2) + (a0 << 3) + (u32)&gPtrTbl)` — a genuinely different tree — was
616
+ // enumerated nowhere. An axis exists BECAUSE the asm underdetermines the choice; supplying the
617
+ // rank from a new place does not make it determined, and nothing reports a candidate that was
618
+ // never enumerated. Map first, exactly as everywhere else: a name the map knows is answered by
619
+ // the map.
620
+ const derivedOrMapped = (n: string): SymbolInfo | undefined => byName?.get(n) ?? probeShapes.get(n);
621
+ const fnNamesMultidimArray = [...bareGlobalSymbols(probe).keys()].some((n) => {
622
+ const i = derivedOrMapped(n);
623
+ return i !== undefined && i.shape === 'array' && (arrayInnerExtents(i)?.length ?? 0) > 0;
624
+ });
625
+ const declRankCands = fnNamesMultidimArray
626
+ ? [...ptrElemCands, ...ptrElemCands.map((s) => ({ ...s, suffix: `${s.suffix}/flat-rank`, declRank: false }))]
627
+ : ptrElemCands;
628
+ // The axis chain, derived from STRUCTURING_AXES: each admitted axis doubles the list, OFF arm
629
+ // first — order is load-bearing for the dropped-primary skip below (every OFF sibling
630
+ // enumerates before its ON twin, so a twin's stripped-key lookup always finds a sibling that
631
+ // has already run or been condemned). Each per-axis rationale lives on its table entry; both
632
+ // arms are always emitted and the differ referees, never a default — the dedup below collapses
633
+ // a pair wherever the axis changed nothing.
253
634
  const probeDefs = defOpMap(probe);
254
- const readsANamedGlobal = probe.blocks.some((b) =>
255
- b.ops.some(
256
- (op) => op.opcode === 'load' && globalCellOf(probeDefs, op.operands[0], op.attrs.off as number) !== null,
257
- ),
258
- );
259
- const senseCands = readsANamedGlobal
260
- ? [
261
- ...bitfieldCands.map((s) => ({ ...s, reread: false })),
262
- ...bitfieldCands.map((s) => ({ ...s, suffix: `${s.suffix}/reread-globals`, reread: true })),
263
- ]
264
- : bitfieldCands.map((s) => ({ ...s, reread: false }));
635
+ type AxisCand = (typeof ptrElemCands)[number] & Record<StructuringAxis['flag'], boolean>;
636
+ /** Every axis OFF — seeded from the table so an added axis is one table entry and not a second
637
+ * hand-edited literal, in table order like everything else derived from it.
638
+ *
639
+ * WHAT THE CAST CANNOT CATCH: a `StructuringAxis['flag']` union member with NO table entry.
640
+ * `Object.fromEntries` types its result by the key type it was handed, not by the union, so the
641
+ * assertion is taken on trust where the hand-written literal was checked. Such a member is
642
+ * inert either way every reader of these flags iterates `STRUCTURING_AXES`, so a flag with no
643
+ * entry is never read but it stops being a type error and becomes an absent field. */
644
+ const axisFlagsOff = Object.fromEntries(STRUCTURING_AXES.map((ax) => [ax.flag, false])) as Record<
645
+ StructuringAxis['flag'],
646
+ boolean
647
+ >;
648
+ let axisCands: AxisCand[] = declRankCands.map((s) => ({ ...s, ...axisFlagsOff }));
649
+ for (const ax of STRUCTURING_AXES) {
650
+ if (ax.probeGate !== undefined && !ax.probeGate(probe, probeDefs)) {
651
+ opts.onAxisGated?.(ax.suffix);
652
+ continue;
653
+ }
654
+ axisCands = [
655
+ ...axisCands,
656
+ ...axisCands.map((s) => ({ ...s, suffix: `${s.suffix}${ax.suffix}`, [ax.flag]: true }) as AxisCand),
657
+ ];
658
+ }
659
+ /** Is this the point where NO structuring lever is on? The base-axes abort guard's other half:
660
+ * at the base LIFT variant a failure here aborts the row, because it says the lift is broken
661
+ * rather than that one axis cannot spell this tree.
662
+ *
663
+ * `s.suffix === ''` IS NOT THE SAME TEST, which is why this is a named predicate rather than
664
+ * the string compare it looks like. `/flip-branch` and `/flip-join` name a branch sense
665
+ * RELATIVE to the target's default, so BOTH senses are base axis points — the flipped one
666
+ * carries a suffix and still has every lever off. The table's own flags decide, plus the four
667
+ * shape booleans that predate the table. */
668
+ const isBaseAxisPoint = (s: AxisCand): boolean =>
669
+ !s.anchor && !s.join && s.bitfields && s.ptrElems && s.declRank && STRUCTURING_AXES.every((ax) => !s[ax.flag]);
265
670
 
266
- const seen = new Set<string>();
671
+ const seen = new Map<string, Candidate>();
672
+ const seenTrees = new Set<string>();
673
+ /** the PRE-FAN products' own tree dedup — see the pre-fan loop for why it is not `seenTrees` */
674
+ const seenPreFan = new Set<string>();
267
675
  const out: Candidate[] = [];
676
+ // The map-derived VALUE references one emitted tree contains, applied at every point a candidate
677
+ // is finalized and derived from the tree that candidate emitted. No pipeline stage carries refs
678
+ // (SFn has no such field), so a future l3 pass that rewrites the tree can never leave a stale ref
679
+ // behind: whatever tree reaches emit is the tree the refs describe, by construction. Collected
680
+ // against the FULL name-keyed map for EVERY spelling variant — the '/raw-globals' sibling drops
681
+ // the map's shaped SPELLINGS, but its tree still NAMES pool/reloc-derived globals (ARM
682
+ // `.word gSym`, MIPS `%lo(gSym)`), and those references need declarations in the self-declared
683
+ // scoring world exactly like the named variant's (without them every raw sibling fails to compile
684
+ // there, and the eval-winning raw candidate becomes unreproducible outside project headers).
685
+ // The volatility tie-break's input, derived at the same moment as the refs and for the same
686
+ // reason: whatever tree reaches emit is the tree it describes. Absent on a target that declares
687
+ // no device window, which is how every non-GBA target opts out.
688
+ const volOf = (tree: SFn): { deviceVolatile?: number } => {
689
+ const n = deviceVolatileClaims(tree, target.capabilities.deviceRegisters);
690
+ return n > 0 ? { deviceVolatile: n } : {};
691
+ };
692
+ // Every refusal is reported at most once per (name, reason): `refsOf` runs per CANDIDATE over
693
+ // the same probe-derived dictionary, so without this the caller would hear the same refusal
694
+ // once per spelling in the fan (hundreds of times on a wide row).
695
+ const refusalsSeen = new Set<string>();
696
+ const refuse = (name: string, reason: RefusedDeclarationReason): void => {
697
+ if (refusalsSeen.has(`${name}\u0000${reason}`)) {
698
+ return;
699
+ }
700
+ refusalsSeen.add(`${name}\u0000${reason}`);
701
+ opts.onRefusedDeclaration?.(name, reason);
702
+ };
703
+ // The names the tree spells are read out of the asm's own literal pool / relocations and
704
+ // synthesized as name-only symbols (`bareGlobalSymbols`); where a symbol MAP knows a name, the
705
+ // map's facts WIN. A UNION rather than an either/or: the per-CALL fallback it replaced
706
+ // (`opts.symbols ?? bareGlobalSymbols(...)`) let ONE map entry switch the synthesis off for
707
+ // every OTHER name in the function, so supplying more information made the tool strictly
708
+ // worse. A union cannot — each name is declared by whichever half knows more about it.
709
+ // SCOPE: `declSymbols` is used ONLY here. It must never reach `opts.symbols`/`baseOpts.symbols`
710
+ // or `frontend.lift` — feeding it to the lift would turn on pool promotion, interior
711
+ // attribution and the `/raw-globals` variant, which is a different (and source-moving) change.
712
+ // THREE halves now, in increasing authority: the name-only pool/reloc symbols, the array shapes
713
+ // the asm evidences for them (raise/globalshape.ts — an `extern u16 gTbl[];` where the bare
714
+ // spelling needs one, and the declaration a candidate spelling `gTbl[i]` cannot compile
715
+ // without), and the project map, which knows more than either.
716
+ const mapSymbols = baseOpts.symbols;
717
+ const declSymbols = new Map<string, SymbolInfo>([...bareGlobalSymbols(probe), ...probeShapes, ...(mapSymbols ?? [])]);
718
+ // The four per-enumeration constants named at the seam rather than captured across 60 lines of
719
+ // closure (rank-declare.ts states why they belong on one object).
720
+ const refsOf = makeRefCollector({ declSymbols, accessFacts, mapSymbols, refuse });
721
+ // THE RE-SPELLING FAN, as a function whose PARAMETER LIST is the invariant the tree skip below
722
+ // rests on: every spelling here is a pure function of the structured tree and this call's own
723
+ // constants, so a tree an earlier axis point already spelled can only re-emit sources `seen`
724
+ // already holds. Inline in that loop the invariant would be a comment asking future levers not to
725
+ // read `fn` or the axis flags; as a signature, a lever that needs one has to widen it in front of
726
+ // a reviewer. The same argument l3/ast.ts's `walkExprs` header makes for its own shape, and it
727
+ // counts for more here: a lever reading `fn` would not misprint a candidate, it would DELETE one,
728
+ // and nothing in the harness reports a candidate that was never enumerated.
729
+ // `leverLabel` names the SPELLING this call is fanning, and it is a diagnostic argument only: it
730
+ // reaches `onLeverError` and nothing else, so the invariant the parameter list states above —
731
+ // every spelling is a pure function of the tree and this call's own constants — is untouched by
732
+ // it. It exists because the pre-fan products call this on a REWRITTEN tree, where a refusal of
733
+ // the primary spelling is a refusal of the rewrite, not of the row's own spelling.
734
+ //
735
+ // IT PREFIXES EVERY `onLeverError` IN THIS FUNCTION, not just the primary emit's, and that is
736
+ // the whole point rather than a detail: every one of them is reachable from both fans, and the
737
+ // suffix each already carries names a LEVER, which on a pre-fan tree is a lever applied to the
738
+ // rewrite. Reported without this prefix, a refusal of `/unmerge/volatile` reads as a refusal of
739
+ // `/volatile` — a spelling that did not fail and is still in the fan. The order
740
+ // is the candidate labels' own (`${pf.suffix}${sp.suffix}`), so a reported label and an
741
+ // enumerated one name the same spelling the same way.
742
+ const fanOut = (sfn: SFn, leverLabel = ''): FanResult => {
743
+ // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
744
+ // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
745
+ // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
746
+ // when a loop re-spells, BOTH representations are emitted and the differ referees. The
747
+ // re-spelling passes the same boundary contracts as the primary; one that fails them is
748
+ // dropped here — never scored, never able to win.
749
+ const spellings: Spelling[] = [];
750
+ // The PRIMARY spelling takes the same posture as every re-spelling below: a backend that
751
+ // declines by throwing costs this tree — its primary and the re-spellings built from it —
752
+ // never the row. The opposite posture from the STRUCTURING refusal below, which aborts the
753
+ // row at the base point, and for the reason that separates them: that one says the lift is
754
+ // broken, this one that the target language has no spelling for a tree the lift got right
755
+ // (structuring is language-neutral, and the signedness pins it inserts are `cast` nodes the
756
+ // Pascal backend loud-declines). Refusing EVERY tree is still loud — the empty-enumeration
757
+ // check at the end raises the last refusal.
758
+ try {
759
+ spellings.push({ suffix: '', source: backend.emit(sfn), ...refsOf(sfn), ...volOf(sfn) });
760
+ } catch (e) {
761
+ opts.onLeverError?.(name + leverLabel, firstLine(e));
762
+ return { spellings, emit: { error: e } };
763
+ }
764
+ // Representation re-spellings — each a lever on the same footing as signedness/branch sense,
765
+ // each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
766
+ // that declines by throwing — Pascal loud-fails unspellable shapes — drops the candidate,
767
+ // never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
768
+ //
769
+ // POLICY: re-spellings derive from the BASE spelling only — levers do not compose by
770
+ // default. FOUR product mechanisms are sanctioned, each with its own admission bar — the
771
+ // three below, which all derive from or compose onto a spelling, plus the PRE-FAN products
772
+ // (PRE_FAN_PRODUCTS, applied to the TREE before this fan runs over it; its admission bar is
773
+ // stated at the table) —
774
+ // plus ALTERNATIVE OUTPUTS: one lever whose single application has several legitimate
775
+ // results (which locals a coalesce merges, which pointers /volatile qualifies) emits
776
+ // each as its own candidate via `enumerate`, capped at the lever, with the base spelling
777
+ // retained; outputs may also ride an already-sanctioned product (the /livebase/volatile
778
+ // subsets), since they add no new lever to the composition.
779
+ // Products with /volatile go only onto a lever whose re-spelling CENTRES ON a
780
+ // numeric-address pointer local — the joint spelling is reachable from neither lever
781
+ // alone, each product narrows /volatile to the lever's own locals (volatilePtrLocals'
782
+ // `only`), and each needed a row to demand it. The SHAPE products (SHAPE_PRODUCTS) are
783
+ // derived onto EVERY spelling: statement order/shape is orthogonal to what any
784
+ // representation lever changes — the same kind of independent dimension as signedness —
785
+ // so they compose as an axis rather than a pairing; a third blanket product needs the
786
+ // same argument, not just a row. And a specific LEVER PAIRING is admitted on one of two
787
+ // grounds, never on "it might help". FIRST, a row demands the joint spelling AND that
788
+ // spelling is reachable from neither lever alone: /livebase × /indexed, × /sinkinit,
789
+ // × /nearbase and × /coalesce, plus /vol-store × /unreduce and that pair × /ptr-field —
790
+ // each with its demanding row at the respell site. (A TRIPLE is admitted on the same ground
791
+ // and no weaker one: it is one joint spelling with one demanding row, and the pairs BELOW it
792
+ // are not thereby admitted — on synthetic:dmaptrsrc the two intermediate pairs measure 27 and
793
+ // 32 against the triple's 0, and neither is in the fan.) SECOND, a
794
+ // lever COMMITS a policy the differ would otherwise never see — /nearbase × /sinkinit,
795
+ // where `l3/nearbase.ts` picks one of two init orderings inside the pass, so without the
796
+ // pairing that choice decides a match with no candidate beside it to lose to. The second
797
+ // ground is narrower than it looks: it needs a committed choice INSIDE a lever with an
798
+ // existing lever that expresses the alternative, not a lever one could imagine wanting
799
+ // twice. Anything else stays un-composed. A pairing is admitted for a LEVER, so it fans
800
+ // over that lever's whole admission table (LIVEBASE_ADMISSIONS): a roster row changes
801
+ // which bases the same hoist binds, not what pairing it with /coalesce means.
802
+ // And a lever must PRESERVE SEMANTICS by construction: the differ referees byte-exactness
803
+ // (a wrong candidate can never fake a score-0 match), but on a NONMATCH row the best-
804
+ // scoring source is shown to the user — a semantically-wrong re-spelling there is
805
+ // plausible-but-wrong output, the defect class this project exists to avoid. THE ONE
806
+ // EXCEPTION IS THE SAME RULE READ FORWARD: where a lever cannot establish its semantics from
807
+ // inside the pass — `l3/unreduce.ts` moving a read into a loop whose device stores may make
808
+ // the DEVICE write memory — the spelling is marked `Candidate.matchOnly` and published ONLY
809
+ // at a byte-exact score, which is the clause in brackets above used as a licence instead of a
810
+ // consolation. It is never shown as a best-effort answer, so the nonmatch case the sentence
811
+ // is about cannot arise. Hence each
812
+ // lever's decline-over-approximate gates, adversarially audited.
813
+ // Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
814
+ // from the pass itself — rather than from the contracts or the backend — would escape and
815
+ // abort the whole enumeration for this row, primary included: the one way a lever can cost
816
+ // a match. Making that structural rather than per-call-site means no lever can opt out.
817
+ //
818
+ // WHICH boundary contracts run here, and why it is three of the four. A lever gets the
819
+ // tree `structureChecked` already validated, so what these re-check is what a LEVER can
820
+ // break, not what structuring can. `assertResolved` and `assertDerefsTyped` catch an
821
+ // unspellable tree — a candidate the compiler would reject, which the harness would report
822
+ // as a dropped spelling with no cause. `assertLocalsWritten` catches the one wrongness the
823
+ // differ REWARDS: a pass that moves or suppresses an assignment and never emits it leaves
824
+ // the reads standing over whatever the allocator left behind, and that candidate compiles,
825
+ // scores, and can win (the shape #106 shipped). Levers that place a def — l3/sinkinit.ts,
826
+ // l3/basecse.ts's first-use policy, l3/nearbase.ts, l3/scopebase.ts, l3/argbase.ts — are
827
+ // exactly the population that can produce it, so the check belongs on every lever tree
828
+ // rather than on theirs. It cost nothing when measured: 0 violations over the 34357 trees the
829
+ // artifact's agbcc rows enumerated in both symbol-map configurations. A count with no commit
830
+ // stamp — re-run it rather than reading it as today's.
831
+ // `assertEffectsPreserved` is the fourth and is NOT here: it needs the L1 `fn`, and
832
+ // `fanOut`'s parameter list is the invariant the tree-dedup skip rests on (see its header).
833
+ // Widening it for a contract is a defensible change and an argued one — not a silent import.
834
+ // A lever returns its tree, or `{ sfn, needsProof }` when it cannot establish its own
835
+ // semantics from inside the pass (Candidate.matchOnly carries the argument).
836
+ const respell = (suffix: string, make: () => LeverResult, alreadyShaped = false): void => {
837
+ try {
838
+ const made = make();
839
+ if (!made) {
840
+ return; // the lever declined to fire — no candidate, not a duplicate of the primary
841
+ }
842
+ const alt = 'sfn' in made ? made.sfn : made;
843
+ const proof: { matchOnly?: true } = 'sfn' in made && made.needsProof ? { matchOnly: true } : {};
844
+ assertResolved(alt);
845
+ assertDerefsTyped(alt);
846
+ assertLocalsWritten(alt);
847
+ spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt), ...volOf(alt), ...proof });
848
+ // STATEMENT-SHAPE products, derived onto EVERY spelling — the second sanctioned
849
+ // product mechanism (the POLICY note above carries the admission argument). Each is
850
+ // a statement-order/shape fact orthogonal to representation; subsets compose in the
851
+ // fixed order below. A shape that never fires declines and costs nothing.
852
+ if (!alreadyShaped) {
853
+ // A shape REORDERS statements, and it is derived after a lever has placed its defs — so
854
+ // the placement is re-checked on the shaped tree (contracts.ts). Differential: judged
855
+ // only where the unshaped tree already satisfied the walk, so a lever whose placement it
856
+ // never described is not dropped on the strength of a model that does not apply.
857
+ //
858
+ // `minted` is a NAME DIFF, so for a RENAMING lever (`/regspell`, `/merge-names`) it also
859
+ // holds locals the lever never PLACED. Harmless and deliberate: the differential's
860
+ // early return absorbs a name the unshaped tree already fails on, and a renamed local
861
+ // whose def a shape moved below a read is the same wrongness as a placed one.
862
+ //
863
+ // KNOWN GAP on the other side of the same diff: a lever that RELOCATES a local it did not
864
+ // mint contributes no name, so the shape differential does not judge it. The `scope`
865
+ // placement is the one that does this — it sinks the run `structureChecked` already
866
+ // committed (l3/basecse.ts judges those itself, over the placer's own report of the
867
+ // motion) — and closing it here needs that report threaded out to this level. Not widened
868
+ // to every relocated local instead: judging those would drop candidates across the whole
869
+ // fan with nothing measured to license it. What keeps it uninhabited is `initFirstGuards`,
870
+ // which moves only const or pure-read assigns and so cannot lift a read of a base local
871
+ // above its init.
872
+ const minted = createdLocals(sfn, alt);
873
+ for (const subset of SHAPE_SUBSETS) {
874
+ // ONE TRY PER SHAPE — a shape is its own candidate and fails as its own candidate.
875
+ // Sharing the lever's outer try would let a throw deriving one subset discard every
876
+ // later one, under a label (the base lever's suffix) that names no shape at all.
877
+ const shapeSuffix = subset.map((x) => x.suffix).join('');
878
+ try {
879
+ const shaped = applyShapes(subset, alt);
880
+ if (shaped !== null) {
881
+ assertResolved(shaped.out);
882
+ assertDerefsTyped(shaped.out);
883
+ assertLocalsWritten(shaped.out);
884
+ assertPlacementSurvives(alt, shaped.out, minted);
885
+ spellings.push({
886
+ suffix: `${suffix}${shaped.suffix}`,
887
+ source: backend.emit(shaped.out),
888
+ ...refsOf(shaped.out),
889
+ ...volOf(shaped.out),
890
+ // a shape derived from a proof-gated spelling inherits the requirement
891
+ ...proof,
892
+ });
893
+ }
894
+ } catch (e) {
895
+ opts.onLeverError?.(name + leverLabel + suffix + shapeSuffix, firstLine(e));
896
+ }
897
+ }
898
+ }
899
+ } catch (e) {
900
+ // A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
901
+ // REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
902
+ // so without this a lever that fails here vanishes with no trace — indistinguishable
903
+ // from one that correctly declined, which is exactly the hidden failure
904
+ // DroppedCandidate exists to surface.
905
+ opts.onLeverError?.(name + leverLabel + suffix, firstLine(e));
906
+ }
907
+ };
908
+ // `/argbase` — name a call's argument bases before the call (l3/argbase.ts). A lever on the
909
+ // same footing as the others: the primary inline spelling stays in the list, so the differ
910
+ // referees and this can never cost a match.
911
+ for (const subset of SHAPE_SUBSETS) {
912
+ // the truthful suffix needs the pass to RUN first, so this bypasses respell's
913
+ // label-then-thunk shape: same try posture, label from the fired members
914
+ try {
915
+ const shaped = applyShapes(subset, sfn);
916
+ if (shaped !== null) {
917
+ // the ONE call whose suffix already names shapes — say so, rather than making `respell`
918
+ // read it back out of the label it was handed
919
+ respell(shaped.suffix, () => shaped.out, true);
920
+ }
921
+ } catch (e) {
922
+ // the error label falls back to the full subset — the fired set is unknown mid-throw
923
+ const label = subset.map((x) => x.suffix).join('');
924
+ opts.onLeverError?.(name + leverLabel + label, firstLine(e));
925
+ }
926
+ }
927
+ respell('/argbase', () => materializeArgBases(sfn));
928
+ // `/zerosub` — spell a negate of a SHARED subtraction as `0 - x` (l3/zerosub.ts). gcc 2.9
929
+ // folds `-(a - b)` into `(b - a)` before CSE but leaves `0 - (a - b)` as a negate of the
930
+ // subtraction itself, so over a value the function also uses elsewhere the two spellings are
931
+ // a computation and a register apart — and both are reachable from a real source. The differ
932
+ // referees; its gate keeps it off every shape where the fold rule does not apply, which is
933
+ // every operand but a shared subtraction.
934
+ respell('/zerosub', () => zeroSubNegates(sfn));
935
+ // `/volatile` — declare a pointer local holding a NUMERIC address as pointing to volatile
936
+ // data (l3/volatileptr.ts). A raw constant has no declaration anywhere, so the original
937
+ // qualifier is not derivable — and it is codegen-visible (a volatile MEM is barred from
938
+ // motion, which lands the allocator on different homes). Both spellings are emitted and
939
+ // the differ referees.
940
+ respell('/volatile', () => volatilePtrLocals(sfn));
941
+ // `/vol-slot` — declare a STACK-HOMED scalar local volatile (l3/volatileval.ts). The
942
+ // qualifier takes away the allocator's freedom to keep the value in a callee-saved
943
+ // register across a call, and which of the three ways a slot can arise (a volatile local,
944
+ // an address-taken one, plain register pressure) the source used is not derivable from
945
+ // the asm. A DECLARATION lever, not a structuring axis (docs/level-tower.md's third
946
+ // fork): it changes nothing structure() decides, so it rides the base spelling like its
947
+ // `/volatile` sibling rather than doubling every enumeration, and its frame-flag gate
948
+ // costs nothing on a function with no slot.
949
+ respell('/vol-slot', () => volatileValueLocals(sfn));
950
+ /** `/vol-store`'s pass with the target's device-register window handed over — the window that
951
+ * keeps it off ordinary memory. Written once because five call sites take it. */
952
+ const volStore = (from: SFn): SFn | null => volatileDeviceStores(from, target.capabilities.deviceRegisters);
953
+ // `/vol-store` — pin a store at a fixed DEVICE-REGISTER address `volatile` (l3/volstore.ts).
954
+ // Where `/volatile` above qualifies a pointer LOCAL holding the address, this qualifies the
955
+ // access itself, which is the spelling a `REG_*` macro produces and the one structure.ts
956
+ // leaves when the address re-materializes at each use. Codegen-visible: agbcc's `load_mems`
957
+ // hoists an unpinned fixed-address store clean out of a loop (gcc/loop.c:8934), so the pinned
958
+ // spelling is the only one that reproduces a device-driving loop body at all. Its window gate
959
+ // is the target's own `deviceRegisters` range, which is what keeps it off ordinary memory.
960
+ respell('/vol-store', () => volStore(sfn));
961
+ /** `/unreduce` with both halves of the device model handed over — the SPELLING range and the
962
+ * MEMORY-MODEL trigger list (target.ts). Written once because three call sites take it. */
963
+ const unreduced = (from: SFn): UnreduceResult | null =>
964
+ unreduceAccumulators(from, target.capabilities.deviceRegisters, target.capabilities.deviceMemoryWriters);
965
+ // `/unreduce` — delete a loop-carried accumulator and spell each read as its closed form
966
+ // (l3/unreduce.ts). Strength reduction is a compiler pass, so the accumulated form is what the
967
+ // asm shows whichever form the source had; the un-reduced form is the other pre-image, and it
968
+ // reaches a preheader slot no C statement can (a compiler-created giv init is inserted after
969
+ // the invariant hoist, gcc/loop.c:1151 then :1173). The scalar-value sibling of `/indexed`,
970
+ // which makes the same argument for a pointer walk.
971
+ respell('/unreduce', () => unreduced(sfn));
972
+ // `/ptr-field` — declare a recovered WORD field a pointer (l3/ptrfield.ts). raise/structs.ts
973
+ // types a field from the access width alone, and on a 32-bit target `void *` fits that
974
+ // evidence exactly — but not the compiler's alias analysis, which is what lets a pointer
975
+ // field's load leave a loop an `s32` store pins it inside. Both are enumerated.
976
+ respell('/ptr-field', () => pointerFields(sfn));
977
+ // `/offmember` — spell a leaf base's constant subscript as a struct MEMBER (l3/offmember.ts),
978
+ // so the offset stays in the load's displacement instead of folding into the pool literal.
979
+ // The SECOND source of the shape `/basefold` already reads: that row answers the same
980
+ // evidence with a named base, this one with an aggregate member, and the two are different C
981
+ // and different register pressure. Offered only where the target declares the fold — MIPS and
982
+ // PPC put the addend in the instruction by construction, so nothing there says a member put
983
+ // it there, exactly as with BASEFOLD_ADMISSIONS above.
984
+ if (target.compilerBehaviors.foldsConstAddrOffset) {
985
+ respell('/offmember', () => spellOperandMembers(sfn));
986
+ }
987
+ // The `/vol-store` × `/unreduce` PAIRING — row-demanded (synthetic:dmafill), and the joint
988
+ // spelling is reachable from neither lever alone: pinning the stores keeps three of them in
989
+ // the loop body, which is what makes the loop's register pressure — and so the placement of
990
+ // the induction init — observable at all. Alone the two score 19 and 34 against the row's own
991
+ // 30; together, 0. The TRIPLE adds `/ptr-field` for synthetic:dmaptrsrc, whose closed form
992
+ // reads a struct field the un-reduce puts back inside the loop: 27 · 35 · 42 alone, 0
993
+ // together. The intermediate pairs are not admitted, and the reason is that NO ROW DEMANDS
994
+ // ONE — neither could win where they are reachable: compiled on synthetic:dmaptrsrc, VT TIES
995
+ // `/vol-store`'s 27 and RT LOSES to it at 32. (An earlier version of this note said "each
996
+ // scores worse than a lever already on the roster", which is loose twice over: VT ties rather
997
+ // than loses, and RT's 32 beats the ADMITTED standalone `/unreduce`'s 35. Ranking the pairs
998
+ // against the BEST already-admitted spelling is the comparison that holds.)
999
+ //
1000
+ // WHAT THE STANDALONE LINES COST, since neither of the two levers ever wins an artifact row
1001
+ // ALONE — every `/unreduce` and `/ptr-field` winner rides inside a `/vol-store` pairing, which
1002
+ // is the property `apps/benchmark/test/census.test.ts` asserts rather than the count it used
1003
+ // to quote here. They are kept because a lever has to be able to LOSE on its own terms: the
1004
+ // admission posture (compareScored orders by score) is what makes a wrong re-spelling
1005
+ // harmless, and it is only observable when the single-lever spelling is in the fan —
1006
+ // `synthetic:dmastride` exists to show exactly that for `/unreduce`, at 33 against its match.
1007
+ //
1008
+ // AND THE SUBSET APPLIER IS NOT THE RIGHT MECHANISM HERE, though it looks like it: rebuilding
1009
+ // this as a SHAPE_SUBSETS-style table would admit VT and RT by construction, because
1010
+ // `applyShapes` is SKIP-ON-DECLINE and would emit "everything that fired" on any tree where
1011
+ // one of the three declines. That is the property the shape products are designed around and
1012
+ // the one the pairing policy forbids — a pair reaches the fan only when a row demands it.
1013
+ //
1014
+ // Both compose through `composeLevers`, which carries `/unreduce`'s proof obligation across
1015
+ // the stages after it — hand-writing that carry made dropping it a type-correct edit.
1016
+ respell('/vol-store/unreduce', () => composeLevers(sfn, [volStore, unreduced]));
1017
+ respell('/vol-store/unreduce/ptr-field', () => composeLevers(sfn, [volStore, unreduced, pointerFields]));
1018
+ // `/inlinebase` — spell a CONSTANT-address pointer local at its uses instead
1019
+ // (l3/inlinebase.ts). The local is structure/analysis.ts's value home for a `const` the
1020
+ // asm kept in a callee-saved register across a call; the register is real, but a constant
1021
+ // re-spelled per use is CSEd back into that same one, so which the source had is not
1022
+ // derivable. Its own bare-`const`-initializer gate keeps it off l3/basecse.ts's reuse
1023
+ // hoists, whose placement levers already answer that question.
1024
+ //
1025
+ // TWO ALTERNATIVE OUTPUTS, not a product: deleting the local also deletes the only place
1026
+ // a `volatile` POINTEE could be written, and a raw address has no declaration anywhere
1027
+ // else to carry it. So the qualified spelling is emitted too, `/volatile` narrowed to
1028
+ // exactly the locals this lever deletes. Usually the bytes separate them and the score
1029
+ // decides (11 against 12 on pokeemerald:EReader_Reset), but where the compiler was not
1030
+ // exploiting the non-volatility they are byte-identical — as they are on that row's
1031
+ // WINNING shape, the one that also qualifies the slot — and `compareScored`'s device-
1032
+ // volatility term picks the qualified twin, 0x4000208 being REG_IME.
1033
+ //
1034
+ // COST — it fires broadly: on 33 of the 69 klonoa functions that lift with no symbol map
1035
+ // (a symbol-map sweep sees fewer, since an absolute pool constant lifts to a `gaddr`
1036
+ // there). Both outputs together add 766 candidates over 47058, +1.6%, and up to +67% on
1037
+ // one function (EntityPositionFromLevelTable) — the same class of price the enumeration
1038
+ // already pays for `/volatile`, and cheaper than the axis over the same question would
1039
+ // be — the choice the lever's header argues. `/vol-slot` adds nothing at all there: no
1040
+ // klonoa function reaches its frame gate.
1041
+ const inlineVolatile = (): SFn | null => {
1042
+ const only = new Set(inlinableConstBases(sfn));
1043
+ const q = only.size ? volatilePtrLocals(sfn, only) : null;
1044
+ return q ? inlineConstBases(q) : null;
1045
+ };
1046
+ respell('/inlinebase/volatile', inlineVolatile);
1047
+ respell('/inlinebase', () => inlineConstBases(sfn));
1048
+ // The `/inlinebase` × `/vol-slot` PAIRING — row-demanded, and the joint spelling is
1049
+ // reachable from neither lever alone: on pokeemerald:EReader_Reset the primary scores 11,
1050
+ // `/inlinebase` alone 11 and `/vol-slot` alone 2, and the pair 0. The two touch disjoint
1051
+ // locals (one pointer-typed, one a scalar frame slot), so applying them in either order
1052
+ // gives the same spelling — and each of `/inlinebase`'s two outputs carries it.
1053
+ respell('/inlinebase/volatile/vol-slot', () => {
1054
+ const r = inlineVolatile();
1055
+ return r ? volatileValueLocals(r) : null;
1056
+ });
1057
+ respell('/inlinebase/vol-slot', () => {
1058
+ const r = inlineConstBases(sfn);
1059
+ return r ? volatileValueLocals(r) : null;
1060
+ });
1061
+ // `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
1062
+ // (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
1063
+ // carries: this one fires exactly where that placement would extend a live range the
1064
+ // original never had.
1065
+ // `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
1066
+ // derivable from the tree — on the row this was built for the two legal merges score 18 and
1067
+ // 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
1068
+ // discards the winner. Every variant is emitted and the differ referees, exactly as
1069
+ // `/regcopy` does for its allocator-ambiguous tail choice.
1070
+ //
1071
+ // POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
1072
+ // levers do not compose. These are not a second lever composed onto the first: coalescing is
1073
+ // enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
1074
+ // just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
1075
+ //
1076
+ // EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
1077
+ // runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
1078
+ // the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
1079
+ respell('/scopebase', () => hoistScopedBases(sfn));
1080
+ // `/regionbase` — the same pass under its second region rule: a base the source spells inside N
1081
+ // disjoint regions becomes N locals, one per region, rather than one at function scope. A LEVER
1082
+ // beside `/scopebase`, not a replacement for it: both spellings and the un-hoisted primary stay
1083
+ // in the list, so the differ settles which allocation the original had.
1084
+ const regionbase = (): SFn | null => hoistScopedBases(sfn, { regions: 'per-region' });
1085
+ respell('/regionbase', regionbase);
1086
+ // …and its `/volatile` PRODUCT, narrowed to exactly the locals this lever mints — the same
1087
+ // pairing `/livebase` and `/inlinebase` already carry, for the same reason. The shape this
1088
+ // lever exists for is a DEVICE base (the DMA block at 0x040000D4), and the project's own
1089
+ // reference spells it `vu32 *dmaRegs`; without the product every region local this lever wins
1090
+ // with is published UNqualified, and `compareScored`'s `deviceVolatile` term — which prefers
1091
+ // the qualified twin on a tie — never sees a qualified twin to prefer. It is a candidate like
1092
+ // any other where the qualifier costs bytes, and the differ referees.
1093
+ const regionVolatile = (): SFn | null => {
1094
+ const r = regionbase();
1095
+ return r ? volatilePtrLocals(r, createdLocals(sfn, r)) : null;
1096
+ };
1097
+ respell('/regionbase/volatile', regionVolatile);
1098
+ // …and the `/vol-store` triple, the pairing this lever is the first to inhabit (see
1099
+ // l3/volstore.ts, where the two qualifiers' reach over a tree's OWN locals is disjoint).
1100
+ // `/volatile` qualifies a pointer LOCAL and `/vol-store` a STORE SITE, and this lever leaves
1101
+ // both in one function: it homes the regions holding two or more direct uses and leaves every
1102
+ // other spelling of the same device address inline. On `synthetic:dmascope` that residue is
1103
+ // the write to REG_DMA0CNT that STARTS the transfer, and without the triple it is published
1104
+ // bare beside three `volatile s32 *` region locals.
1105
+ respell('/regionbase/volatile/vol-store', () => {
1106
+ const v = regionVolatile();
1107
+ return v ? volStore(v) : null;
1108
+ });
1109
+ const enumerate = (
1110
+ label: string,
1111
+ from: () => SFn | null | undefined,
1112
+ variantsOf: (s: SFn) => { merged: string; sfn: SFn }[] = coalesceCandidates,
1113
+ ): void => {
1114
+ let variants: { merged: string; sfn: SFn }[] = [];
1115
+ try {
1116
+ const base = from();
1117
+ variants = base ? variantsOf(base) : [];
1118
+ } catch (e) {
1119
+ opts.onLeverError?.(name + leverLabel + label, firstLine(e));
1120
+ return;
1121
+ }
1122
+ for (const c of variants) {
1123
+ respell(`${label}-${c.merged}`, () => c.sfn);
1124
+ }
1125
+ };
1126
+ enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
1127
+ enumerate('/coalesce', () => sfn);
1128
+ // `/volatile`'s per-local SUBSETS: which pointers the source declared volatile is
1129
+ // per-pointer knowledge (an MMIO block and a plain RAM table sit side by side, and
1130
+ // qualifying the table blocks the read collapse its region wants), so each proper
1131
+ // non-empty subset is its own candidate — the same alternative-OUTPUTS mechanism as the
1132
+ // coalesce merges, not a product (l3/volatileptr.ts volatileSubsetCandidates carries the
1133
+ // ≤3 cap). The all-qualifiers form is plain `/volatile` above; the livebase product's
1134
+ // subsets ride below with the product's own `only` scope.
1135
+ enumerate(
1136
+ '/volatile',
1137
+ () => sfn,
1138
+ (s) => volatileSubsetCandidates(s),
1139
+ );
1140
+ respell('/indexed', () => reindexWalks(sfn));
1141
+ respell('/indexed/volatile', () => {
1142
+ const kept = new Set<string>();
1143
+ const r = reindexWalks(sfn, kept);
1144
+ return r ? volatilePtrLocals(r, kept) : null;
1145
+ });
1146
+ // `/livebase` — hoist a reused leaf base the default basecse pass REFUSED (l3/basecse.ts,
1147
+ // LIVEBASE_GATES): its `loop` and `repeated-const-offset` rules predict re-materialization,
1148
+ // and an MMIO poll (store then re-read the same fixed offset while it spins) is the shape
1149
+ // where the prediction is wrong — the compiler holds ONE base register across stores, the
1150
+ // loop, and the read-back. The primary already carries every base those rules admit, so a
1151
+ // hoist-nothing result means the lever has nothing to add and declines.
1152
+ // One family per admission row; a row binding exactly what an earlier row bound is the same
1153
+ // spelling under a different label, so it declines for that too. `/basefold`'s TWO rows and
1154
+ // `/unfolded` join the roster where the target declares the fold, and `/orderbase` where it
1155
+ // declares the array-shape fork, so a target with neither is offered the two `/livebase` rows
1156
+ // and nothing else. The same fact is stated at the POLICY sites above; a roster change repairs
1157
+ // all of them or none.
1158
+ const admissions: readonly BaseAdmission[] = [
1159
+ ...LIVEBASE_ADMISSIONS,
1160
+ ...(target.compilerBehaviors.foldsConstAddrOffset ? [...BASEFOLD_ADMISSIONS, ...UNFOLDED_ADMISSIONS] : []),
1161
+ // …and the ORDER row where the compiler's subscript expansion forks on the base's array-ness,
1162
+ // which is the same opt-in raise/globalshape.ts carries: with it off nothing is stamped, so
1163
+ // `order-licensed` would refuse every key anyway and this only saves the census.
1164
+ ...(target.compilerBehaviors.arrayShapeFromStride ? ORDERBASE_ADMISSIONS : []),
1165
+ ];
1166
+ // AND THE SAME SKIP KEYED ON THE LICENCE ITSELF WOULD BUY NOTHING, which is worth a paragraph
1167
+ // because this row is where the next reader will propose it. `orderLicensedGlobals` is decidable
1168
+ // on the lifted fn, so the row could also be dropped wherever THAT set is empty. It would be
1169
+ // sound, and it would be inert, for the same one reason: an empty licence stamps no
1170
+ // `baseOrdered` (structure.ts `stampOrderedBases`), so `order-licensed` refuses every key, so
1171
+ // `hoist` returns null and this row's three emission sites — two `respell`s and the `enumerate`
1172
+ // whose generator fans over volatile SUBSETS, so the third is a set and not one spelling — emit
1173
+ // nothing. Which GENERALIZES to every axis carrying a licence: a skip like it is sound exactly
1174
+ // where the axis would have emitted no candidate, so a sound one shrinks the fan by zero, so it
1175
+ // removes no COMPILE, and one compile per candidate is where a ranked run's cost is; what it
1176
+ // saves is one `admittedBases` walk per tree. `docs/level-tower.md` carries the general form.
1177
+ // Measured on klonoa's `LoadBGTilemapData`, the checkout function whose 112,896-candidate fan
1178
+ // raises the question: the licence is empty on every lift variant of BOTH symbol-map arms —
1179
+ // four named symbols DO reach the licence table map-ful and the ADDRESS gates refuse all four,
1180
+ // so "the pool spells no names" is not the reason — and the skip fires on every tree there and
1181
+ // removes not one candidate.
1182
+ //
1183
+ // IF IT IS EVER BUILT ANYWAY, IT IS `orderLicensedGlobals(fn, target)` READ AT THE SITE BELOW
1184
+ // that hands `orderLicensed` to the structuring call, PER LIFT VARIANT — never per function,
1185
+ // and never either of the two predicates standing beside it in that same loop. All three wrong
1186
+ // readings delete the SAME four live candidates on `sub_806800C` in the sa3 checkout, in BOTH
1187
+ // arms: `unsigned/setup-args/orderbase` and its `/flip-join`, `/derived-home` and
1188
+ // `/flip-join/derived-home` siblings.
1189
+ // · PER FUNCTION — `/setup-args` narrows the lift and can license a name the base lift does
1190
+ // not, so the first variant's answer is not the function's.
1191
+ // · `inferGlobalArrays`, seven lines above the licence call and off the same `fn` — a
1192
+ // documented strict SUBSET (`raise/globalshape.ts`), and measurably empty on functions
1193
+ // where the licence is not, several of them carrying `/orderbase` candidates.
1194
+ // · the licence RECOMPUTED after `raiseRecovered` — not the next statement but the third,
1195
+ // ten lines down, past the map-precedence delete over `inferredSymbols` and
1196
+ // `applyIdiomPatterns`. `raise/globalshape.ts` says in its own module note that the raising
1197
+ // tower destroys the order evidence, but the tempting next step — "so it reads empty
1198
+ // everywhere and the skip is free" — is FALSE: it reads NON-empty on a function whose
1199
+ // `/orderbase` candidates it then deletes anyway. Firing less often is not a defence.
1200
+ // A per-row label/source diff catches the last two, and CANNOT catch the first. Both of those
1201
+ // delete `unsigned/orderbase` off `synthetic:bgarr:agbcc`, the exact source that row publishes
1202
+ // as its score-0 MATCH — a row carrying no symbol map, so its single arm is the one the gate
1203
+ // actually runs. The per-function reading needs `/setup-args` AND `/orderbase` in ONE label,
1204
+ // and NO published winner label carries both — so a green corpus gate is evidence about two of
1205
+ // these readings and none at all about the third. That property is the gate, not a count:
1206
+ // apps/benchmark/test/census.test.ts asserts it over the committed artifact.
1207
+
1208
+ // The CENSUS is a pure function of (this tree, that table) and every row asks for every
1209
+ // earlier row's, from thunks each product re-invokes — quadratic in the roster, times the
1210
+ // number of products. Memoized on the gate table's identity. The value is a list of key
1211
+ // STRINGS whose two readers here only compare and count it, so a memo hit shares no tree.
1212
+ const censuses = new Map<readonly Gate<BaseKey>[], readonly string[]>();
1213
+ const census = (g: readonly Gate<BaseKey>[]): readonly string[] => {
1214
+ const hit = censuses.get(g);
1215
+ if (hit) {
1216
+ return hit;
1217
+ }
1218
+ const v = admittedBases(sfn, g);
1219
+ censuses.set(g, v);
1220
+ return v;
1221
+ };
1222
+ /** Does an EARLIER roster row already bind exactly `bound` at this placement? Then this row is
1223
+ * that row's spelling under a second label and declines.
1224
+ *
1225
+ * Same bases in the same POSITION is the same spelling; the same bases somewhere else is not,
1226
+ * which is why the placement is a conjunct and not an afterthought. `rows` is the slice's
1227
+ * own list, so the two readers scope it differently — the roster hoist asks over the whole
1228
+ * admissions roster, the homesplit pairing over the PAIRED rows only, because a skip there
1229
+ * must never drop a withhold no other row enumerates. Captures `census`, so a repeated table
1230
+ * costs one memo lookup rather than a second walk. */
1231
+ const shadowedByEarlier = (
1232
+ rows: readonly { placement: HoistPlacement; gates: readonly Gate<BaseKey>[] }[],
1233
+ i: number,
1234
+ placement: HoistPlacement,
1235
+ bound: readonly string[],
1236
+ ): boolean => rows.slice(0, i).some((r) => r.placement === placement && sameBases(bound, census(r.gates)));
1237
+ const livebases = admissions.map(({ suffix, gates, placement, pairings }, i) => {
1238
+ const hoist = (): SFn | null => {
1239
+ const bound = census(gates);
1240
+ if (bound.length === 0) {
1241
+ return null;
1242
+ }
1243
+ return shadowedByEarlier(admissions, i, placement, bound) ? null : hoistBaseLocals(sfn, gates, placement);
1244
+ };
1245
+ const volatiles = (): SFn | null => {
1246
+ const r = hoist();
1247
+ return r ? volatilePtrLocals(r, createdLocals(sfn, r)) : null;
1248
+ };
1249
+ return { suffix, hoist, volatiles, pairings, gates, placement };
1250
+ });
1251
+ // THE PLACEMENT DIFFERENTIAL, one composition inwards. `respell` re-checks a lever's
1252
+ // placement across the statement SHAPES derived onto it; the lever-on-lever products below
1253
+ // are the same hazard in the same file and are outside it, because the composition happens
1254
+ // INSIDE one `make()` thunk and the intermediate tree never reaches `respell`'s check. A
1255
+ // def-MOVING pass (`sinkInitsToFirstUse`, `nearBaseClusters`, `reindexWalks`) running on a
1256
+ // tree a PLACING lever built can move a def below a use exactly as a shape can. Same
1257
+ // differential, so a placement neither pass can model is not judged either way, and the throw
1258
+ // lands inside the thunk — a reported, dropped candidate.
1259
+ //
1260
+ // BOTH SIDES' minted locals, because the mover MINTS TOO: `nearBaseClusters` creates the
1261
+ // cluster base it then places, and `reindexWalks` creates the induction variable, so the
1262
+ // outer lever's name diff alone is empty for a standalone mover and a strict subset for a
1263
+ // composition — the mover's own stranding of its own local walks straight through. Judging a
1264
+ // name the BEFORE tree does not carry keeps the differential honest rather than turning it
1265
+ // absolute: a name absent from `before` is never read there, so that walk passes and only the
1266
+ // `after` placement is judged.
1267
+ const survives = (before: SFn | null, after: SFn | null): SFn | null => {
1268
+ if (before !== null && after !== null) {
1269
+ assertPlacementSurvives(before, after, new Set([...createdLocals(sfn, before), ...createdLocals(sfn, after)]));
1270
+ }
1271
+ return after;
1272
+ };
1273
+ // Every product below fans over the rows a demanding row earned, never the whole roster.
1274
+ const paired = livebases.filter((l) => l.pairings);
1275
+ for (const { suffix, hoist, volatiles } of livebases) {
1276
+ respell(suffix, hoist);
1277
+ respell(`${suffix}/volatile`, volatiles);
1278
+ enumerate(`${suffix}/volatile`, hoist, (r) => volatileSubsetCandidates(r, createdLocals(sfn, r)));
1279
+ }
1280
+ // The livebase × indexed PAIRINGS — the third sanctioned product kind (see POLICY):
1281
+ // row-demanded, and the joint spelling is reachable from neither lever alone (the
1282
+ // frame-copy + DMA shape).
1283
+ for (const { suffix, hoist, volatiles } of paired) {
1284
+ respell(`${suffix}/indexed`, () => {
1285
+ const r = hoist();
1286
+ return r ? survives(r, reindexWalks(r)) : null;
1287
+ });
1288
+ respell(`${suffix}/volatile/indexed`, () => {
1289
+ const r = volatiles();
1290
+ return r ? survives(r, reindexWalks(r)) : null;
1291
+ });
1292
+ }
1293
+ // The livebase × sinkinit PAIRINGS — the same admission again: row-demanded
1294
+ // (kleod:DecompressDma), and the joint spelling is reachable from neither lever alone. The
1295
+ // bases whose placement moves the row are the ones only this lever's ablation binds, and
1296
+ // `/sinkinit` alone reads the DEFAULT hoist's head, which does not carry them.
1297
+ for (const { suffix, hoist, volatiles } of paired) {
1298
+ respell(`${suffix}/sinkinit`, () => {
1299
+ const r = hoist();
1300
+ return r ? survives(r, sinkInitsToFirstUse(r)) : null;
1301
+ });
1302
+ respell(`${suffix}/volatile/sinkinit`, () => {
1303
+ const r = volatiles();
1304
+ return r ? survives(r, sinkInitsToFirstUse(r)) : null;
1305
+ });
1306
+ }
1307
+ // The livebase x homesplit PAIRINGS — the fourth sanctioned product kind, and row-demanded
1308
+ // (synthetic:dmapoll): ONE base kept at the head and a SECOND split per region, which neither
1309
+ // lever spells alone because each applies its own policy to every base it binds: compiled
1310
+ // against that row's own object, the score reaches 0 only where the two policies land on
1311
+ // DIFFERENT bases, and every uniform choice is worse. The endpoint figures live in
1312
+ // l3/homesplit.ts, which is the measurement's one home, along with why this is a PIPE and
1313
+ // never a merge.
1314
+ //
1315
+ // WHICH key is withheld is not derivable, so every admitted key is its own candidate, LABELLED
1316
+ // with that key — a label is an identity, and one label over two withholds names two programs.
1317
+ // `HOMESPLIT_FAN_GATES`' `homesplit-fan-cap` is what bounds the product.
1318
+ // ADDITIVE, like every lever here: `/livebase-block`, `/regionbase`, `/scopebase` and the
1319
+ // un-hoisted primary all stay in the list, which is what keeps `synthetic:dmaflat` — where the
1320
+ // composed spelling scores 13 against its own 0 — at MATCH.
1321
+ for (const [i, { suffix, gates, placement }] of paired.entries()) {
1322
+ const bound = census(gates);
1323
+ // The ROSTER's dedup, which `hoist` applies to every other product and this loop has to ask
1324
+ // for itself: every pairing piped from a shadowed row is that row's spelling under a second
1325
+ // label too. Asked over the PAIRED rows only — the earlier row runs the identical pipe and
1326
+ // emits the identical source. Without it both run and `seen` collapses the pair afterwards,
1327
+ // having paid a head hoist, region plan, rewrite and emit for each.
1328
+ if (shadowedByEarlier(paired, i, placement, bound)) {
1329
+ continue;
1330
+ }
1331
+ // The function-level half of the pairing's admission, asked ONCE over the census: both its
1332
+ // rules read the key count and nothing else, so inside the pipe they would cost that whole
1333
+ // pipe to report a fact this loop already holds.
1334
+ for (const key of homeSplitWithholds(bound)) {
1335
+ const lever = `${suffix}/homesplit-${homeSplitTag(key)}`;
1336
+ const homesplit = (): SFn | null => {
1337
+ const p = splitHomeBases(sfn, {
1338
+ gates,
1339
+ placement,
1340
+ key,
1341
+ ...(target.capabilities.deviceRegisters ? { deviceRegisters: target.capabilities.deviceRegisters } : {}),
1342
+ });
1343
+ return p ? survives(p.homed, p.split) : null;
1344
+ };
1345
+ const homesplitVolatile = (): SFn | null => {
1346
+ const r = homesplit();
1347
+ return r ? volatilePtrLocals(r, createdLocals(sfn, r)) : null;
1348
+ };
1349
+ respell(lever, homesplit);
1350
+ respell(`${lever}/volatile`, homesplitVolatile);
1351
+ respell(`${lever}/volatile/vol-store`, () => {
1352
+ const v = homesplitVolatile();
1353
+ return v ? volStore(v) : null;
1354
+ });
1355
+ }
1356
+ }
1357
+ // `/mulfirst` — product-first commutative sums (l3/mulfirst.ts): IDO/mwcc schedule the
1358
+ // independent operand's load above the product's mflo/mullw, so def order re-spells a
1359
+ // product-first source as load-first. Both orders are emitted; the differ referees.
1360
+ respell('/mulfirst', () => mulFirstSums(sfn));
1361
+ // `/nearbase` — neighbor absolute addresses derive from one shared base local
1362
+ // (l3/nearbase.ts): one object's cells anchored as separate pool constants re-spell as
1363
+ // offsets off its lowest address, within the target's declared derivation reach. Both
1364
+ // spellings are emitted; the differ referees.
1365
+ const nearSpan = target.compilerBehaviors.nearBaseSpan;
1366
+ const near = (base: SFn | null): SFn | null =>
1367
+ base !== null && nearSpan !== undefined ? survives(base, nearBaseClusters(base, nearSpan)) : null;
1368
+ // …and WHERE its cluster inits sit, which is a second question with its own answer.
1369
+ // `l3/nearbase.ts` places them above the run already there, and that is a committed choice
1370
+ // made on one row (`synthetic:dmafield`) rather than on a compiler fact — a cluster base is
1371
+ // reached at 2+ addresses by construction, so "first touched late" says nothing about it, and
1372
+ // which order the source wrote is per-function knowledge the asm does not carry. With no
1373
+ // second candidate that choice decides a MATCH rather than a candidate, which is the whole
1374
+ // reason this row is here. `/sinkinit` here is the same transform it is everywhere else — each leading base init at its own first use — applied to a run whose order
1375
+ // `prepend` chose, so where first use does not separate two inits the cluster base still leads
1376
+ // (that tie is the one thing this is NOT identical to `placeBaseLocals(…, 'first-use')` on;
1377
+ // pinned in test/sinkinit.test.ts). Priced over the corpus at 590 candidate sources on 15 of
1378
+ // 1140 observations — where the two orderings agree the sink declines and nothing is added.
1379
+ const nearSunk = (base: SFn | null): SFn | null => {
1380
+ const r = near(base);
1381
+ return r ? survives(r, sinkInitsToFirstUse(r)) : null;
1382
+ };
1383
+ respell('/nearbase', () => near(sfn));
1384
+ respell('/nearbase/sinkinit', () => nearSunk(sfn));
1385
+ // The livebase × nearbase PAIRINGS — the same admission as livebase × indexed above:
1386
+ // the volatile triple is the row-demanded one, and the joint spelling is reachable from
1387
+ // neither lever alone (a neighbor-cell object and a multi-index MMIO block in one
1388
+ // function — each lever's constants are invisible to the other's model); the plain
1389
+ // sibling rides for symmetry with /livebase/indexed.
1390
+ for (const { suffix, hoist, volatiles } of paired) {
1391
+ respell(`${suffix}/nearbase`, () => near(hoist()));
1392
+ respell(`${suffix}/volatile/nearbase`, () => near(volatiles()));
1393
+ respell(`${suffix}/nearbase/sinkinit`, () => nearSunk(hoist()));
1394
+ respell(`${suffix}/volatile/nearbase/sinkinit`, () => nearSunk(volatiles()));
1395
+ }
1396
+ // The livebase × coalesce PAIRINGS — same admission again: the volatile triple is the
1397
+ // row-demanded one, the joint spelling reachable from neither lever alone (an MMIO base
1398
+ // worth homing and a counter shared across both arms of one if, in one function); the
1399
+ // plain sibling rides for symmetry.
1400
+ // ARM-DISJOINT merges only: the demanding row's shared counter is that class, and the
1401
+ // span-model merges already ride the plain /coalesce label — pairing them too would
1402
+ // multiply candidates with no row behind it.
1403
+ for (const { suffix, hoist, volatiles } of paired) {
1404
+ enumerate(`${suffix}/coalesce`, hoist, armDisjointCandidates);
1405
+ enumerate(`${suffix}/volatile/coalesce`, volatiles, armDisjointCandidates);
1406
+ }
1407
+ // `/parkfirst` — incoming-argument parks lead the entry prefix (l3/parkfirst.ts): the
1408
+ // park's `mov` lifts to pure SSA aliasing, so its position is unrecoverable and the
1409
+ // default order is emission's. Both orders are emitted; the differ referees.
1410
+ respell('/parkfirst', () => parkParamsFirst(sfn));
1411
+ // `/sinkinit` — each leading pointer-base init sinks to its own first use (l3/sinkinit.ts):
1412
+ // the base hoist places every init at the head of the body, which keeps the base live across
1413
+ // everything above its first use and can cost a callee-saved register the original avoided.
1414
+ // Which placement the source used is not derivable from the asm, so both are emitted and the
1415
+ // differ referees.
1416
+ respell('/sinkinit', () => sinkInitsToFirstUse(sfn));
1417
+ // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
1418
+ // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
1419
+ // ambiguous, so both are ranked).
1420
+ //
1421
+ // LABELLED BY THE TAIL THE VARIANT CARRIES, NEVER BY ITS INDEX. The reuse tail exists only
1422
+ // where R1 fired, so the list is 1, 2 or 3 long and the fresh tail sits at no fixed position;
1423
+ // an index-keyed label table names the fresh spelling `/regcopy-ret` on every R1-less function
1424
+ // — the dead-var-reuse name on the one spelling that has no dead var — and `candidateLabel` is
1425
+ // what every census in this repo counts, `bench diff` included. The exhaustive record is the
1426
+ // pin: a new tail kind is a type error here rather than a silent `/regcopy-3`.
1427
+ // `cli/test/matching/regspell-candidate.test.ts` holds the correspondence.
1428
+ const REGCOPY_LABEL: Record<RegcopyTail, string> = {
1429
+ none: '/regcopy',
1430
+ reuse: '/regcopy-ret',
1431
+ fresh: '/regcopy-ret-fresh',
1432
+ };
1433
+ registerishSpellings(sfn).forEach((alt) => respell(REGCOPY_LABEL[alt.tail], () => alt.sfn));
1434
+ return { spellings };
1435
+ };
268
1436
  // The SYMBOL-MAP spelling is itself a ranked LEVER on the same footing as signedness/branch
269
1437
  // sense: naming a global changes agbcc's codegen (the eager-load effect), and which side
270
1438
  // byte-wins is genuinely per-function — the dogfood's landed matches split between extern
271
1439
  // spellings and raw-address macros. So when a map is present the raw-global spelling is ALSO
272
1440
  // enumerated ('/raw-globals') and the differ referees; the dedup below collapses the pair
273
1441
  // wherever the map changed nothing, so this never scores worse than either side alone.
1442
+ //
1443
+ // Does the `/raw-globals` arm have a RANK of its own to spell? Read off the DERIVED shapes the
1444
+ // map does not answer for — the only ones a map-less structuring ever sees — because that is
1445
+ // exactly the population `/flat-rank`'s decline just below is about. A superset of what the raw
1446
+ // arm's own lift derives (it is read off the probe's), for the reason the axis gate above is one
1447
+ // too: this only ADDS an OFF arm, and where the arm changes nothing the tree dedup collapses it.
1448
+ const rawDerivesRank = [...probeShapes].some(
1449
+ ([n, i]) => byName?.get(n) === undefined && (arrayInnerExtents(i)?.length ?? 0) > 0,
1450
+ );
274
1451
  const symbolVariants: { suffix: string; symbols?: typeof opts.symbols }[] = opts.symbols
275
1452
  ? [
276
1453
  { suffix: '', symbols: opts.symbols },
@@ -279,163 +1456,380 @@ export function enumerateCandidates(
279
1456
  : [{ suffix: '' }];
280
1457
  for (const [svIndex, sv] of symbolVariants.entries()) {
281
1458
  const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
1459
+ // `/no-bitfield` names a spelling the MAP makes available, so it has no inhabitant on the
1460
+ // variant that structures without one: structure() normalizes `spellBitfieldMembers` to false
1461
+ // when `symbols` is absent, so both arms structure the identical tree whatever reads it. This
1462
+ // declines to build the second arm rather than leaving the tree skip to collapse it, which is
1463
+ // worth 512 of LoadBGTilemapData's 1536 structurings under docs/ranked-repro.md's flags.
1464
+ // Declining is not pruning — same posture as the signedness decline below, and the same
1465
+ // candidate list; bitfield-members.test.ts pins the normalization the decline rests on.
1466
+ // …and `/no-ptr-elem` names a spelling only the MAP makes available, for the same reason:
1467
+ // structure() normalizes `spellPtrMemberElements` to false without `symbols`, so both arms
1468
+ // structure the identical tree on the raw variant. `/flat-rank` IS NOT a third such spelling,
1469
+ // and the difference is why its decline is asked of the EVIDENCE and not of the map: the
1470
+ // declared subscripts come off a render context structure() builds from the UNION of the map
1471
+ // and the shapes this function's own strides evidence (raise/globalshape.ts), so the raw arm
1472
+ // derives a rank of its own. The decline therefore stands only where no derived shape carries
1473
+ // a rank for that arm to spell — the condition under which both arms really do structure the
1474
+ // identical tree.
1475
+ const svCands = sv.symbols
1476
+ ? axisCands
1477
+ : axisCands.filter((s) => s.bitfields && s.ptrElems && (s.declRank || rawDerivesRank));
1478
+ const treeOwnedFold = treeOwnedIn(sv.symbols);
1479
+ // The signedness axis DECLINES where the pin has nothing to pin. `pinScalarParams` writes only
1480
+ // over an entry param still `unknown`/`int` that is not one of the recovered pointers/
1481
+ // aggregates `ptrIdx` excludes; where no param is left, the second pass re-lifts, re-raises and
1482
+ // re-structures a function BYTE-IDENTICAL to the first, reaching a tree the first pass already
1483
+ // spelled. Declining is not pruning: the candidate list is the same list, reached without
1484
+ // building the duplicates. What the decline saves is therefore invisible in the candidates —
1485
+ // sign-axis.test.ts counts LIFTS, the one reading of the enumeration that it moves.
1486
+ //
1487
+ // Read off the pin's OWN call, per symbol variant — the `/raw-globals` arm lifts without the
1488
+ // map and answers for itself, so no lift is governed by a fact measured on a different one.
1489
+ let pinnable = false;
282
1490
  for (const cand of SIGN_CANDS) {
283
- const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
284
- verify(fn);
285
- applyIdiomPatterns(fn, target, opts.patterns);
286
- // The shared tower spine (pipeline.ts) the candidate's ONE difference from decompile() is the
287
- // signedness pin, injected between pre-recovery and recoverTypes via the beforeRecover hook.
288
- raiseRecovered(fn, target, { beforeRecover: () => pinScalarParams(fn, cand.signed, ptrIdx) });
289
- for (const s of senseCands) {
290
- // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
291
- // senses structure the same recovered function without re-lifting.
292
- let sfn: SFn;
1491
+ if (cand.signed && !pinnable) {
1492
+ break;
1493
+ }
1494
+ const base = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
1495
+ // `/setup-args` pass a prototype-less callee only what the CALLING BLOCK set up; which of
1496
+ // the two readings the source spelled is genuinely ambiguous, and frontend/ssa.ts
1497
+ // narrowToSetupArgs carries the argument for why the differ is what settles it.
1498
+ //
1499
+ // A LIFT VARIANT, in the same product position as the signedness pin and the symbol-map
1500
+ // spelling — not a re-spelling lever under the POLICY note below. Dropping an argument
1501
+ // changes the IR every structuring axis then reads: the value the argument carried loses a
1502
+ // consumer, so what materializes changes with it, and a row whose callee arities are GUESSED
1503
+ // can need the narrowed lift to reach a spelling neither side reaches alone —
1504
+ // `kleod:ReadKeyInput` did, until its manifest declared those arities to asmlift as its own
1505
+ // `ctx` already declared them to m2c; it now matches on the base lift, at
1506
+ // `unsigned/derived-home`, enumerating no variant at all.
1507
+ // Only spellings the narrowing actually changed reach a compiler: one that changes nothing
1508
+ // downstream emits the base spelling's source and the dedup collapses it, and a DECLARED
1509
+ // arity records nothing and enumerates no variant at all. What survives the dedup is the
1510
+ // product's real price, and it is not free: this arm added 1201 distinct candidates, all of
1511
+ // them in the 13 rows whose narrowing changes anything downstream. Quoted as a DELTA with no
1512
+ // denominator, because the agbcc row count it was taken over has moved since — measured
1513
+ // before those six kleod rows declared their callee arities,
1514
+ // and declaring one takes its row out of this population.
1515
+ //
1516
+ // `/connective` — spell a same-scrutinee const-test chain as `x == 0 || x == 2` rather than
1517
+ // leaving it to switch recovery. They are mutually exclusive within one raise
1518
+ // (raise/shortcircuit.ts's REFUSALS note has the mechanism: a folded `logic_or` is not the
1519
+ // `icmp` switch-recover.ts requires), so no predicate settles it — the differ does.
1520
+ // Enumerated only where THIS VARIANT's lift reports the PAIRWISE refusal, which a handful of
1521
+ // corpus rows do.
1522
+ //
1523
+ // WHAT IT IS *NOT* FOR: the shared-arm spelling `switch (x) { case 0: case 2: … }`. That is
1524
+ // the structurer's DEFAULT (switch-recover.ts groups case values sharing a body), and it is
1525
+ // the same object as the `||` only in the DEGENERATE shape — one case group plus `default:`,
1526
+ // where the dispatch has nothing to balance (agbcc 12 instructions each and one .text md5,
1527
+ // IDO 64 bytes each and one md5). A second group parts them: agbcc 20 against 16, the switch
1528
+ // building a balanced `bgt` dispatch where the chain tests sequentially; IDO 80 bytes each,
1529
+ // different bytes. So on a recovered MULTI-GROUP switch the connective is a genuine second
1530
+ // spelling, and this axis is the only thing that reaches it.
1531
+ //
1532
+ // WHERE IT IS WORTH 0 POINTS IT IS STILL NOT WORTH NOTHING, and the two populations differ.
1533
+ // On `kleod:ProcessInputAndUpdateEntities` the grouping alone reaches the same score the
1534
+ // axis reaches with it, yet the published winner there carries `/connective` and spells its
1535
+ // site `gUnk_030034C0 == 0 || gUnk_030034C0 == 2` — so deleting the axis moves that row's
1536
+ // SOURCE. It moves the SCORE on the other population, where switch recovery declined
1537
+ // ENTIRELY and the tree came out as nested `if`s: `kleod:CountCollectedGems` and
1538
+ // `kleod:CheckWorldCompletion`, neither with a `switch` at all. Telling the two apart needs
1539
+ // an L3 fact (did recovery produce a grouped arm?) at a raise-level hook, which is a level
1540
+ // inversion; the fan is the price instead. NO ABLATION PAIR IS QUOTED HERE: the artifact
1541
+ // carries only the with-axis score, so half a refreshed pair would manufacture a delta
1542
+ // across two bases — re-run the ablation to price it.
1543
+ //
1544
+ // It rides the LIFT variants because the raise mutates in place: a second raise policy needs
1545
+ // its own copy of the lifted fn, exactly as `/setup-args` needs one to narrow. Crossed with
1546
+ // `/setup-args` rather than nested under it — dropping a call argument and choosing this
1547
+ // shape are independent, and the four combinations dedup down to whatever the trees differ on.
1548
+ const connectiveVariants = treeOwnedFold
1549
+ ? [
1550
+ { suffix: '', connective: false },
1551
+ { suffix: '/connective', connective: true },
1552
+ ]
1553
+ : [{ suffix: '', connective: false }];
1554
+ const liftVariants: { suffix: string; narrow: boolean; connective: boolean }[] = (
1555
+ hasSetupArgsNarrowing(base)
1556
+ ? [
1557
+ { suffix: '', narrow: false },
1558
+ { suffix: '/setup-args', narrow: true },
1559
+ ]
1560
+ : [{ suffix: '', narrow: false }]
1561
+ ).flatMap((l) => connectiveVariants.map((c) => ({ ...l, ...c, suffix: `${l.suffix}${c.suffix}` })));
1562
+ for (const lv of liftVariants) {
1563
+ let fn: Fn;
1564
+ let inferredSymbols = new Map<string, SymbolInfo>();
1565
+ let orderLicensed: ReadonlySet<string> = new Set<string>();
293
1566
  try {
294
- sfn = structureChecked(fn, {
295
- ...svOpts,
296
- preserveDivergentBranchSense: s.sense,
297
- anchorConstCopies: s.anchor,
298
- spellBitfieldMembers: s.bitfields,
299
- rereadGlobals: s.reread,
300
- });
1567
+ // A NON-EMPTY SUFFIX IS WHAT NEEDS ITS OWN COPY, the catch below's spelling: naming the
1568
+ // flags here would leave a fourth axis sharing the primary's already-mutated `base`.
1569
+ fn = lv.suffix === '' ? base : frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
1570
+ if (lv.narrow && !narrowToSetupArgs(fn)) {
1571
+ continue; // nothing to cut after all — the base lift's own candidates already cover it
1572
+ }
1573
+ verify(fn);
1574
+ // This variant's OWN array-shape evidence, off its own lifted fn (a symbol map promotes
1575
+ // numeric pool words to `gaddr`, so the `/raw-globals` arm genuinely answers differently).
1576
+ //
1577
+ // NEVER A NAME THE PROJECT MAP KNOWS, and the filter is here rather than left to
1578
+ // structure()'s map-first lookup because THE `/raw-globals` ARM STRUCTURES WITH NO MAP
1579
+ // AND DECLARES WITH ONE. `declSymbols` is probe-derived and map-last (the map wins every
1580
+ // name it knows), so on an asm whose pool NAMES its globals the raw arm could spell a
1581
+ // subscript off THIS function's strides — `gFoo[i][j]`, inner extent 2 — while the
1582
+ // declaration beside it came from the map — `extern u32 gFoo[][8];` — and the emitted C
1583
+ // would stride by 8. Compiling, and the wrong address. One name the map describes is
1584
+ // one name this derivation does not claim, on either arm.
1585
+ //
1586
+ // AND NEVER A SHAPE THE DECLARATION BLOCK WILL NOT CARRY, which is the same hazard one
1587
+ // step further out. `declSymbols` is built ONCE, off the probe's lift; this map is built
1588
+ // per variant, off the variant's own. Where the two lifts disagree about a name the map
1589
+ // does NOT know, the map-precedence delete above says nothing and the raw arm would
1590
+ // again spell from one shape and declare from another. So the test is not "the map
1591
+ // knows this name" but "whatever will be DECLARED for this name says the same thing" —
1592
+ // a name the two answer differently keeps the cast form, which needs no declaration.
1593
+ inferredSymbols = inferGlobalArrays(fn, target);
1594
+ // The ORDER half, off the same variant lift. NO map-precedence filter, and the
1595
+ // asymmetry is the point: a shape is a DECLARATION, so a name the map describes must
1596
+ // not be spelled from this function's strides — a licence declares nothing, and the
1597
+ // spelling it enables keeps the cast (`(T *)&gSym`), which is byte-correct under any
1598
+ // declaration. A map that names the symbol an array takes the access to a bare `var`
1599
+ // base anyway, which carries no licence: the two never meet.
1600
+ orderLicensed = orderLicensedGlobals(fn, target);
1601
+ for (const [n, si] of [...inferredSymbols]) {
1602
+ if (baseOpts.symbols?.has(n) === true || !sameDerivedShape(declSymbols.get(n), si)) {
1603
+ inferredSymbols.delete(n);
1604
+ }
1605
+ }
1606
+ applyIdiomPatterns(fn, target, opts.patterns);
1607
+ // The shared tower spine (pipeline.ts). TWO differences from `decompile()`, both passed
1608
+ // here: the signedness pin, injected between pre-recovery and recoverTypes via the
1609
+ // `beforeRecover` hook, and the `pre.shortCircuit` connective owner, which `decompile()`
1610
+ // leaves at its default. Stated in full so this copy and pipeline.ts's cannot silently
1611
+ // diverge again — a third argument added here is a third line in both.
1612
+ raiseRecovered(
1613
+ fn,
1614
+ target,
1615
+ {
1616
+ beforeRecover: () => {
1617
+ pinnable = pinScalarParams(fn, cand.signed, ptrIdx) || pinnable;
1618
+ },
1619
+ },
1620
+ prototypes[name],
1621
+ { shortCircuit: { foldTreeOwned: lv.connective } },
1622
+ );
301
1623
  } catch (e) {
302
- if (!s.anchor && s.bitfields && !s.reread) {
303
- throw e; // the base axes keep their behavior: a structuring failure aborts the row
1624
+ // THE PRIMARY IS THE EMPTY SUFFIX, by construction: every lift axis appends a non-empty
1625
+ // one, so `suffix === ''` is the only spelling of "no lever is on" that stays correct
1626
+ // when a fourth is added — the same reason the structuring half below reads its table
1627
+ // instead of naming its flags.
1628
+ if (lv.suffix === '') {
1629
+ throw e; // the base lift keeps its behavior: a raising failure aborts the row
304
1630
  }
305
- // an anchored variant that fails structuring or its contracts is a dropped lever, never
306
- // an aborted enumeration — same rule as respell below
307
- opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
1631
+ // A dropped lever, never an aborted enumeration the same posture as `respell`.
1632
+ opts.onLeverError?.(name + lv.suffix, firstLine(e));
308
1633
  continue;
309
1634
  }
310
- // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
311
- // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
312
- // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
313
- // when a loop re-spells, BOTH representations are emitted and the differ referees. The
314
- // re-spelling passes the same boundary contracts as the primary; one that fails them is
315
- // dropped here never scored, never able to win.
316
- // Each spelling's symbol refs are DERIVED from its own final tree right where the
317
- // spelling is emitted the single point a candidate comes into existence. No pipeline
318
- // stage carries refs (SFn has no such field), so a future l3 pass that rewrites the tree
319
- // can never leave a stale ref behind: whatever tree reaches emit is the tree the refs
320
- // describe, by construction. Collected against the FULL name-keyed map for EVERY
321
- // spelling variant — the '/raw-globals' sibling drops the map's shaped SPELLINGS, but
322
- // its tree still NAMES pool/reloc-derived globals (ARM `.word gSym`, MIPS `%lo(gSym)`),
323
- // and those references need declarations in the self-declared scoring world exactly
324
- // like the named variant's (without them every raw sibling fails to compile there,
325
- // and the eval-winning raw candidate becomes unreproducible outside project headers).
326
- const refsOf = (tree: SFn): { symbolRefs?: SymbolRef[] } => {
327
- const refs = baseOpts.symbols
328
- ? collectSymbolRefs(tree.body, baseOpts.symbols, tree.name).map((r) => {
329
- // name-only symbols carry the IR-derived access facts — the width authority
330
- // for their synthesized declaration (shaped symbols keep the map's truth)
331
- const access = r.info.shape === undefined ? accessFacts.get(r.name) : undefined;
332
- return access ? { ...r, access } : r;
333
- })
334
- : [];
335
- return refs.length ? { symbolRefs: refs } : {};
336
- };
337
- const spellings: { suffix: string; source: string; symbolRefs?: SymbolRef[] }[] = [
338
- { suffix: '', source: backend.emit(sfn), ...refsOf(sfn) },
339
- ];
340
- // Representation re-spellings — each a lever on the same footing as signedness/branch sense,
341
- // each guarded: it must pass the same boundary contracts as the primary AND emit (a backend
342
- // that declines by throwing — Pascal loud-fails unspellable shapes — drops the candidate,
343
- // never aborts the enumeration). A dropped re-spelling loses nothing: the primary remains.
344
- //
345
- // POLICY: re-spellings derive from the BASE spelling only — levers do not compose
346
- // (an /indexed + /regcopy product is deferred until a row demands it). And a lever must
347
- // PRESERVE SEMANTICS by construction: the differ referees byte-exactness (a wrong candidate
348
- // can never fake a score-0 match), but on a NONMATCH row the best-scoring source is shown
349
- // to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
350
- // defect class this project exists to avoid. Hence each lever's decline-over-approximate
351
- // gates, adversarially audited.
352
- // Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
353
- // from the pass itself — rather than from the contracts or the backend — would escape and
354
- // abort the whole enumeration for this row, primary included: the one way a lever can cost
355
- // a match. Making that structural rather than per-call-site means no lever can opt out.
356
- const respell = (suffix: string, make: () => SFn | null | undefined): void => {
357
- try {
358
- const alt = make();
359
- if (!alt) {
360
- return; // the lever declined to fire — no candidate, not a duplicate of the primary
361
- }
362
- assertResolved(alt);
363
- assertDerefsTyped(alt);
364
- spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
365
- } catch (e) {
366
- // A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
367
- // REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
368
- // so without this a lever that fails here vanishes with no trace — indistinguishable
369
- // from one that correctly declined, which is exactly the hidden failure
370
- // DroppedCandidate exists to surface.
371
- opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
1635
+ // the per-variant axis gates, on THIS variant's lifted fn — see the table doc
1636
+ const variantOff = STRUCTURING_AXES.filter((ax) => ax.variantGate !== undefined && !ax.variantGate(fn));
1637
+ const variantCands = svCands.filter((s) => variantOff.every((ax) => !s[ax.flag]));
1638
+ // `/merge-names` combinations whose un-merged sibling was DROPPED. `structure()` already
1639
+ // refuses to let the axis unlock a function the primary declines, but it can only see its own
1640
+ // refusalsa boundary contract fails out here, in `structureChecked`. Without this a
1641
+ // `/reread-globals/merge-names` candidate could ship where plain `/reread-globals` did not,
1642
+ // which is the same trade one level up. `senseCands` puts each `mergeNames:false` sibling
1643
+ // first, so the entry is always recorded before its merged twin is reached.
1644
+ const droppedPrimary = new Set<string>();
1645
+ for (const s of variantCands) {
1646
+ if (
1647
+ STRUCTURING_AXES.some((ax) => ax.strip && s[ax.flag] && droppedPrimary.has(s.suffix.replace(ax.suffix, '')))
1648
+ ) {
1649
+ // A SKIPPED variant is recorded exactly like a dropped one, or the closure would not be
1650
+ // transitive: with plain X dropped and X/inplace skipped-but-unrecorded,
1651
+ // X/inplace/merge-names would find neither stripped key and run shipping a
1652
+ // double-lever candidate where its ancestor failed the boundary contracts.
1653
+ droppedPrimary.add(s.suffix);
1654
+ continue;
372
1655
  }
373
- };
374
- // `/argbase` name a call's argument bases before the call (l3/argbase.ts). A lever on the
375
- // same footing as the others: the primary inline spelling stays in the list, so the differ
376
- // referees and this can never cost a match.
377
- respell('/argbase', () => materializeArgBases(sfn));
378
- // `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
379
- // (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
380
- // carries: this one fires exactly where that placement would extend a live range the
381
- // original never had.
382
- // `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
383
- // derivable from the tree — on the row this was built for the two legal merges score 18 and
384
- // 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
385
- // discards the winner. Every variant is emitted and the differ referees, exactly as
386
- // `/regcopy` does for its allocator-ambiguous tail choice.
387
- //
388
- // POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
389
- // levers do not compose. These are not a second lever composed onto the first: coalescing is
390
- // enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
391
- // just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
392
- //
393
- // EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
394
- // runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
395
- // the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
396
- respell('/scopebase', () => hoistScopedBases(sfn));
397
- const enumerate = (label: string, from: () => SFn | null | undefined): void => {
398
- let variants: { merged: string; sfn: SFn }[] = [];
1656
+ // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
1657
+ // senses structure the same recovered function without re-lifting.
1658
+ let sfn: SFn;
399
1659
  try {
400
- const base = from();
401
- variants = base ? coalesceCandidates(base) : [];
1660
+ sfn = structureChecked(fn, {
1661
+ ...svOpts,
1662
+ ...(inferredSymbols.size ? { inferredSymbols } : {}),
1663
+ ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
1664
+ preserveDivergentBranchSense: s.sense,
1665
+ negateJoinedBranchSense: s.join ? !defSense : defSense,
1666
+ anchorConstCopies: s.anchor,
1667
+ anchorLoopEntryConsts: s.entry,
1668
+ spellBitfieldMembers: s.bitfields,
1669
+ spellPtrMemberElements: s.ptrElems,
1670
+ spellDeclaredSubscripts: s.declRank,
1671
+ ...STRUCTURING_AXES.reduce((acc, ax) => ({ ...acc, ...ax.options(s[ax.flag]) }), {}),
1672
+ });
402
1673
  } catch (e) {
403
- opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
404
- return;
405
- }
406
- for (const c of variants) {
407
- respell(`${label}-${c.merged}`, () => c.sfn);
1674
+ if (lv.suffix === '' && isBaseAxisPoint(s)) {
1675
+ throw e; // the base lift's base axes keep their behavior: a failure aborts the row
1676
+ }
1677
+ // Recorded for EVERY dropped variant: a candidate with more axes on looks its siblings
1678
+ // up by stripping one axis at a time, and the stripped key can itself carry the other.
1679
+ droppedPrimary.add(s.suffix);
1680
+ // an anchored variant that fails structuring or its contracts is a dropped lever, never
1681
+ // an aborted enumeration — same rule as respell below
1682
+ opts.onLeverError?.(name + lv.suffix + s.suffix, firstLine(e));
1683
+ continue;
408
1684
  }
409
- };
410
- enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
411
- enumerate('/coalesce', () => sfn);
412
- respell('/indexed', () => reindexWalks(sfn));
413
- // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
414
- // the dead value var; tail assign-back into a fresh var the tail choice is allocator-
415
- // ambiguous, so both are ranked)
416
- const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
417
- registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
418
- for (const sp of spellings) {
419
- const source = sp.source;
420
- // Collapse a spelling that produced identical source (a function with no divergent `if`
421
- // structures the same either way): no point scoring a duplicate spelling. Deduping the
422
- // WHOLE emitted set (not just scored survivors) is equivalent an identical source
423
- // scores identically, so it can never change `best` — and it keeps the candidate set to
424
- // the genuinely distinct spellings.
425
- if (seen.has(source)) {
1685
+ // A TREE another axis point already spelled. `fanOut` reads the tree and this call's own
1686
+ // constants, nothing that varies per axis point — its signature is the argument — so a
1687
+ // repeated tree can only re-emit sources `seen` already holds: the candidate list, its
1688
+ // order and its labels are exactly the ones the whole fan produces, reached without
1689
+ // re-deriving forty passes. An axis is INERT on most functions (nothing to re-read, no
1690
+ // bitfield member, no joined if), and an inert axis is a factor of two in the cross that
1691
+ // changes nothing: on the klonoa checkout's `LoadBGTilemapData` under
1692
+ // docs/ranked-repro.md's flags, 640 of 1024 axis points (62.5%) re-derive a tree an
1693
+ // earlier one already emitted.
1694
+ //
1695
+ // Keyed on the JSON text, in a Set of STRINGS — a value comparison, so it can never
1696
+ // merge two trees the way a hash could. Its one direction of error is a MISS (a
1697
+ // differing key order re-runs a fan whose spellings then dedup as they do today), and
1698
+ // the property that rules the other direction outthat the text determines the tree —
1699
+ // is pinned by rank-tree-key.test.ts rather than assumed.
1700
+ //
1701
+ // The key therefore spans EVIDENCE fields too, `index.operandOff` among them, which
1702
+ // `exprEquals` deliberately ignores (l3/ast.ts). The two are right to disagree: two
1703
+ // trees identical but for that field denote the same cells, so a CSE may collapse them,
1704
+ // and they admit different bases under `BASEFOLD_GATES`, so a fan may not. Dropping it
1705
+ // from the key would be the direction the paragraph above rules out. It carries a
1706
+ // DISPLACEMENT rather than a presence flag, so it can split two trees that print the
1707
+ // same subscript off different addends — re-priced when it widened, over klonoa's
1708
+ // `LoadBGTilemapData` under docs/ranked-repro.md's flags: 66816 candidates either way,
1709
+ // and all 66816 `[score]` lines identical. It splits 0 keys, so the miss it can cause
1710
+ // has no inhabitant.
1711
+ const treeKey = JSON.stringify(sfn);
1712
+ if (seenTrees.has(treeKey)) {
1713
+ opts.onTreeDeduped?.();
426
1714
  continue;
427
1715
  }
428
- seen.add(source);
429
- out.push({
430
- label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
431
- source,
432
- group: svIndex,
433
- ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
434
- });
1716
+ seenTrees.add(treeKey);
1717
+ // The row's OWN tree, so this is the one call whose backend refusal is the row's cause.
1718
+ const primary = fanOut(sfn);
1719
+ const spellings = primary.spellings;
1720
+ if (primary.emit) {
1721
+ lastEmitError = primary.emit.error;
1722
+ }
1723
+ // The PRE-FAN products (PRE_FAN_PRODUCTS, the fourth mechanism the POLICY note names):
1724
+ // rewrite the TREE, then fan the whole re-spelling set over the result, so every lever
1725
+ // below derives from the rewrite instead of composing onto it. The gate is the pass's
1726
+ // own decline; the contracts are `respell`'s three, for `respell`'s reasons.
1727
+ for (const pf of PRE_FAN_PRODUCTS) {
1728
+ try {
1729
+ const made = pf.apply(sfn);
1730
+ if (made === null) {
1731
+ continue;
1732
+ }
1733
+ // The SAME tree dedup the primary above gets, and for the same reason: `fanOut` is a
1734
+ // pure function of the tree, so re-fanning one already fanned buys nothing and makes
1735
+ // the row's quoted fan cost a number that is partly duplicates. A SEPARATE set, not
1736
+ // `seenTrees`: adding a rewritten tree there would let it skip a later PRIMARY tree
1737
+ // that happens to equal it, and that primary's own pre-fan output — which nothing
1738
+ // has computed — would go with it.
1739
+ const madeKey = JSON.stringify(made);
1740
+ if (seenPreFan.has(madeKey)) {
1741
+ continue;
1742
+ }
1743
+ seenPreFan.add(madeKey);
1744
+ assertResolved(made);
1745
+ assertDerefsTyped(made);
1746
+ assertLocalsWritten(made);
1747
+ // A backend refusal on this REWRITTEN tree is not a refusal of the row's own
1748
+ // spelling, so it never becomes the row's stated cause: `FanResult.emit` is dropped
1749
+ // here and only the primary call above records one.
1750
+ //
1751
+ // It is reported instead through `onLeverError` under `pf.suffix`, which is what
1752
+ // `fanOut`'s second argument is for: a primary emit refusal does not THROW —
1753
+ // `fanOut` returns it — so the `catch` below never sees it, and under the bare
1754
+ // function name it would read as a refusal of the primary spelling while the lever's
1755
+ // whole half of the fan was deleted.
1756
+ const fanned = fanOut(made, pf.suffix).spellings;
1757
+ for (const sp of fanned) {
1758
+ spellings.push({ ...sp, suffix: `${pf.suffix}${sp.suffix}` });
1759
+ }
1760
+ } catch (e) {
1761
+ opts.onLeverError?.(`${name}${pf.suffix}`, firstLine(e));
1762
+ }
1763
+ }
1764
+ for (const sp of spellings) {
1765
+ const source = sp.source;
1766
+ // Collapse a spelling that produced identical source (a function with no divergent `if`
1767
+ // structures the same either way): no point scoring a duplicate spelling. Deduping the
1768
+ // WHOLE emitted set (not just scored survivors) is equivalent — an identical source
1769
+ // scores identically, so it can never change `best` — and it keeps the candidate set to
1770
+ // the genuinely distinct spellings.
1771
+ //
1772
+ // THE PUBLISHED LABEL IS THEREFORE NOT AN ATTRIBUTION, and every argument in this tree
1773
+ // that counts winning labels is unsound to exactly that extent. The label kept is the
1774
+ // FIRST route's; the later routes are discarded, silently and by design. Adding one
1775
+ // roster row renamed 21 agbcc rows whose emitted source sets were byte-identical —
1776
+ // a CANDIDATE-SET census (whole fan unchanged, some candidate relabelled), which is a
1777
+ // different population from a WINNING-label census: over published winners the same
1778
+ // row moved 5 labels, 2 of them renames.
1779
+ //
1780
+ // AND A LABEL CENSUS CANNOT EVEN SEPARATE A RENAME FROM A RESPELLING. Of those 5
1781
+ // winners, THREE changed the source they publish — `synthetic:unfoldpark`
1782
+ // (402 → 397 bytes, score 9 → 0), `kleod:ConfigureEntityBehavior` (3677 → 3993,
1783
+ // 233 → 230) and `synthetic:livepark` (337 → 346, both MATCH) — while
1784
+ // `synthetic:foldpark` and `kleod:DecompressDma` are byte-identical renames. The two
1785
+ // look the same from here; only the emitted SOURCE tells them apart (`bench diff`
1786
+ // publishes that field, `bench regression` does not).
1787
+ //
1788
+ // So "N rows win under this family" bounds nothing: a family can win zero labels and
1789
+ // still be the only route to a source, and a family can win five and have introduced
1790
+ // three. Price a family by ABLATING it and re-running the rows
1791
+ // (LIVEBASE_BLOCK_GATES carries the recipe); a zero census is not a death certificate,
1792
+ // and a nonzero one is not a mechanism.
1793
+ // THE SEAM FIX IS BOOKED AND NOT BUILT: keep the losing producers on the surviving
1794
+ // candidate (`label` plus an `alsoReachedBy: string[]`) and a census by mechanism
1795
+ // becomes one. It is not free — every consumer that reads `label` as the derivation
1796
+ // would have to say which it means, and the published `candidateLabel` must not
1797
+ // change — so build it when a round needs the census, not before. Until then the only
1798
+ // sound census is an ablation.
1799
+ const dup = seen.get(source);
1800
+ if (dup !== undefined) {
1801
+ // The same TEXT, reached twice. `matchOnly` is a property of the DERIVATION and the
1802
+ // published artifact is the text, so a spelling some sound route also produces is a
1803
+ // proven one however the first route reached it — clear the flag rather than keeping
1804
+ // whichever route the enumeration happened to walk first.
1805
+ if (sp.matchOnly === undefined) {
1806
+ delete dup.matchOnly;
1807
+ }
1808
+ continue;
1809
+ }
1810
+ const made: Candidate = {
1811
+ label: `${cand.label}${lv.suffix}${s.suffix}${sp.suffix}${sv.suffix}`,
1812
+ source,
1813
+ group: svIndex,
1814
+ ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
1815
+ ...(sp.deviceVolatile ? { deviceVolatile: sp.deviceVolatile } : {}),
1816
+ ...(sp.matchOnly ? { matchOnly: sp.matchOnly } : {}),
1817
+ };
1818
+ seen.set(source, made);
1819
+ out.push(made);
1820
+ }
435
1821
  }
436
1822
  }
437
1823
  }
438
1824
  }
1825
+ // Every tree the fan produced was refused by the backend. Each refusal on its own is a dropped
1826
+ // candidate; all of them together is the row, and it stays LOUD — the alternative is a caller
1827
+ // ranking an empty list and reporting no match for a function nothing ever tried to spell.
1828
+ if (out.length === 0) {
1829
+ throw new Error(`no spellable candidate for '${name}': ${firstLine(lastEmitError ?? 'no candidate produced')}`, {
1830
+ cause: lastEmitError,
1831
+ });
1832
+ }
439
1833
  return out;
440
1834
  }
441
1835
 
@@ -452,20 +1846,31 @@ export function rankBy<S extends { score: number }>(
452
1846
  ): RankedResult<S> {
453
1847
  const results: (Scored<S> & { order: number })[] = [];
454
1848
  const dropped: DroppedCandidate[] = []; // spellings that failed to build; only fatal if ALL do
1849
+ const withheld: WithheldCandidate[] = []; // spellings that built but did not earn publication
455
1850
  let lastScoreErr: unknown = null;
456
1851
  candidates.forEach((c, order) => {
457
1852
  try {
458
- results.push({ ...c, order, score: scoreFn(c.source, symbol, c) });
1853
+ const score = scoreFn(c.source, symbol, c);
1854
+ const why = withheldReason(c, score);
1855
+ if (why !== null) {
1856
+ withheld.push({ label: c.label, score: score.score, why });
1857
+ return;
1858
+ }
1859
+ results.push({ ...c, order, score });
459
1860
  } catch (e) {
460
1861
  lastScoreErr = e;
461
1862
  dropped.push({ label: c.label, error: firstLine(e) });
462
1863
  }
463
1864
  });
464
1865
  if (results.length === 0) {
465
- throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
1866
+ // Naming the withheld count matters here: "no scorable candidate" with a null cause reads as a
1867
+ // scorer failure, and a list that was entirely proof-gated is a different thing entirely.
1868
+ const why =
1869
+ lastScoreErr !== null ? firstLine(lastScoreErr) : `${withheld.length} candidate(s) withheld, none scored`;
1870
+ throw new Error(`no scorable candidate for '${symbol}': ${why}`, { cause: lastScoreErr });
466
1871
  }
467
1872
  results.sort(compareScored);
468
- return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
1873
+ return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped, withheld };
469
1874
  }
470
1875
 
471
1876
  /** THE candidate ordering — score, then preference group, then readability, then enumeration
@@ -479,6 +1884,23 @@ export function rankBy<S extends { score: number }>(
479
1884
  *
480
1885
  * GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
481
1886
  *
1887
+ * DEVICE VOLATILITY next: at equal bytes, the spelling that qualifies a DEVICE REGISTER
1888
+ * (`capabilities.deviceRegisters`) is the one to publish. A dropped `volatile` on an MMIO cell is
1889
+ * a real bug in the C that only this compiler at these flags hides — the differ cannot referee
1890
+ * it, because the compiler was not exploiting the non-volatility on this input. Gated on the
1891
+ * window rather than counting the word, because outside it the qualifier is a claim about
1892
+ * ordinary memory that the asm does not support — over the bench, counting the word
1893
+ * alone decides twelve rows and only two of them touch a device address. A declared term rather
1894
+ * than an enumeration order, which an unrelated lever's spellings can slide between.
1895
+ *
1896
+ * IT IS A PREFERENCE, AND EVERY NEW MINTER INHERITS IT. `deviceVolatileClaims` only ever ADDS a
1897
+ * claim, so any lever that qualifies a device access wins its own tie by construction: when
1898
+ * `/vol-store` joined the roster, six rows changed their published `candidateLabel` and `source`
1899
+ * with no score and no outcome moving. That is a judgement about the source rather than a
1900
+ * measurement of it — the differ never refereed those six — and it is the same judgement this
1901
+ * term was declared to make, taken on the same evidence. What it must never do is change WHICH
1902
+ * candidates exist; that stays an admission question, one lever at a time.
1903
+ *
482
1904
  * CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
483
1905
  * the C backend has to cast a shift operand back to the signedness the machine op needs, so
484
1906
  * pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
@@ -486,29 +1908,56 @@ export function rankBy<S extends { score: number }>(
486
1908
  * cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
487
1909
  * silently install the noisier spelling.
488
1910
  *
1911
+ * LINE COUNT next, the other half of the same job: two spellings can tie on score AND on casts
1912
+ * and still differ by a whole control-flow shape — a `/defsite`-anchored `v0 = 0; if (c) v0 = 1;`
1913
+ * against the braced `if/else` its sibling emits. Counted the way the report counts it
1914
+ * (apps/benchmark/src/eval/quality.ts `lines`), for the same reason `castCount` is: ranking must
1915
+ * not optimize for something the published metric measures differently.
1916
+ *
489
1917
  * ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
490
1918
  * result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
491
- * would make each preference an accident of two unrelated decisions. */
1919
+ * would make each preference an accident of two unrelated decisions.
1920
+ *
1921
+ * WHAT NO TERM HERE WEIGHS: a comparison's rendered SIGNEDNESS. `/uns-cmp`'s whole product is
1922
+ * that polarity, and it carries in a DECLARED TYPE rather than a cast — `castCount` reads 0 on
1923
+ * both sides of the tie it loses — so at equal bytes enumeration order decides, and a rival
1924
+ * axis's spelling can publish a signed compare where the asm's is unsigned. Weighing it needs
1925
+ * the candidate's own SFn and the icmp facts it was structured from, neither of which this
1926
+ * comparator carries; `deviceVolatile` is the shape such a term would take. */
492
1927
  export function compareScored<S extends { score: number }>(
493
1928
  a: Candidate & { score: S; order: number },
494
1929
  b: Candidate & { score: S; order: number },
495
1930
  ): number {
496
1931
  return (
497
- a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
1932
+ a.score.score - b.score.score ||
1933
+ a.group - b.group ||
1934
+ (b.deviceVolatile ?? 0) - (a.deviceVolatile ?? 0) ||
1935
+ castCount(a.source) - castCount(b.source) ||
1936
+ lineCount(a.source) - lineCount(b.source) ||
1937
+ a.order - b.order
498
1938
  );
499
1939
  }
500
1940
 
1941
+ /** Non-blank lines in a candidate's rendered source — the compactness tie-break above, counted
1942
+ * exactly as `quality.ts` counts `lines`. Deterministic, and total on any string. */
1943
+ function lineCount(source: string): number {
1944
+ return source.split('\n').filter((l) => l.trim().length > 0).length;
1945
+ }
1946
+
501
1947
  /** Scalar casts in a candidate's rendered source — the readability tie-break above.
502
1948
  *
503
- * A TEXT count over the emitted string, matching how the benchmark's own readability metric
504
- * measures the same thing (apps/benchmark/src/eval/quality.ts) the two must agree about what
505
- * "cast noise" means, or ranking optimizes for something the report then scores differently.
1949
+ * A WITHIN-GROUP tie-break over two spellings of ONE function, and deliberately NARROWER than
1950
+ * the published readability metric (apps/benchmark/src/eval/quality.ts `casts`): it counts the
1951
+ * decomp SCALAR typedef vocabulary only `(u8)` `(s32)` so a pointer, struct or C-keyword
1952
+ * cast is not read as noise, those being structural spellings a candidate does not choose.
1953
+ *
1954
+ * ONE exemption, the `&` form: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of
1955
+ * integer arithmetic on a link-time address, which decomp projects write themselves, and
1956
+ * counting it would penalize precisely the named spelling this ranking is supposed to prefer.
506
1957
  *
507
- * It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
508
- * those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
509
- * ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
510
- * arithmetic on a link-time address, which decomp projects write themselves. Counting it would
511
- * penalize precisely the named spelling this ranking is supposed to prefer.
1958
+ * NOT a second copy of the published metric, and it must not be read as one: that one counts a
1959
+ * wider vocabulary and exempts more, so a number here is not comparable to a number there. What
1960
+ * the two share is only the direction fewer casts reads better.
512
1961
  *
513
1962
  * Deterministic, and total on any string. */
514
1963
  function castCount(source: string): number {
@@ -517,7 +1966,9 @@ function castCount(source: string): number {
517
1966
  return all - addr;
518
1967
  }
519
1968
 
520
- /** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
1969
+ /** First line of whatever a lever, a backend or the scorer threw — the compiler's own diagnostic,
1970
+ * not a stack. TOTAL on any value, including a non-Error throw, so no caller has to re-spell the
1971
+ * `instanceof` test; a caller wanting a word for "nothing was thrown" supplies it at the call. */
521
1972
  function firstLine(e: unknown): string {
522
- return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
1973
+ return e instanceof Error ? e.message.split('\n')[0] : String(e);
523
1974
  }