@asmlift/core 0.3.0 → 0.4.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.
@@ -1,4 +1,16 @@
1
- // asmlift — boolean-value short-circuit recovery (F-CFG; successor-aware, agbcc-class).
1
+ // asmlift — short-circuit connective recovery (F-CFG; successor-aware, agbcc-class).
2
+ //
3
+ // A `&&`/`||` reaches the IR in two shapes, and this module recovers BOTH into the SAME pair of
4
+ // opcodes (`logic_and`/`logic_or`) the backend already prints as `&&`/`||`:
5
+ //
6
+ // - the VALUE form (`return a && b`) — a diamond whose merge phi is the boolean. That is
7
+ // `recognizeShortCircuit`, described below.
8
+ // - the CONTROL-FLOW form (`if (a || b) X else Y`) — no value at all, just two `cond_br` blocks
9
+ // that share a target. That is `recognizeBranchShortCircuit`, at the bottom of this file.
10
+ //
11
+ // They are one concept and stay in one file, but they are separate passes because their inputs do
12
+ // not overlap: the value form needs the second block to end in `br` carrying a phi argument, the
13
+ // branch form needs it to end in `cond_br`.
2
14
  //
3
15
  // `return a && b` compiles (agbcc) to a value-producing diamond: `if (a==0) result=0; else result=(b!=0)`,
4
16
  // where the merge block returns the phi. The structurer lowers that as `if (a==0){v0=0}else{v0=(-b|b)>>31}
@@ -27,26 +39,10 @@
27
39
  // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
28
40
  // negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
29
41
  import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
30
- import type { Opcode } from '../ir/opcodes';
31
- import { EFFECTFUL_OPS } from '../ir/opcodes';
42
+ import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
32
43
  import { T } from '../ir/types';
33
44
 
34
- const NEGATE_ICMP: Record<string, Opcode> = {
35
- icmp_eq: 'icmp_ne',
36
- icmp_ne: 'icmp_eq',
37
- icmp_slt: 'icmp_sge',
38
- icmp_sge: 'icmp_slt',
39
- icmp_sgt: 'icmp_sle',
40
- icmp_sle: 'icmp_sgt',
41
- icmp_ult: 'icmp_uge',
42
- icmp_uge: 'icmp_ult',
43
- icmp_ugt: 'icmp_ule',
44
- icmp_ule: 'icmp_ugt',
45
- };
46
- const BOOL_OPS = new Set([...Object.keys(NEGATE_ICMP), 'logic_and', 'logic_or']);
47
- // Ops with an observable side effect — unsafe to HOIST out of a short-circuit's conditional arm
48
- // (they would run unconditionally). Derived from the ONE effect table in ir/opcodes.ts.
49
- const SIDE_EFFECT = EFFECTFUL_OPS;
45
+ const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
50
46
 
51
47
  /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
52
48
  // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
@@ -124,6 +120,16 @@ export function recognizeShortCircuit(fn: Fn): boolean {
124
120
  if (bp.length !== 1) {
125
121
  continue;
126
122
  }
123
+ // The ENTRY block is never a feeder, for the same reason it is never ^g in the branch form
124
+ // below: `predecessors()` walks successor edges only, so an entry block that is also a loop
125
+ // header shows one predecessor while actually running BEFORE it on the first iteration.
126
+ // Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
127
+ // assertResolved and assertDerefsTyped all pass. PRE-EXISTING (this fold predates the branch
128
+ // form and `main` miscompiles the same MIPS input); fixed here because the branch form's
129
+ // note used to assert this one was safe.
130
+ if (bfeed === fn.blocks[0]) {
131
+ continue;
132
+ }
127
133
  const h = bp[0];
128
134
  const ht = term(h);
129
135
  if (ht.opcode !== 'cond_br') {
@@ -157,7 +163,7 @@ export function recognizeShortCircuit(fn: Fn): boolean {
157
163
  }
158
164
  const cond = ht.operands[0];
159
165
  const condDef = defs.get(cond);
160
- if (!condDef || !NEGATE_ICMP[condDef.opcode]) {
166
+ if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
161
167
  continue;
162
168
  } // head condition must be a negatable icmp
163
169
 
@@ -171,14 +177,14 @@ export function recognizeShortCircuit(fn: Fn): boolean {
171
177
  // expression, where C's own short-circuit re-guards them. Any side effect ⇒ DECLINE the fold — the
172
178
  // merge-variable spelling the fall-through leaves is correct (the side effect stays in B's block),
173
179
  // just possibly non-matching.
174
- if (bfeed.ops.slice(0, -1).some((op) => SIDE_EFFECT.has(op.opcode))) {
180
+ if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
175
181
  continue;
176
182
  }
177
183
  bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
178
184
  let condSide = cond;
179
185
  if (wantNeg) {
180
186
  condSide = mkValue(T.unk(32));
181
- before(mkOp(NEGATE_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
187
+ before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
182
188
  }
183
189
  // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
184
190
  let res = condSide;
@@ -205,3 +211,239 @@ export function recognizeShortCircuit(fn: Fn): boolean {
205
211
  }
206
212
  return changed;
207
213
  }
214
+
215
+ // ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
216
+ //
217
+ // `if (a || b) X else Y` produces no value: it is two `cond_br` blocks that SHARE a target.
218
+ //
219
+ // ^h: cond_br c1, ^X, ^g <- `a`
220
+ // ^g: … ; cond_br c2, ^Y, ^X <- `b` (sole predecessor ^h)
221
+ //
222
+ // Nothing in the tower recognizes that today, so the structurer reaches ^X from two arms and
223
+ // TAIL-DUPLICATES it — `if (a) X else { if (b') Y else X }`. The duplicate is correct C but it is
224
+ // not the C the compiler compiled, and the duplicated tail costs every byte it contains.
225
+ //
226
+ // The fold rewrites ^h's terminator to one `cond_br` over a connective and drops ^g. Which
227
+ // connective, and which successor slot, follows from WHICH of ^h's edges leads to ^g:
228
+ //
229
+ // ^g is ^h's FALL → ^g runs iff !c1, so the SHARED block is taken iff `c1 || cShared`
230
+ // ⇒ cond_br(logic_or(c1, cShared))[shared, other]
231
+ // ^g is ^h's TAKEN → ^g runs iff c1, so the OTHER block is taken iff `c1 && cOther`
232
+ // ⇒ cond_br(logic_and(c1, cOther))[other, shared]
233
+ //
234
+ // where `cShared`/`cOther` is ^g's own condition ORIENTED at that block — ^g's `cond_br` operand
235
+ // when it already branches there, otherwise its negation (so ^g's condition must be a negatable
236
+ // icmp, exactly as in the value form). Both spellings keep ^h's original successor ORDER for the
237
+ // edge that did not change, so the branch sense the frontend read out of the asm is preserved.
238
+ //
239
+ // REFUSALS (each one a real way this could be wrong, not a hypothetical):
240
+ //
241
+ // - ^g is the ENTRY block. `predecessors()` walks successor edges only — it does not model the
242
+ // implicit edge into `fn.blocks[0]` — so an entry block that is ALSO a loop header (its one
243
+ // real predecessor being its own latch) passes the sole-predecessor test below while the whole
244
+ // soundness argument fails for it: on the first iteration ^g runs BEFORE ^h, so hoisting ^g's
245
+ // body into ^h reorders it, and deleting ^g moves the entry to another block entirely. That
246
+ // turns an entry-guarded `while` into a `do…while` whose body runs once unconditionally —
247
+ // silent wrong code, caught by no contract (verify, assertResolved and assertDerefsTyped all
248
+ // pass). MIPS and PPC reach this: only thumb.ts inserts a synthetic preheader that would give
249
+ // the header a second predecessor. `retsink.ts` guards the same way (`fn.blocks[0] !== m`).
250
+ // - ^g has a predecessor other than ^h — folding would delete a block still reachable elsewhere.
251
+ // - ^g has block params — ^h's edge binds them, and dropping the edge drops the binding.
252
+ // - ^h and ^g both test the SAME value against CONSTANTS — that is a comparison-tree `switch`,
253
+ // not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
254
+ // `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
255
+ // disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
256
+ // of nested `if`s. The switch is the better recovery and it is the more specific one, so it
257
+ // wins the shape. Cost: a genuine source-level `x == 1 || x == 2` that is NOT part of a wider
258
+ // tree also declines — the same conservative trade loops.ts makes when it refuses to infer a
259
+ // header from `cond_br` shape.
260
+ // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
261
+ // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
262
+ // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
263
+ // MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
264
+ // conditional computation into an unconditional one. Single-use-and-local is precisely the
265
+ // shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
266
+ // re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
267
+ // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
268
+ // so it can only carry one argument list; picking either would silently drop the other path's
269
+ // phi input.
270
+ // - ^g's two successors are the same block, or ^g's condition is not a negatable icmp when the
271
+ // orientation needs negating.
272
+ //
273
+ // Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
274
+ // miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
275
+ // more condition block.
276
+ export function recognizeBranchShortCircuit(fn: Fn): boolean {
277
+ let changed = false;
278
+ const term = (b: Block) => b.ops[b.ops.length - 1];
279
+ let progress = true;
280
+ while (progress) {
281
+ progress = false;
282
+ const defs = defOpMap(fn);
283
+ const preds = predecessors(fn);
284
+ outer: for (const h of fn.blocks) {
285
+ const ht = term(h);
286
+ if (ht.opcode !== 'cond_br') {
287
+ continue;
288
+ }
289
+ const [taken, fall] = ht.successors;
290
+ // Try ^g = the fall edge, then ^g = the taken edge. `gIsFall` picks the connective.
291
+ for (const gIsFall of [true, false]) {
292
+ const gEdge = gIsFall ? fall : taken;
293
+ const sharedFromH = gIsFall ? taken : fall;
294
+ const g = gEdge.block;
295
+ if (g === h || g === sharedFromH.block || g.params.length > 0) {
296
+ continue;
297
+ }
298
+ // The ENTRY block is never ^g — see the REFUSALS note. `predecessors()` cannot see the
299
+ // implicit entry edge, so this is the only thing standing between an entry-block loop
300
+ // header and a silently reordered function body.
301
+ if (g === fn.blocks[0]) {
302
+ continue;
303
+ }
304
+ if ((preds.get(g) ?? []).length !== 1) {
305
+ continue;
306
+ }
307
+ const gt = term(g);
308
+ if (gt.opcode !== 'cond_br') {
309
+ continue;
310
+ }
311
+ const [gTaken, gFall] = gt.successors;
312
+ if (gTaken.block === gFall.block) {
313
+ continue;
314
+ }
315
+ // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold.
316
+ if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
317
+ continue;
318
+ }
319
+ // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
+ // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
+ // short circuit.
322
+ // HOIST_UNSAFE_OPS, not EFFECTFUL_OPS: a live `opaque` is an instruction asmlift could not
323
+ // model, and moving it out of the arm that guards it is the reordering this refuses. Loud
324
+ // either way today (a decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under
325
+ // `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
326
+ // an observed bug.
327
+ const body = g.ops.slice(0, -1);
328
+ if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
329
+ continue;
330
+ }
331
+ if (!definedValuesStayLocal(fn, g)) {
332
+ continue;
333
+ }
334
+ // Which of ^g's edges rejoins ^h's other successor? That is the shared block.
335
+ const sharedEdge =
336
+ gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
337
+ if (!sharedEdge) {
338
+ continue;
339
+ }
340
+ const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
341
+ if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
342
+ continue;
343
+ }
344
+ // The second operand, oriented at the block whose slot it decides: `logic_or` asks "does ^g
345
+ // reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
346
+ const wantEdge = gIsFall ? sharedEdge : otherEdge;
347
+ const c2 = gt.operands[0];
348
+ const c2Def = defs.get(c2);
349
+ let second = c2;
350
+ const negated: Op[] = [];
351
+ if (wantEdge !== gTaken) {
352
+ if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
353
+ continue;
354
+ }
355
+ second = mkValue(T.unk(32));
356
+ negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
357
+ }
358
+ const res = mkValue(T.unk(32));
359
+ const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
360
+ operands: [ht.operands[0], second],
361
+ results: [res],
362
+ });
363
+ // ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
364
+ // (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
365
+ h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
366
+ ...mkOp('cond_br', { operands: [res] }),
367
+ successors: gIsFall
368
+ ? [
369
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
370
+ { block: otherEdge.block, args: [...otherEdge.args] },
371
+ ]
372
+ : [
373
+ { block: otherEdge.block, args: [...otherEdge.args] },
374
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
375
+ ],
376
+ });
377
+ fn.blocks = fn.blocks.filter((x) => x !== g);
378
+ changed = true;
379
+ progress = true;
380
+ break outer; // defs/preds are stale after the mutation — recompute on the next round
381
+ }
382
+ }
383
+ }
384
+ return changed;
385
+ }
386
+
387
+ /** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
388
+ * comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
389
+ * only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
390
+ * range check) is a genuine connective this fold should still take. */
391
+ function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
392
+ const eqTest = (v: Value): { scrutinee: Value } | null => {
393
+ const d = defs.get(v);
394
+ if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
395
+ return null;
396
+ }
397
+ const [x, y] = d.operands;
398
+ const xc = defs.get(x)?.opcode === 'const';
399
+ const yc = defs.get(y)?.opcode === 'const';
400
+ // exactly one side constant — `x == y` between two variables is no switch test
401
+ return xc === yc ? null : { scrutinee: xc ? y : x };
402
+ };
403
+ const a = eqTest(c1);
404
+ const b = eqTest(c2);
405
+ return a !== null && b !== null && a.scrutinee === b.scrutinee;
406
+ }
407
+
408
+ /** True when every value `g` defines is read at most once, and any read is inside `g`.
409
+ *
410
+ * The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
411
+ * ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
412
+ * is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
413
+ * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
414
+ * escape, and only this check stops it.
415
+ *
416
+ * An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
417
+ * itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
418
+ * WRONG — the entry block dominates every block whatever M's predecessor count — and it was wrong
419
+ * about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
420
+ * form was missing entirely. Both now have it. When changing either fold, check the other. */
421
+ function definedValuesStayLocal(fn: Fn, g: Block): boolean {
422
+ const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
423
+ if (defined.size === 0) {
424
+ return true;
425
+ }
426
+ const uses = new Map<Value, number>();
427
+ for (const b of fn.blocks) {
428
+ for (const op of b.ops) {
429
+ for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
430
+ if (!defined.has(v)) {
431
+ continue;
432
+ }
433
+ if (b !== g) {
434
+ return false; // escapes ^g — the structurer would render it before the `if`
435
+ }
436
+ uses.set(v, (uses.get(v) ?? 0) + 1);
437
+ }
438
+ }
439
+ }
440
+ // ZERO uses is fine — a dead op renders nothing at all, so it cannot escape the short circuit.
441
+ // TWO or more is not: analysis.ts materializes a multi-consumer value into a local, which is a
442
+ // statement, and a statement lands before the `if`.
443
+ return [...defined].every((v) => (uses.get(v) ?? 0) <= 1);
444
+ }
445
+
446
+ /** Two successor argument lists that a fold may collapse into one: same values, same order. */
447
+ function sameArgs(a: Value[], b: Value[]): boolean {
448
+ return a.length === b.length && a.every((v, i) => v === b[i]);
449
+ }
@@ -196,8 +196,18 @@ export function recognizeStructs(fn: Fn): number {
196
196
  return count;
197
197
  }
198
198
 
199
- /** The distinct struct types this function references (unwrapping struct pointers on every value),
200
- * deduped by name and sorted, for the backend to declare above the function. */
199
+ /** The distinct struct types this function's L2 GRAPH mentions (unwrapping struct pointers on every
200
+ * value), deduped by name and sorted, for the backend to declare above the function.
201
+ *
202
+ * "Mentions", not "references": this walks `fn.blocks` at the moment structuring runs, and the
203
+ * result is CACHED on the SFn (`structure.ts`) and carried by every later `{...sfn}` pass, so a
204
+ * struct whose last use a subsequent L3 pass removed would still be declared. Sibling `locals` is
205
+ * reference-pruned after dead-store elimination (`l3/dce.ts`) for exactly that reason; this list is
206
+ * not. No pass drops such a use today — the IR-level DCE runs long before recognition, and l3/dce's
207
+ * `mustKeep` never drops a `field`/`index` — so the staleness is LATENT, not live, which is why it
208
+ * is recorded here rather than fixed speculatively. The fix, if a pass ever makes it reachable, is
209
+ * the one l3/symbol-refs.ts already applies to symbol references: derive at the consumption point
210
+ * (`backend/cfamily.ts` structDecls) instead of caching. */
201
211
  export function collectStructs(fn: Fn): StructType[] {
202
212
  const seen = new Map<string, StructType>();
203
213
  const consider = (t: IrType) => {
package/src/rank.ts CHANGED
@@ -15,9 +15,12 @@ import { frontendFor } from './frontend/registry';
15
15
  import { Fn, type Value, defOpMap } from './ir/core';
16
16
  import { T } from './ir/types';
17
17
  import { verify } from './ir/verify';
18
+ import { materializeArgBases } from './l3/argbase';
18
19
  import type { LanguageBackend, SFn } from './l3/ast';
20
+ import { coalesceCandidates } from './l3/coalesce';
19
21
  import { registerishSpellings } from './l3/regspell';
20
22
  import { reindexWalks } from './l3/reindex';
23
+ import { hoistScopedBases } from './l3/scopebase';
21
24
  import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
22
25
  import { RewritePattern } from './pattern/engine';
23
26
  import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
@@ -117,12 +120,30 @@ export interface EnumerateOptions {
117
120
  asmData?: AsmData;
118
121
  /** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
119
122
  symbols?: SymbolMap;
123
+ /** Called when a re-spelling lever THROWS or fails a boundary contract, so the failure is visible
124
+ * instead of the candidate silently not existing. Enumeration continues either way — the primary
125
+ * spelling is unaffected — but a lever that never fires because it always throws is a defect, and
126
+ * without this it looks identical to a lever that correctly declined. */
127
+ onLeverError?: (label: string, error: string) => void;
120
128
  }
121
129
 
122
- /** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
130
+ /** One distinct candidate spelling a point in the axis cross (signedness × branch sense ×
131
+ * def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
132
+ * emitted to source. */
123
133
  export interface Candidate {
124
134
  label: string;
125
135
  source: string;
136
+ /** Which PREFERENCE GROUP this spelling belongs to — the symbol-variant index (0 = the map's own
137
+ * named spellings, 1 = their `/raw-globals` siblings). Enumeration emits the groups in
138
+ * preference order, and a lower group WINS a score tie: when both compile to the same bytes the
139
+ * reader should get `gCounter.field`, not a byte offset off a hoisted `(u8 *)` base.
140
+ *
141
+ * Carried structurally rather than left to enumeration order because the readability tie-break
142
+ * (compareScored) must compare only spellings that are genuinely alternatives of the same
143
+ * thing. Ranking a named spelling against a raw-address one on cast count is not a readability
144
+ * comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
145
+ * construction, trading named struct fields for anonymous byte offsets. */
146
+ group: number;
126
147
  /** the map-derived VALUE references this candidate's tree contains — what the scoring
127
148
  * layer's declaration synthesis renders. DERIVED, never carried: computed once from the
128
149
  * exact tree this candidate's source was emitted from, at the moment the candidate is
@@ -179,10 +200,32 @@ export function enumerateCandidates(
179
200
  // and let the differ referee. The default sense is always among them, so this never scores
180
201
  // worse; it only wins where the flip matches.
181
202
  const defSense = baseOpts.preserveDivergentBranchSense ?? true;
182
- const senseCands = [
183
- { suffix: '', sense: defSense },
184
- { suffix: '/flip-branch', sense: !defSense },
203
+ // `/defsite` def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
204
+ // structuring axis on the same footing as branch sense: where the asm materialized a merge
205
+ // constant is placement evidence, but whether the SOURCE spelled it there is genuinely
206
+ // ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
207
+ // (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
208
+ // below collapses every variant the anchoring left unchanged.
209
+ const baseSense = [
210
+ { suffix: '', sense: defSense, anchor: false, bitfields: true },
211
+ { suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
212
+ { suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
213
+ { suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
185
214
  ];
215
+ // `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
216
+ // The named read recompiles at the DECLARATION's access width; where that diverges from the
217
+ // asm's load width, the shifts are the spelling that matches — so both are emitted and the
218
+ // differ referees. Enumerated only when the map carries any bitfield member at all (checked
219
+ // below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
220
+ // every variant where no fold fired.
221
+ const mapHasBitfields =
222
+ opts.symbols !== undefined &&
223
+ [...opts.symbols.values()].some((infos) =>
224
+ infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
225
+ );
226
+ const senseCands = mapHasBitfields
227
+ ? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
228
+ : baseSense;
186
229
  // Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
187
230
  // so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
188
231
  // compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
@@ -211,7 +254,7 @@ export function enumerateCandidates(
211
254
  { suffix: '/raw-globals', symbols: undefined },
212
255
  ]
213
256
  : [{ suffix: '' }];
214
- for (const sv of symbolVariants) {
257
+ for (const [svIndex, sv] of symbolVariants.entries()) {
215
258
  const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
216
259
  for (const cand of SIGN_CANDS) {
217
260
  const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
@@ -223,7 +266,23 @@ export function enumerateCandidates(
223
266
  for (const s of senseCands) {
224
267
  // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
225
268
  // senses structure the same recovered function without re-lifting.
226
- const sfn = structureChecked(fn, { ...svOpts, preserveDivergentBranchSense: s.sense });
269
+ let sfn: SFn;
270
+ try {
271
+ sfn = structureChecked(fn, {
272
+ ...svOpts,
273
+ preserveDivergentBranchSense: s.sense,
274
+ anchorConstCopies: s.anchor,
275
+ spellBitfieldMembers: s.bitfields,
276
+ });
277
+ } catch (e) {
278
+ if (!s.anchor && s.bitfields) {
279
+ throw e; // the base axes keep their behavior: a structuring failure aborts the row
280
+ }
281
+ // an anchored variant that fails structuring or its contracts is a dropped lever, never
282
+ // an aborted enumeration — same rule as respell below
283
+ opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
284
+ continue;
285
+ }
227
286
  // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
228
287
  // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
229
288
  // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
@@ -266,24 +325,72 @@ export function enumerateCandidates(
266
325
  // to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
267
326
  // defect class this project exists to avoid. Hence each lever's decline-over-approximate
268
327
  // gates, adversarially audited.
269
- const respell = (suffix: string, alt: SFn): void => {
328
+ // Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
329
+ // from the pass itself — rather than from the contracts or the backend — would escape and
330
+ // abort the whole enumeration for this row, primary included: the one way a lever can cost
331
+ // a match. Making that structural rather than per-call-site means no lever can opt out.
332
+ const respell = (suffix: string, make: () => SFn | null | undefined): void => {
270
333
  try {
334
+ const alt = make();
335
+ if (!alt) {
336
+ return; // the lever declined to fire — no candidate, not a duplicate of the primary
337
+ }
271
338
  assertResolved(alt);
272
339
  assertDerefsTyped(alt);
273
340
  spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
274
- } catch {
275
- // contract-failing or unspellable re-spelling: drop it, keep the primary
341
+ } catch (e) {
342
+ // A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
343
+ // REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
344
+ // so without this a lever that fails here vanishes with no trace — indistinguishable
345
+ // from one that correctly declined, which is exactly the hidden failure
346
+ // DroppedCandidate exists to surface.
347
+ opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
276
348
  }
277
349
  };
278
- const indexed = reindexWalks(sfn);
279
- if (indexed) {
280
- respell('/indexed', indexed);
281
- }
350
+ // `/argbase` name a call's argument bases before the call (l3/argbase.ts). A lever on the
351
+ // same footing as the others: the primary inline spelling stays in the list, so the differ
352
+ // referees and this can never cost a match.
353
+ respell('/argbase', () => materializeArgBases(sfn));
354
+ // `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
355
+ // (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
356
+ // carries: this one fires exactly where that placement would extend a live range the
357
+ // original never had.
358
+ // `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
359
+ // derivable from the tree — on the row this was built for the two legal merges score 18 and
360
+ // 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
361
+ // discards the winner. Every variant is emitted and the differ referees, exactly as
362
+ // `/regcopy` does for its allocator-ambiguous tail choice.
363
+ //
364
+ // POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
365
+ // levers do not compose. These are not a second lever composed onto the first: coalescing is
366
+ // enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
367
+ // just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
368
+ //
369
+ // EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
370
+ // runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
371
+ // the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
372
+ respell('/scopebase', () => hoistScopedBases(sfn));
373
+ const enumerate = (label: string, from: () => SFn | null | undefined): void => {
374
+ let variants: { merged: string; sfn: SFn }[] = [];
375
+ try {
376
+ const base = from();
377
+ variants = base ? coalesceCandidates(base) : [];
378
+ } catch (e) {
379
+ opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
380
+ return;
381
+ }
382
+ for (const c of variants) {
383
+ respell(`${label}-${c.merged}`, () => c.sfn);
384
+ }
385
+ };
386
+ enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
387
+ enumerate('/coalesce', () => sfn);
388
+ respell('/indexed', () => reindexWalks(sfn));
282
389
  // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
283
390
  // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
284
391
  // ambiguous, so both are ranked)
285
392
  const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
286
- registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
393
+ registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
287
394
  for (const sp of spellings) {
288
395
  const source = sp.source;
289
396
  // Collapse a spelling that produced identical source (a function with no divergent `if`
@@ -298,6 +405,7 @@ export function enumerateCandidates(
298
405
  out.push({
299
406
  label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
300
407
  source,
408
+ group: svIndex,
301
409
  ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
302
410
  });
303
411
  }
@@ -332,15 +440,59 @@ export function rankBy<S extends { score: number }>(
332
440
  if (results.length === 0) {
333
441
  throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
334
442
  }
335
- // Score first; ENUMERATION ORDER breaks a tie. That order is meaningful, not incidental:
336
- // enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
337
- // when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
338
- // than a bare address. Spelled as an explicit comparator because relying on Array#sort's
339
- // stability would make the preference an accident of two unrelated decisions.
340
- results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
443
+ results.sort(compareScored);
341
444
  return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
342
445
  }
343
446
 
447
+ /** THE candidate ordering — score, then preference group, then readability, then enumeration
448
+ * order. Exported because there are TWO drivers over the same enumeration (this module's sync
449
+ * `rankBy` for the Node/objdiff scorer, and the webapp's async await-loop for the wasm one), and
450
+ * a per-driver copy would let the same input produce two different winners.
451
+ *
452
+ * SCORE dominates absolutely: the differ is the fitness function, and a tie means the axis that
453
+ * separates these two spellings did not change the bytes — so everything below only chooses what
454
+ * the READER sees, and can never cost a match.
455
+ *
456
+ * GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
457
+ *
458
+ * CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
459
+ * the C backend has to cast a shift operand back to the signedness the machine op needs, so
460
+ * pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
461
+ * for the same bytes as `s32 f(s32 a0) { return a0 >> a1; }`. Before the backend synthesized that
462
+ * cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
463
+ * silently install the noisier spelling.
464
+ *
465
+ * ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
466
+ * result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
467
+ * would make each preference an accident of two unrelated decisions. */
468
+ export function compareScored<S extends { score: number }>(
469
+ a: Candidate & { score: S; order: number },
470
+ b: Candidate & { score: S; order: number },
471
+ ): number {
472
+ return (
473
+ a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
474
+ );
475
+ }
476
+
477
+ /** Scalar casts in a candidate's rendered source — the readability tie-break above.
478
+ *
479
+ * A TEXT count over the emitted string, matching how the benchmark's own readability metric
480
+ * measures the same thing (apps/benchmark/src/eval/quality.ts) — the two must agree about what
481
+ * "cast noise" means, or ranking optimizes for something the report then scores differently.
482
+ *
483
+ * It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
484
+ * — those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
485
+ * ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
486
+ * arithmetic on a link-time address, which decomp projects write themselves. Counting it would
487
+ * penalize precisely the named spelling this ranking is supposed to prefer.
488
+ *
489
+ * Deterministic, and total on any string. */
490
+ function castCount(source: string): number {
491
+ const all = source.match(/\((?:u|s)(?:8|16|32)\)/g)?.length ?? 0;
492
+ const addr = source.match(/\((?:u|s)32\)\s*&/g)?.length ?? 0;
493
+ return all - addr;
494
+ }
495
+
344
496
  /** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
345
497
  function firstLine(e: unknown): string {
346
498
  return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');