@asmlift/core 0.5.0 → 0.7.0

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