@asmlift/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -167
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +194 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +54 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +337 -2
  16. package/src/ir/opcodes.ts +140 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/argbase.ts +2 -1
  24. package/src/l3/ast.ts +464 -57
  25. package/src/l3/basecse.ts +664 -76
  26. package/src/l3/coalesce.ts +429 -43
  27. package/src/l3/dce.ts +31 -9
  28. package/src/l3/gates.ts +21 -0
  29. package/src/l3/hoist.ts +293 -14
  30. package/src/l3/homesplit.ts +285 -0
  31. package/src/l3/initfirst.ts +301 -0
  32. package/src/l3/inlinebase.ts +193 -0
  33. package/src/l3/mentions.ts +113 -0
  34. package/src/l3/mulfirst.ts +42 -0
  35. package/src/l3/nearbase.ts +152 -0
  36. package/src/l3/offmember.ts +371 -0
  37. package/src/l3/parkfirst.ts +96 -0
  38. package/src/l3/pollguard.ts +154 -0
  39. package/src/l3/ptrfield.ts +227 -0
  40. package/src/l3/regspell.ts +110 -85
  41. package/src/l3/reindex.ts +715 -78
  42. package/src/l3/scopebase.ts +644 -218
  43. package/src/l3/sinkinit.ts +40 -0
  44. package/src/l3/slotorder.ts +123 -0
  45. package/src/l3/storage.ts +48 -0
  46. package/src/l3/symbol-refs.ts +41 -8
  47. package/src/l3/tailmerge.ts +15 -0
  48. package/src/l3/typing.ts +198 -9
  49. package/src/l3/unmerge.ts +263 -0
  50. package/src/l3/unreduce.ts +971 -0
  51. package/src/l3/volatileptr.ts +207 -0
  52. package/src/l3/volatileval.ts +130 -0
  53. package/src/l3/volstore.ts +229 -0
  54. package/src/l3/zerosub.ts +62 -0
  55. package/src/pattern/engine.ts +236 -13
  56. package/src/pipeline.ts +157 -56
  57. package/src/proto.ts +112 -14
  58. package/src/raise/arrays.ts +6 -1
  59. package/src/raise/divpow2.ts +2 -2
  60. package/src/raise/globalshape.ts +1038 -0
  61. package/src/raise/gvn.ts +33 -18
  62. package/src/raise/latch.ts +126 -0
  63. package/src/raise/memberarrays.ts +594 -0
  64. package/src/raise/narrow.ts +124 -0
  65. package/src/raise/narrowlocal.ts +556 -0
  66. package/src/raise/paramwidth.ts +179 -0
  67. package/src/raise/pre-recovery.ts +97 -14
  68. package/src/raise/recover.ts +56 -23
  69. package/src/raise/retsink.ts +210 -10
  70. package/src/raise/shortcircuit.ts +474 -74
  71. package/src/raise/struct-arrays.ts +19 -2
  72. package/src/raise/structs.ts +33 -3
  73. package/src/rank-axes.ts +630 -0
  74. package/src/rank-declare.ts +256 -0
  75. package/src/rank.ts +1723 -272
  76. package/src/structure/analysis.ts +1392 -141
  77. package/src/structure/bitfields.ts +332 -0
  78. package/src/structure/globalaccess.ts +274 -0
  79. package/src/structure/hazards.ts +411 -20
  80. package/src/structure/loops.ts +2 -49
  81. package/src/structure/namecoalesce.ts +435 -0
  82. package/src/structure/structure.ts +2678 -526
  83. package/src/structure/switch-recover.ts +616 -144
  84. package/src/symbols.ts +62 -1
  85. package/src/target.ts +367 -24
  86. package/src/trace.ts +111 -32
@@ -0,0 +1,630 @@
1
+ // asmlift — the candidate-enumeration TABLES, split out of rank.ts so the enumeration driver and
2
+ // the data it walks are two files. Every table here is DATA the driver derives from: adding an
3
+ // axis, a shape product or a base-CSE admission is one entry in one of these lists, and the
4
+ // driver reads it without a second hand-edited site.
5
+ //
6
+ // DECLARATION ORDER IS PUBLISHED BEHAVIOUR. `compareScored` breaks a score tie by enumeration
7
+ // order, so the order of `STRUCTURING_AXES`, `SHAPE_PRODUCTS` and every `*_ADMISSIONS` roster
8
+ // decides which of two byte-identical spellings wins and gets its `candidateLabel` into
9
+ // `results.json`. Reordering one of these arrays is a behaviour change wearing a cleanup's
10
+ // clothes — never do it as tidying.
11
+ //
12
+ // A SIBLING MODULE, never a `rank/` directory: a directory named `rank` beside `rank.ts` is a
13
+ // resolver trap, and `@asmlift/core`'s `"./*": "./src/*.ts"` export map addresses these files by
14
+ // name.
15
+ import { globalCellOf } from './ir/alias';
16
+ import type { Fn, Op, Value } from './ir/core';
17
+ import { successorsOf } from './ir/core';
18
+ import type { SFn } from './l3/ast';
19
+ import {
20
+ BASEFOLD_GATES,
21
+ type BaseKey,
22
+ LIVEBASE_BLOCK_GATES,
23
+ LIVEBASE_GATES,
24
+ ORDERBASE_GATES,
25
+ UNFOLDED_GATES,
26
+ } from './l3/basecse';
27
+ import type { Gate } from './l3/gates';
28
+ import type { HoistPlacement } from './l3/hoist';
29
+ import { initFirstGuards } from './l3/initfirst';
30
+ import { pollGuards, pollReads } from './l3/pollguard';
31
+ import { unmergeJoins } from './l3/unmerge';
32
+ // TYPE-ONLY, and deliberately: the axes table types its `options` as
33
+ // `Parameters<typeof structureChecked>[1]`, which needs the binding in a `typeof` position and
34
+ // nothing at runtime. A value import here would make this module depend on the whole pipeline.
35
+ import type { structureChecked } from './pipeline';
36
+ import {
37
+ hasDerivedReadHome,
38
+ hasHomeableSharedAddress,
39
+ hasLoopSharedPureValue,
40
+ hasMergeFeedHome,
41
+ } from './structure/analysis';
42
+ import { edgeCopyOrdersDiffer, hasParamRootedMerge } from './structure/structure';
43
+
44
+ /** The STRUCTURING AXES — the boolean candidate dimensions crossed into every enumeration
45
+ * (after signedness/branch-sense/defsite/bitfields, which have their own shapes). One entry per
46
+ * axis; chain construction, the dropped-sibling strip closure, the per-candidate
47
+ * StructureOptions, and the base-axes abort guard all derive from this table, so a new axis is
48
+ * one entry — not four hand-edited sites that can drift.
49
+ *
50
+ * `probeGate` gates the arm's ENUMERATION on the shared probe (the only thing the axis can
51
+ * change must exist at all); `variantGate` re-evaluates per symbol-variant on that variant's
52
+ * own lifted fn (a map-lifted probe spells const bases as gaddr, which would blind the
53
+ * /raw-globals siblings — the /addr-home lesson). `strip` opts the axis into the
54
+ * dropped-sibling closure: an axis-ON candidate is skipped when its OFF sibling failed the
55
+ * boundary contracts. Two axes are EXEMPT from structure()'s assertPrimaryAccepts invariant:
56
+ * `/reread-globals` only relaxes inlining barriers and `/uns-cmp` only changes spelling and
57
+ * declarations — neither adds materialization or merging, so neither can unlock a function the
58
+ * primary declines (reread also skips the strip closure). Both exemptions are stated here
59
+ * rather than left implicit in a missing `||` arm or trigger term. */
60
+ export interface StructuringAxis {
61
+ flag:
62
+ | 'reread'
63
+ | 'inplace'
64
+ | 'mergeNames'
65
+ | 'addrHome'
66
+ | 'exprHome'
67
+ | 'derivedHome'
68
+ | 'mergeHome'
69
+ | 'unsCmp'
70
+ | 'freshMerge'
71
+ | 'copyDefPos';
72
+ suffix: string;
73
+ options: (on: boolean) => Parameters<typeof structureChecked>[1];
74
+ probeGate?: (probe: Fn, defs: Map<Value, Op>) => boolean;
75
+ variantGate?: (fn: Fn) => boolean;
76
+ strip: boolean;
77
+ }
78
+ export const STRUCTURING_AXES: readonly StructuringAxis[] = [
79
+ // `/reread-globals` — the VALUE-HOME axis (structure/analysis.ts AnalyzeOptions). Whether the
80
+ // source read a global once into a variable or re-read it at each use is not derivable from
81
+ // asm: the compiler CSEs the second spelling back into one load, and the round-5 dogfood
82
+ // watched agbcc land on both sides inside a single function (its highest-cost defect, 25 of
83
+ // 27 points on one klonoa function and 35/50 both ways on another). Gated on the function
84
+ // having a load that resolves to a named global at all.
85
+ {
86
+ flag: 'reread',
87
+ suffix: '/reread-globals',
88
+ options: (on) => ({ rereadGlobals: on }),
89
+ probeGate: (probe, defs) =>
90
+ probe.blocks.some((b) =>
91
+ b.ops.some((op) => op.opcode === 'load' && globalCellOf(defs, op.operands[0], op.attrs.off as number) !== null),
92
+ ),
93
+ strip: false,
94
+ },
95
+ // `/inplace` — materialize a load that feeds a `cond_br` join arg (structure.ts
96
+ // materializeJoinFeeds), so the merge homes in the load's own variable and the identity arm
97
+ // elides to a one-sided in-place overwrite (`v = *p; if (v > 31) v = 32;`). The recompiled
98
+ // code differs (the two-sided form needs a second register — at the margin a callee-save
99
+ // push — and the emptied arm flips the branch sense). Gated on a load-fed cond_br arg.
100
+ {
101
+ flag: 'inplace',
102
+ suffix: '/inplace',
103
+ options: (on) => ({ materializeJoinFeeds: on }),
104
+ probeGate: (probe, defs) =>
105
+ probe.blocks.some((b) =>
106
+ b.ops.some(
107
+ (op) =>
108
+ op.opcode === 'cond_br' && op.successors.some((sx) => sx.args.some((a) => defs.get(a)?.opcode === 'load')),
109
+ ),
110
+ ),
111
+ strip: true,
112
+ },
113
+ // `/merge-names` — coalesce two variables a merge copy would join when the values under them
114
+ // never interfere (structure/namecoalesce.ts). Whether the source had one variable there is
115
+ // not derivable, and the copies are worth less than they look — agbcc coalesces most of them
116
+ // itself, so which side scores better is per-function. Gated on a merge fed by 2+ edges.
117
+ {
118
+ flag: 'mergeNames',
119
+ suffix: '/merge-names',
120
+ options: (on) => ({ coalesceMergeNames: on }),
121
+ probeGate: (probe) =>
122
+ probe.blocks
123
+ .slice(1)
124
+ .some(
125
+ (b) => b.params.length > 0 && new Set(probe.blocks.filter((pr) => successorsOf(pr).includes(b))).size > 1,
126
+ ),
127
+ strip: true,
128
+ },
129
+ // `/addr-home` — the address-home axis (structure/analysis.ts AnalyzeOptions
130
+ // homeSharedAddresses): a pure computed address dereferenced at 2+ sites, and the multi-render
131
+ // loads through it, materialize into locals — the source's pointer-local + scalar-temp
132
+ // spelling, where the default re-derives per use (a pool literal per folded offset). Gated PER
133
+ // SYMBOL VARIANT (see the table doc) on that variant's own lifted fn having a homeable base.
134
+ {
135
+ flag: 'addrHome',
136
+ suffix: '/addr-home',
137
+ options: (on) => ({ homeSharedAddresses: on }),
138
+ variantGate: hasHomeableSharedAddress,
139
+ strip: true,
140
+ },
141
+ // `/expr-home` — the loop-expression-home axis (structure/analysis.ts AnalyzeOptions
142
+ // homeLoopExprs): a pure value defined outside a loop with 2+ distinct consumers, at least one
143
+ // of them inside it, materializes into a local carrying the value's recovered type — the register
144
+ // the compiler holds across the iterations (`u32 size = 16 << t;` driving a loop bound, a product
145
+ // and a shift), where the default re-derives per use. Gated per symbol variant like `/addr-home`
146
+ // (the cone refusal reads the variant's own lift).
147
+ {
148
+ flag: 'exprHome',
149
+ suffix: '/expr-home',
150
+ options: (on) => ({ homeLoopExprs: on }),
151
+ variantGate: hasLoopSharedPureValue,
152
+ strip: true,
153
+ },
154
+ // `/derived-home` — the derived-read-home axis (structure/analysis.ts AnalyzeOptions
155
+ // homeDerivedReads): a pure value with 2+ consumers standing on a memory read materializes, and
156
+ // the read then renders once inside it — the register the asm carried the DERIVED value in
157
+ // (`eor r1,r1,r0` keeps `0x3FF ^ REG_KEYINPUT`), where the default homes the read and re-derives
158
+ // the computation at every use. Both spellings compile (agbcc CSEs the re-derivation back), so
159
+ // the differ referees. Gated per symbol variant like its `/addr-home` and `/expr-home` siblings,
160
+ // and for the same reason the /addr-home lesson names: the scope refuses a cone holding a
161
+ // standalone address, and a pool constant the map lifts to a `gaddr` is a bare `const` in the
162
+ // `/raw-globals` sibling — so the two variants genuinely answer differently.
163
+ {
164
+ flag: 'derivedHome',
165
+ suffix: '/derived-home',
166
+ options: (on) => ({ homeDerivedReads: on }),
167
+ variantGate: hasDerivedReadHome,
168
+ strip: true,
169
+ },
170
+ // `/merge-home` — the merge-feed-home axis (structure/analysis.ts AnalyzeOptions
171
+ // homeMergeFeeds): a pure value one join's incoming edges render into the SAME parameter slot
172
+ // from 2+ places materializes in the block that dominates them — the value the source computed
173
+ // once above the branch (`s32 m = (b & 1) ? 0x400 : 0;`), where the default has no name to
174
+ // reference on an edge and re-derives the whole expression per arm. Gated per symbol variant on
175
+ // the scope itself rather than on an approximation of it.
176
+ //
177
+ // An ADMISSION, not a default: forced on, the spelling is REPLACED across the fan rather than
178
+ // added to it, which costs `kleod:MultiplyQ4`, `kleod:MultiplyQ8` and
179
+ // `pokeemerald:MathUtil_Mul16` their matches. On the roster that is unreachable — `compareScored`
180
+ // orders by score and the un-homed sibling rides beside it.
181
+ //
182
+ // Its fan is essentially one row's: over the 16 corpus rows the gate admits, 2790 → 5841
183
+ // candidates map-less and 2538 → 5363 with a map, of which `kleod:UpdateCameraScroll` (outcome
184
+ // `noncompile`, so they buy nothing) is +2944 and +2752, three rows add none at all where
185
+ // `/defsite` already spells the same tree, and the rest pay 107 and 73 between them.
186
+ {
187
+ flag: 'mergeHome',
188
+ suffix: '/merge-home',
189
+ options: (on) => ({ homeMergeFeeds: on }),
190
+ variantGate: hasMergeFeedHome,
191
+ strip: true,
192
+ },
193
+ // `/uns-cmp` — spell unsigned compares unsigned (structure.ts unsignedCompareSpelling): an
194
+ // icmp_u* operand takes a (u32) cast where the rendered operands do not guarantee the
195
+ // unsignedness, and a mixed-claimant declaration reconciles to u32 when nothing under the
196
+ // name needs signed. Which side the source spelled is genuinely ambiguous: a signed spelling
197
+ // that byte-matched was PROVED non-negative by the compiler (only then does it emit the
198
+ // unsigned branch from a signed compare), and emission's provable set is smaller than the
199
+ // compiler's. Gated on the function having an unsigned compare at all.
200
+ {
201
+ flag: 'unsCmp',
202
+ suffix: '/uns-cmp',
203
+ options: (on) => ({ unsignedCompareSpelling: on }),
204
+ probeGate: (probe) => probe.blocks.some((b) => b.ops.some((op) => op.opcode.startsWith('icmp_u'))),
205
+ strip: true,
206
+ },
207
+ // `/fresh-merge` — the parameter-merge-home axis (structure.ts `freshParamMerge`, whose
208
+ // `FRESH_MERGE_GATES` carry the argument): a merge whose carrier is a parameter takes its own
209
+ // local (`if (a1 < a0) { v0 = a0; } else { v0 = a1; }`) where the default assigns back into the
210
+ // parameter (`if (a1 < a0) a1 = a0;`). Both are ordinary C over the same values, so
211
+ // the differ decides. At TWO arguments they compile to the SAME bytes on agbcc and on mwcc
212
+ // (measured, both directions), which is why `maxi`/`mini` hold under the axis.
213
+ //
214
+ // IT ALSO UNLOCKS `/defsite`. `anchorConstCopies` refuses a merge whose name claims another SSA
215
+ // value, so a merge that adopted its parameter is never anchored, while a minted home is sole by
216
+ // construction and clears that one refusal — a constant arm then writes above the branch, where
217
+ // the remaining placement rules allow it. That pair spells m2c's own
218
+ // `v0 = 0xFF; if (a0 <= 0xFF) v0 = a0;`, which is how `synthetic:clampu8:mwcc_242_81` matches
219
+ // under `signed/defsite/fresh-merge` — `signed/defsite` is inert on the base tree and does not
220
+ // appear in that row's fan at all. Priced at the guard it widens: sole-claimant admissions go
221
+ // 196 → 245, 34 rows gaining 49 merges between them. A DELTA WITHOUT ITS DENOMINATOR, on
222
+ // purpose — the corpus grows, so the row count that census ran over is not today's and quoting
223
+ // it would read as verified. Re-run the sweep before budgeting against the totals.
224
+ //
225
+ // Gated on `hasParamRootedMerge`, which lives beside the rule it over-approximates. Structural,
226
+ // so it cannot answer differently per symbol variant.
227
+ {
228
+ flag: 'freshMerge',
229
+ suffix: '/fresh-merge',
230
+ options: (on) => ({ freshParamMerge: on }),
231
+ probeGate: (probe) => hasParamRootedMerge(probe),
232
+ strip: true,
233
+ },
234
+ // `/copy-defpos` — the EDGE-COPY ORDER axis (structure.ts `preferDefPosCopyOrder`). The frontend
235
+ // measures the order each predecessor wrote its successors' keys (ir/core.ts `WriteOrder`) and
236
+ // the default lays the edge's copies out in it. That the compiler laid its copies out in the
237
+ // order it wrote them is only licensed for a CYCLIC copy set, where the spill has to be the
238
+ // register whose old value was displaced first; for an acyclic set it is an assumption, and the
239
+ // benchmark answers it BOTH WAYS INSIDE ONE COMPILER: `synthetic:gcd:mwcc_242_81` matches only
240
+ // with the record, while `memcpy1:mwcc_242_81` (19 → 23) and `memset1:mwcc_242_81` (19 → 21)
241
+ // score worse with it, as does `armfall:agbcc` (8 → 11). A per-compiler boolean cannot decide a
242
+ // question with rows on both sides of it inside one compiler, and a lever that emits one tree
243
+ // referees nothing — so the def-position spelling is enumerated beside the record's and the
244
+ // differ picks, exactly as `/fresh-merge` above does for the merge home.
245
+ //
246
+ // THE AXIS SPANS THE UNLICENSED HALF ONLY: its ON arm keeps the record on cyclic sets, so
247
+ // neither arm spells a cycle against the instruction that names the compiler's temp. Over the
248
+ // whole benchmark corpus that scoping moves nothing — `memcpy1`, `memset1` and `armfall` keep their
249
+ // `/copy-defpos` winners, so the acyclic half is what they needed — and it makes one spelling
250
+ // reachable that neither whole-function arm has: the record on a cycle and the proxy on an
251
+ // acyclic edge of the same function, which is what `synthetic:gcd:agbcc`'s two edges want.
252
+ //
253
+ // Gated on the two orders actually differing somewhere in this function, so on a row where the
254
+ // record changes nothing the pair is one tree and the fan does not grow. PER SYMBOL VARIANT, on
255
+ // that variant's own fully-raised fn, because that is `structure()`'s own input and only there
256
+ // is withholding provably inert: same fn, same comparators, so a false answer means the ON arm
257
+ // would structure the tree the OFF arm already spelled. Asked any earlier the claim does not
258
+ // follow, and both ways it fails are real —
259
+ // - THE SYMBOL VARIANT. klonoa's `UpdateHUDCollectibleCount` answers false with the kleod map
260
+ // and true on the `/raw-globals` sibling's own lift (fixture: test/corpus/agbcc-hudcount.s).
261
+ // - THE STAGE. A probe stopping after `recoverTypes` is asked before `foldEmptyLatches`
262
+ // (raise/latch.ts) repoints edges and rewrites the record the gate reads: klonoa's
263
+ // `EntityGravityAndFloorCheck` answers false there and true after that fold.
264
+ // Neither costs candidates today — over 192 klonoa functions enumerated with the map the fan is
265
+ // 27,847 either way, 1,970 of them `/copy-defpos`. `strip` like its neighbours: a reordering
266
+ // cannot rescue a spelling whose OFF sibling failed the boundary contracts.
267
+ {
268
+ flag: 'copyDefPos',
269
+ suffix: '/copy-defpos',
270
+ options: (on) => ({ preferDefPosCopyOrder: on }),
271
+ variantGate: edgeCopyOrdersDiffer,
272
+ strip: true,
273
+ },
274
+ ];
275
+
276
+ /** The statement-shape products (rank's second sanctioned product mechanism): each entry is a
277
+ * statement-order/shape re-spelling orthogonal to every representation lever, derived onto every
278
+ * spelling as sanctioned in the POLICY note at the respell site. Each shape fires alone, plus
279
+ * all of them together in table order — not the full subset lattice; the pairs question is
280
+ * settled by applyShapes' skip-on-decline below, and a row demanding a true EXCLUSION pair —
281
+ * all three fire, the match needs exactly two — is what would earn the lattice. */
282
+ export const SHAPE_PRODUCTS: { suffix: string; apply: (sfn: SFn) => SFn | null }[] = [
283
+ { suffix: '/initfirst', apply: initFirstGuards },
284
+ { suffix: '/pollguard', apply: pollGuards },
285
+ { suffix: '/pollread', apply: pollReads },
286
+ ];
287
+ /** The PRE-FAN products (rank's FOURTH sanctioned product mechanism): a tree rewrite applied
288
+ * BEFORE the re-spelling fan, so the whole fan derives from its output instead of composing onto
289
+ * it. Same record type as SHAPE_PRODUCTS above, and deliberately so — the only difference is
290
+ * WHERE it is applied, and that is the whole admission bar.
291
+ *
292
+ * ADMITTED on one ground: the spelling a row demands needs a downstream lever to run on this
293
+ * rewrite's OUTPUT, and the measured pair shows neither order alone reaches it. For `/unmerge`
294
+ * (l3/unmerge.ts, the dual of the unconditional `tailmerge`) that measurement is
295
+ * `synthetic:dmascope`: the un-merged store has to land inside the arm's own region base
296
+ * (`p0[2] = …`), which only a base lever running AFTER the un-merge can spell — hand-compiled,
297
+ * that source is byte-exact where the merged spelling the structurer produces is 9, and applying
298
+ * the un-merge to the WINNER's tree instead measures 14. Every other lever derives from the base
299
+ * tree, so the order can only be had this way.
300
+ *
301
+ * A pre-fan product only ADDS candidates, so it cannot cost a match; its price is a second fan
302
+ * on every tree where the rewrite fires, which is why the table is not a place to put a lever
303
+ * that would compose perfectly well as a `respell`. */
304
+ export const PRE_FAN_PRODUCTS: typeof SHAPE_PRODUCTS = [{ suffix: '/unmerge', apply: unmergeJoins }];
305
+
306
+ export const SHAPE_SUBSETS: (typeof SHAPE_PRODUCTS)[number][][] = [
307
+ ...SHAPE_PRODUCTS.map((x) => [x]),
308
+ ...(SHAPE_PRODUCTS.length > 1 ? [SHAPE_PRODUCTS] : []),
309
+ ];
310
+
311
+ /** The subset applied in table order, SKIP-ON-DECLINE: a member that declines contributes
312
+ * nothing rather than killing the combination — the all-shapes candidate is "everything that
313
+ * fires", so a pair is reachable whenever the third declines. The label is built from the
314
+ * members that actually FIRED, so a suffix never names a lever that declined; a fired-set that
315
+ * duplicates a smaller subset emits identical source and the dedup collapses it. Null when
316
+ * nothing fired. */
317
+ export const applyShapes = (
318
+ subset: readonly (typeof SHAPE_PRODUCTS)[number][],
319
+ from: SFn,
320
+ ): { out: SFn; suffix: string } | null => {
321
+ let cur = from;
322
+ const fired: string[] = [];
323
+ for (const sp of subset) {
324
+ const r = sp.apply(cur);
325
+ if (r) {
326
+ cur = r;
327
+ fired.push(sp.suffix);
328
+ }
329
+ }
330
+ return fired.length > 0 ? { out: cur, suffix: fired.join('') } : null;
331
+ };
332
+
333
+ /** The locals a lever added — a NAME diff rather than a positional slice, so a pass that ever
334
+ * reorders locals cannot silently empty the set. It is what scopes `/volatile` to the pointers
335
+ * the lever itself created (volatilePtrLocals' `only`), leaving the tree's own locals alone. */
336
+ export const createdLocals = (from: SFn, to: SFn): Set<string> => {
337
+ const before = new Set(from.locals.map((l) => l.name));
338
+ return new Set(to.locals.filter((l) => !before.has(l.name)).map((l) => l.name));
339
+ };
340
+
341
+ /** The base-CSE ADMISSIONS `/livebase` offers the differ, widest first. WHICH of several numeric
342
+ * bases the source named is per-base knowledge the asm does not carry — a DMA register file wants
343
+ * one register held across the whole body while the IWRAM halfword beside it re-materializes — so
344
+ * each admission rides as its own candidate and the differ referees between them. A new
345
+ * admission is one entry here, one gate table, and that table's line in the gate-contract
346
+ * roster — not nine hand-edited sites that can drift; whether it also fans over the `/livebase`
347
+ * PRODUCTS below is the entry's own `pairings`. A MIRROR admission (bind the scalar cells, leave
348
+ * the register file inline) is that, with the complementary predicate; it is never another entry
349
+ * in LIVEBASE_BLOCK_GATES, which can only reject more.
350
+ *
351
+ * WHAT BOUNDS IT. A row declines unless it binds a non-empty set of bases no earlier row already
352
+ * bound, and each product declines wherever its own lever does, so the list widens only where an
353
+ * inhabitant exists — over the corpus the second admission reaches 8 rows, its `/nearbase`
354
+ * pairing 3, and its `/indexed`, `/coalesce` and volatile-subset products none at all. A function
355
+ * inhabiting them all pays far more, and the fan is not always a win there: the mixpoll dataset
356
+ * entry prices one where the `/coalesce` pairing costs the most candidates of any and scores two
357
+ * points worse than going unpaired. THAT row fans anyway because on the `/livebase` rows a
358
+ * pairing belongs to the LEVER rather than to one of its admissions — but it is a per-row
359
+ * decision, not a property of the roster: three of the five rows below are unpaired. `pairings`
360
+ * is the field, and its own doc says how a row earns a `true`.
361
+ *
362
+ * `/basefold` is the third and fourth admission and `/unfolded` the fifth; those three are the
363
+ * conditional set — `enumerateCandidates` appends them where the target declares
364
+ * `compilerBehaviors.foldsConstAddrOffset`. They need no second "did the primary already carry
365
+ * this" test: `structureChecked` runs the DEFAULT hoist to its fixpoint before any tree reaches
366
+ * here, so a key still admissible is by construction one `BASECSE_GATES` rejected, and binding
367
+ * nothing is the whole of the decline.
368
+ * WHAT THE EXEMPTION REACHES, over the agbcc rows the artifact carried when the census ran and in
369
+ * BOTH symbol-map configurations — 451 observations, of which 39 do not lift on this one-tree
370
+ * census. The 451 is the census's OWN denominator, quoted so the "0 of 451" below has one; the
371
+ * corpus row count it came from is deliberately not, because that number has moved since.
372
+ * HOW TO REPRODUCE IT: the prototypes live inside `row.scripts.asmlift`'s `PROTO_INPUT`
373
+ * heredoc, and there is no `row.proto` field — a census reaching for one lifts all 451 with
374
+ * `prototypes: {}` while the harness scores every one of them with `--proto proto.json`, and
375
+ * says nothing about it. Numbers below are from the heredoc.
376
+ * 20 observations bind a key the default table refuses, spread over 14 rows in 4 projects (6
377
+ * map-ful, 14 map-less), 25 keys in all. FOUR are numeric — two on `kleod:RollRandomLevelVariant`
378
+ * and one each on `synthetic:basecell` and `synthetic:foldsink`, all map-less, because with a map
379
+ * the pool constant lifts to a `gaddr` and the numeric clause stands down while the symbol clause
380
+ * takes over. The other 21 are SYMBOL keys over 11 rows in three projects (6 of those
381
+ * observations map-ful, 11 map-less), and all 21 are what the symbol half added: on the
382
+ * value-proxy predicate this replaced, the same census binds the 4 numeric keys and nothing else,
383
+ * losing none of them. `admittedBases(sfn, BASECSE_GATES)` — the COMMITTED table — differs on 0
384
+ * of 451, which is the check that says the widening stayed on the roster.
385
+ * A target that declares no fold is offered none of the three — not to protect a score (no roster
386
+ * row can cost one; see LIVEBASE_BLOCK_GATES) but because `unfoldedOffset` would be read as
387
+ * evidence on an instruction that carries the addend by construction, where there is none.
388
+ * On klonoa's `LoadBGTilemapData` — a checkout function rather than a row, so re-run it with the
389
+ * ranked command in docs/ranked-repro.md — the `/basefold` admission declines on every
390
+ * structuring, leaving that fan the size it was, with ZERO `basefold`-labelled candidates in the
391
+ * control arm. NO FAN TOTAL IS QUOTED HERE ON PURPOSE: that function's fan was 112896 at this
392
+ * commit and two five-figure numbers ago at others, and a DELTA outlives the total it was
393
+ * measured beside — which is what makes a stale paragraph read as verified. Re-run the total
394
+ * before budgeting against it. All floors, though: the ranked path structures each function many ways
395
+ * where this census builds one tree per observation.
396
+ *
397
+ * WHAT THE PAIR COSTS, through the HARNESS's own enumeration and re-runnable from the recipe in
398
+ * the BASEFOLD_ADMISSIONS note below: enumerate every agbcc row with the pair on and off,
399
+ * `ASMLIFT_CANDCACHE=0`, candidates only. The pair adds 3921
400
+ * distinct candidate sources over 14 observations — 3911 over 12 real rows and 10 over 2
401
+ * synthetic ones (`foldsink` 4 → 12, `basecell` 2 → 4) — and every per-row delta equals that
402
+ * row's count of `basefold`-labelled candidates exactly, which is both what says the ablation
403
+ * reached and what says these are sources nothing earlier in the roster emits.
404
+ * It is CONCENTRATED, not spread: in the map configuration the harness uses on real rows,
405
+ * `kleod:ProcessInputAndUpdateEntities` takes +2880 (14976 → 17856),
406
+ * `kleod:UpdateCameraScroll` +512 (5968 → 6480), `kleod:CountCollectedGems` +192 (384 → 576),
407
+ * `kleod:UpdateWorldMapNodeAnim` +176 (488 → 664) and nothing else more than 32. Re-run a
408
+ * concentration figure before budgeting against it: a DELTA can reproduce while the fan it was
409
+ * quoted against has moved, and that is what makes a stale paragraph read as verified.
410
+ * `kleod:UpdateCameraScroll` is an `outcome: noncompile` row — `decompileRanked` throws only when
411
+ * EVERY candidate failed to build — so its whole fan is compiled and discarded, and this made
412
+ * that discard 10% bigger. Timed on two full bench runs on a shared box, and not re-timed since
413
+ * the deltas above, so read them as a floor rather than a price: that row 377.6s → 483.0s, the
414
+ * second 238.4s → 313.6s, real tier 416.1s → 529.4s. Priced — and the three rows the pair was
415
+ * bought with DO NOT BUY IT TODAY: ablated, `sa3:sub_803213C` is MATCH with the pair removed,
416
+ * `kleod:ProcessInputAndUpdateEntities` 211 either way and `kleod:CountCollectedGems` 290 either
417
+ * way. A SCORE QUOTED HERE IS THE ARTIFACT'S: it moves whenever anything at all moves the row,
418
+ * a basefold change or not, so re-read it off the artifact rather than off this line. Read the
419
+ * ablation in the note on BASEFOLD_ADMISSIONS, which carries the fan counts that prove it
420
+ * reached. */
421
+ export interface BaseAdmission {
422
+ suffix: string;
423
+ gates: readonly Gate<BaseKey>[];
424
+ /** WHERE the locals this row binds are initialized (l3/hoist.ts). Eligibility and placement are
425
+ * two questions and this roster answers both, so a row can offer the same bases in the other
426
+ * position without a second gate table — and a row that wants both offers both, as the
427
+ * `/basefold` pair below does. */
428
+ placement: HoistPlacement;
429
+ /** Whether the row joins the `/livebase ×` PAIRINGS below. Each of those products was added for
430
+ * a row that demanded the joint spelling (see POLICY), and every demanding row so far is a
431
+ * `/livebase` row — so a new admission joins them when a row demands it, not by roster
432
+ * membership.
433
+ *
434
+ * ONE BOOLEAN PER ROSTER ROW, ANSWERING A QUESTION THAT IS REALLY PER FUNCTION, so a `false`
435
+ * here is a corpus claim and has to be measured like one — on the whole corpus, not on the
436
+ * synthetic row that earned the entry. The measurement is candidates-only and cheap: enumerate
437
+ * every agbcc row twice from `row.scripts.asmlift`'s heredocs and compare the distinct-source
438
+ * sets. For `/unfolded` (see its note) that is +912 sources over 8 rows (+1.92% corpus fan) and
439
+ * the only nonmatch among the 8 scores the same either way, which is what the `false` rests on.
440
+ * Flipping one of these is one character; the gate on doing it is that census plus a score on
441
+ * every row it moves. */
442
+ pairings: boolean;
443
+ }
444
+
445
+ export const LIVEBASE_ADMISSIONS: readonly BaseAdmission[] = [
446
+ { suffix: '/livebase', gates: LIVEBASE_GATES, placement: 'head', pairings: true },
447
+ { suffix: '/livebase-block', gates: LIVEBASE_BLOCK_GATES, placement: 'head', pairings: true },
448
+ ];
449
+
450
+ /** Narrower than either `/livebase` row, so both go last: they keep both placement heuristics and
451
+ * exempt only `single-use`, and only for a base whose offset survived the compiler's fold.
452
+ *
453
+ * They are ONE eligibility rule at the two placements, because for a base reached ONCE the
454
+ * question the differ has to settle is where the pool load sits, not whether the local exists:
455
+ * the head keeps the address live over everything above the access, the first-use position is
456
+ * where a single access loaded it. Which one the source wrote is per-function knowledge the asm
457
+ * does not carry, so both ride and the differ referees.
458
+ *
459
+ * WHAT EACH ROW IS WORTH, ablated through the harness rather than read off the winning labels,
460
+ * because a label a row wins can be a TIE another row also reaches. THE HEAD ADMISSION IS
461
+ * BRACKETED AND THE SUNK ONE IS NOT. `synthetic:foldhead` is MATCH at 0 under
462
+ * `unsigned/basefold` and becomes NONMATCH 11 under `unsigned` the moment the HEAD entry is
463
+ * removed — and removing BOTH entries gives the same 11, so the sunk entry is what nothing here
464
+ * brackets. `synthetic:foldsink` and `synthetic:basecell` are unbracketed for a reason worth
465
+ * keeping: they are MATCH at 0 in every configuration because `/offmember` ALSO reaches 0 on
466
+ * them and wins `compareScored`'s line-count tie-break. A TIE IS NOT A SUBSUMPTION — that is
467
+ * why a census over winning labels reads zero here, and reading that zero as "loses" would
468
+ * delete a pair that no other spelling reaches.
469
+ * (Their fans still move: `foldsink` 12 → 8 → 8 → 4 over control/sunk/head/both, `basecell`
470
+ * 4 → 4 → 4 → 2, the four-number sequence saying that on `basecell` the two entries emit the
471
+ * SAME two sources and `seen` collapses them, so only removing both takes the fan down.)
472
+ * `sa3:sub_803213C` MATCH, and — with the pair removed — `kleod:ProcessInputAndUpdateEntities`
473
+ * 211, `kleod:CountCollectedGems` 290 and `kleod:RollRandomLevelVariant` 18, each of them the
474
+ * number the artifact already carries, and each of them ENTAILED rather than separately scored:
475
+ * the ablated candidate set is a strict SUBSET of the control one on every row here (enumerated
476
+ * both ways, 0 sources ADDED and 0 RELABELLED — `ProcessInputAndUpdateEntities` 58752 → 48384
477
+ * with 10368 carrying the token, `CountCollectedGems` 576 → 384 with 192, `RollRandomLevelVariant`
478
+ * 29 → 11 with 18, `sub_803213C` 36 → 20 with 16), and no winner's label carries a `basefold`
479
+ * token, so the minimum cannot move. A BRACKET IS A CLAIM ABOUT THE WHOLE TREE, so it expires
480
+ * whenever anything else learns to reach the same spelling more cheaply: re-run one before
481
+ * re-quoting it, including the number that survived the last re-run.
482
+ * The SUNK entry is kept ONLY because it is a real spelling: 3921 distinct candidate sources over
483
+ * 14 observations that nothing else emits (see WHAT THE PAIR COSTS for the per-row split), and
484
+ * a C source that initializes its base pointers where it declares them is the ordinary case.
485
+ * That is a weaker justification than a protected row and should be read as one — a round pricing
486
+ * the agbcc fan may delete it, and the gate on doing so is `bench diff`, not this note. The HEAD
487
+ * entry is NOT in that category: deleting it costs `synthetic:foldhead` its match, which
488
+ * `bench regression` fails on.
489
+ * HOW THE ABLATION IS DONE, since there is no shipped knob: filter this roster at its one use
490
+ * site (the `admissions` const in `enumerateCandidates`) behind a temporary env read, run the
491
+ * rows with `ASMLIFT_CANDCACHE=0`, and revert. Prove the filter REACHED before believing a null
492
+ * result — `synthetic:livepark` MATCH → diff:3 with `/livebase` AND `/unfolded` both removed is
493
+ * the positive control, and a fan count per configuration is the second. Removing `/livebase`
494
+ * alone leaves that row MATCH today, which is a control silently going vacuous rather than a
495
+ * lever going dead: `/unfolded` binds the same base there. Any positive control naming ONE roster
496
+ * row expires the next time a row is added — re-run it, and if it no longer moves, widen the
497
+ * ablation until it does before concluding anything from a null. */
498
+ export const BASEFOLD_ADMISSIONS: readonly BaseAdmission[] = [
499
+ { suffix: '/basefold', gates: BASEFOLD_GATES, placement: 'head', pairings: false },
500
+ { suffix: '/basefold/sinkinit', gates: BASEFOLD_GATES, placement: 'first-use', pairings: false },
501
+ ];
502
+
503
+ /** The fifth admission: its table requires the fold evidence (l3/basecse.ts, UNFOLDED_GATES), so
504
+ * it binds the reused bases an operand offset says a pointer local strode and leaves the ones the
505
+ * pool already carried folded. `/livebase` and `/livebase-block` are a chain — all the reused
506
+ * bases, or those minus the scalar cells — and a source that parked one numeric base and spelled
507
+ * another inline is at neither end of it. This row is not a third link in that chain but beside
508
+ * it: `singleCell` and `unfoldedOffset` are independent fields, so each of the two tables binds
509
+ * keys the other refuses (censused, with its scope, in UNFOLDED_GATES' own note). Read the roster
510
+ * as hand-picked subsets, never as a narrowness ranking.
511
+ *
512
+ * LAST on the roster, so `seen` and `sameBases` between them keep it from restating an earlier
513
+ * ROSTER row — but only `seen` does any work here. `sameBases` declines a row that binds what an
514
+ * EARLIER row binds AT THE SAME PLACEMENT, and the only earlier `first-use` row is
515
+ * `/basefold/sinkinit`, whose table keeps the two gates this one ablates; instrumented over every
516
+ * agbcc row it fires on 0 of 5541 roster observations for this entry (33 distinct functions),
517
+ * against 3939 for `/livebase-block`. So the shadow is available and vacuous, and what keeps this
518
+ * row from restating anything is `seen` — WHICH MAKES IT A RENAMER, and it renames: the roster
519
+ * loop below runs before the `/livebase ×` product loops, so a source one of those products would
520
+ * emit later is claimed by this row's label instead. `synthetic:foldpark` is that case measured —
521
+ * fan 34 with this entry and 34 without, the same source winning at 0 under
522
+ * `signed/unfolded/volatile` here and `signed/livebase-block/volatile/sinkinit` there.
523
+ * Corpus-wide (map-less, candidates only, over the artifact's agbcc rows as they stood) 21 of the 333 rows
524
+ * whose distinct-source set is byte-identical either way carry `/unfolded`-labelled candidates:
525
+ * 21 pure renames against 7 rows that really gain sources, and 0 that lose one. What that costs
526
+ * any census taken over labels is at the `seen` dedup site below.
527
+ *
528
+ * ONE placement, unlike the `/basefold` pair, and by measurement rather than by symmetry. All
529
+ * four configurations scored on `synthetic:unfoldpark`, cache off — the fan, then that fan's best
530
+ * score:
531
+ * first-use, unpaired 44 0 MATCH — shipped
532
+ * first-use, paired 44 0 no product emits a source the unpaired row does not
533
+ * head, unpaired 44 9 the score the row already had without any of this
534
+ * head, paired 48 0 reached only through the `/sinkinit` product
535
+ * The head is where `/livebase` already offers a spelling for every base this table can bind —
536
+ * these are bases reached 2+ times — so what the row adds is the SUNK init, which is where a
537
+ * source that declares its base pointer beside the loop it feeds puts the pool load. On both
538
+ * neighbouring rows the head placement is shadowed outright (`/unfolded` binds set-for-set what
539
+ * `/livebase` binds on `synthetic:livepark` and what `/livebase-block` binds on
540
+ * `synthetic:foldpark`). A second row at the head is one line and no new table; add it when a row
541
+ * demands it, which none does today.
542
+ *
543
+ * `pairings: false` for the reason the field's own doc gives — a product is added for a row that
544
+ * demands the joint spelling, and the row that earned this entry does not: paired and unpaired
545
+ * are the same 44 candidates above. ONE 15-LINE FUNCTION CANNOT SETTLE A CORPUS QUESTION, so the
546
+ * same knob was censused over every agbcc row the artifact carries, candidates only: `true` adds
547
+ * 912 distinct sources over 8 rows, +1.92% of the agbcc corpus fan (quoted as the DELTA,
548
+ * because the total moves with the corpus and with the roster) — `kleod:UpdateCameraScroll`
549
+ * +608, `synthetic:sizebound` +128, `synthetic:dmascope` +64, `kleod:SetupBG3WindowOverlay` and
550
+ * `synthetic:maskhome` +32 each, and +16 each on `dmafield`, `dmaflat` and `dmapoll`. Five of the
551
+ * eight are MATCH and two are `noncompile`, where extra candidates cannot help; the one that
552
+ * could, `synthetic:sizebound`, scores diff:8 with the products on and diff:8 with them off. So
553
+ * the `false` buys 1.92% of the agbcc fan for a measured zero, on the whole corpus rather than on
554
+ * the row that earned the entry. Flip it when a row scores better with it, and re-run that
555
+ * census when one does. */
556
+ export const UNFOLDED_ADMISSIONS: readonly BaseAdmission[] = [
557
+ { suffix: '/unfolded', gates: UNFOLDED_GATES, placement: 'first-use', pairings: false },
558
+ ];
559
+
560
+ /** The sixth admission, and the only one whose evidence is the INSTRUCTION ORDER rather than the
561
+ * shape of the accesses (l3/basecse.ts, ORDERBASE_GATES). It binds a base the assembly says was
562
+ * materialized before the index was scaled — including the `(struct S *)&gSym` of an
563
+ * array-of-struct element, which no other table on this roster can even see.
564
+ *
565
+ * LAST, so `sameBases` can shadow it and it can shadow nothing: on a function whose licensed base
566
+ * is a plain leaf reached twice, `/livebase` already binds exactly that set at this placement and
567
+ * this row declines rather than restating it under a second label.
568
+ *
569
+ * TWO PLACEMENTS, and the FLAT second one is a measured zero. `synthetic:bgarr` emits the identical
570
+ * source at `head` and `first-use` (the hoist has nothing to sit above) and that one row
571
+ * generalizes to nothing: over the artifact's agbcc rows the two emit DIFFERENT source on 3 of the
572
+ * 8 rows this admission binds map-less and 4 of the 10 map-ful — `kleod:SetupBG3WindowOverlay`,
573
+ * `kleod:UpdateCameraScroll`, `pokeemerald:TrySetCantSelectMoveBattleScript`, and map-ful
574
+ * `kleod:StreamCmd_SetBGScroll`. Run through the harness on all four, an entry at
575
+ * `placement: 'first-use'` scores nothing: 146 → 146, noncompile → noncompile, MATCH → MATCH,
576
+ * noncompile → noncompile, against +1129 candidates over those rows' 15167 (+7.4%) and
577
+ * `kleod:UpdateCameraScroll` 224 s → 278 s. That row stays withheld.
578
+ *
579
+ * `scope` is a DIFFERENT question and a row demanded it. `first-use` reaches only the top-level
580
+ * statement list, so on a function whose licensed base is used solely inside a guarded loop it
581
+ * spells the same bytes `head` does — the pool word above the branch — while the reference loads
582
+ * it after. Compiled through the benchmark's own agbcc on `synthetic:ereadctl`'s target, with
583
+ * everything else held identical: the init above the `if` differs, the same init INSIDE the arm is
584
+ * instruction-identical. So the two flat placements are one answer here and this is the other, the
585
+ * way `/basefold`'s pair is one eligibility rule at two positions.
586
+ *
587
+ * AND THE WITHHOLDING ABOVE IS ENFORCED BY THE PLACEMENT, not by this row's absence. `scope`
588
+ * reproduces `first-use` on every function where no nested list holds all of a base's uses, so a
589
+ * scoped row that answered there would ship the withheld candidate under this row's name — and it
590
+ * is the COMMON case, not the corner: over each project's whole `asm` tree, map-ful, of the 48
591
+ * functions this gate table admits, 41 place every init in the top-level list and only 7 reach a
592
+ * nested one. `hoistBaseLocals` DECLINES at `scope` in exactly that case (l3/basecse.ts) — a
593
+ * WITHDRAWAL and not a dedup, because on 29 of the 41 the flat spelling is one no other row here
594
+ * produces, which that file's header prices. Measured on
595
+ * `kleod:UpdateCameraScroll` map-ful, the row that priced the withheld one: 512 of its 512
596
+ * `/orderbase/scoped` sources placed the init at the top level, and all 512 are gone.
597
+ *
598
+ * `pairings: false` on both for the field's own reason — a product is added for a row that demands
599
+ * the joint spelling, and neither row here demands one. */
600
+ export const ORDERBASE_ADMISSIONS: readonly BaseAdmission[] = [
601
+ { suffix: '/orderbase', gates: ORDERBASE_GATES, placement: 'head', pairings: false },
602
+ { suffix: '/orderbase/scoped', gates: ORDERBASE_GATES, placement: 'scope', pairings: false },
603
+ ];
604
+
605
+ export const sameBases = (a: readonly string[], b: readonly string[]): boolean =>
606
+ a.length === b.length && a.every((k, i) => k === b[i]);
607
+
608
+ /** The signedness of the entry parameters — the classic ambiguity asm cannot resolve.
609
+ *
610
+ * Struct LAYOUT is recovered structurally (raise/structs.ts) rather than enumerated here, and the
611
+ * reason is REACH, not neutrality. This file used to say `->field_N` and `[idx]` "compile
612
+ * identically, so the differ cannot referee between them"; the second clause is FALSE on agbcc and
613
+ * `synthetic:dmanest` is the counterexample — the same element read scores 0 as
614
+ * `((struct Elem0 *)K)[a1].field_4` and 2 as `((s32 *)((a1 << 3) + K))[1]`, because an index folds
615
+ * the field offset into the pool literal (tree reassociation) where a COMPONENT_REF leaves it in
616
+ * the load displacement. `synthetic:dmaptrsrc` is a second counterexample on the field's TYPE.
617
+ *
618
+ * What is true is that no candidate is enumerated for the axis, and none is NEEDED: the recovery
619
+ * reads the base from the observed pool word and the field offset from the observed load
620
+ * displacement, so it reproduces the target's own split by construction. The measurements and the
621
+ * conditions are in `raise/structs.ts`; nothing about them belongs in a roster comment. */
622
+ export const SIGN_CANDS = [
623
+ { label: 'unsigned', signed: false },
624
+ { label: 'signed', signed: true },
625
+ ];
626
+
627
+ // A recovered POINTER/aggregate param must NOT be signedness-pinned: pinning a still-`unknown`
628
+ // pointer param to a scalar int BEFORE recovery blocks pointer recovery and emits uncompilable
629
+ // `*(s32)`. Only genuine scalars carry the signedness axis.
630
+ export const NO_PIN_KINDS = new Set(['ptr', 'struct', 'array']);