@asmlift/core 0.9.0 → 0.11.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 (117) hide show
  1. package/README.md +25 -24
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +149 -0
  4. package/src/backend/c.ts +2 -0
  5. package/src/backend/cfamily.ts +197 -61
  6. package/src/backend/cpp.ts +3 -1
  7. package/src/backend/lines.ts +10 -0
  8. package/src/backend/pascal.ts +47 -16
  9. package/src/cdecl/parse.ts +21 -0
  10. package/src/contracts.ts +246 -36
  11. package/src/declare.ts +69 -20
  12. package/src/frontend/arm-body.ts +155 -0
  13. package/src/frontend/asmdata.ts +9 -2
  14. package/src/frontend/call-plan.ts +1117 -61
  15. package/src/frontend/callee-body.ts +370 -0
  16. package/src/frontend/dead-reads.ts +628 -0
  17. package/src/frontend/device-pins.ts +33 -4
  18. package/src/frontend/emit.ts +4 -3
  19. package/src/frontend/fpu.ts +224 -17
  20. package/src/frontend/frame-objects.ts +1689 -206
  21. package/src/frontend/frontend.ts +84 -3
  22. package/src/frontend/high-half.ts +53 -4
  23. package/src/frontend/jump-table.ts +545 -0
  24. package/src/frontend/local-object.ts +158 -32
  25. package/src/frontend/mips.ts +2296 -217
  26. package/src/frontend/own-struct-return.ts +500 -0
  27. package/src/frontend/ppc.ts +2748 -245
  28. package/src/frontend/register-struct-call.ts +399 -0
  29. package/src/frontend/reloc-symbol.ts +22 -7
  30. package/src/frontend/ssa.ts +1721 -43
  31. package/src/frontend/stackargs.ts +199 -72
  32. package/src/frontend/thumb-entry.ts +340 -0
  33. package/src/frontend/thumb.ts +2400 -325
  34. package/src/ir/alias.ts +65 -2
  35. package/src/ir/bits.ts +1699 -4
  36. package/src/ir/core.ts +254 -9
  37. package/src/ir/discipline.ts +86 -21
  38. package/src/ir/entry.ts +157 -0
  39. package/src/{structure → ir}/loops.ts +6 -2
  40. package/src/ir/opcodes.ts +137 -9
  41. package/src/ir/parse.ts +1 -0
  42. package/src/ir/simplify.ts +61 -10
  43. package/src/ir/types.ts +86 -2
  44. package/src/ir/verify.ts +96 -11
  45. package/src/l3/argbase.ts +2 -0
  46. package/src/l3/ast.ts +582 -18
  47. package/src/l3/basecse.ts +68 -15
  48. package/src/l3/caseendgoto.ts +223 -0
  49. package/src/l3/coalesce.ts +42 -14
  50. package/src/l3/constinit.ts +410 -0
  51. package/src/l3/dce.ts +53 -9
  52. package/src/l3/initfirst.ts +42 -11
  53. package/src/l3/intcell.ts +2 -1
  54. package/src/l3/narrowdecl.ts +77 -36
  55. package/src/l3/pollguard.ts +8 -2
  56. package/src/l3/ptrcell.ts +33 -34
  57. package/src/l3/ptrfield.ts +123 -8
  58. package/src/l3/regspell.ts +11 -4
  59. package/src/l3/reindex.ts +295 -99
  60. package/src/l3/scopebase.ts +2 -0
  61. package/src/l3/storage.ts +98 -1
  62. package/src/l3/symbol-refs.ts +84 -6
  63. package/src/l3/tailmerge.ts +6 -2
  64. package/src/l3/typing.ts +108 -15
  65. package/src/l3/unreduce.ts +6 -5
  66. package/src/l3/volatileptr.ts +17 -54
  67. package/src/mangle.ts +30 -0
  68. package/src/pattern/engine.ts +361 -73
  69. package/src/pipeline.ts +1039 -38
  70. package/src/proto-context.ts +1039 -45
  71. package/src/proto.ts +555 -40
  72. package/src/raise/aggmembers.ts +290 -0
  73. package/src/raise/arrays.ts +5 -1
  74. package/src/raise/const.ts +25 -0
  75. package/src/raise/declared-volatile.ts +207 -31
  76. package/src/raise/divpow2.ts +192 -0
  77. package/src/raise/floathelpers.ts +254 -33
  78. package/src/raise/gvn.ts +3 -3
  79. package/src/raise/latch.ts +162 -6
  80. package/src/raise/memberarrays.ts +10 -4
  81. package/src/raise/offsetnames.ts +422 -36
  82. package/src/raise/pairparams.ts +33 -0
  83. package/src/raise/paramwidth.ts +196 -6
  84. package/src/raise/pre-recovery.ts +107 -21
  85. package/src/raise/pre-structure.ts +73 -0
  86. package/src/raise/recover.ts +12 -5
  87. package/src/raise/reroll.ts +674 -0
  88. package/src/raise/shortcircuit.ts +217 -34
  89. package/src/raise/softdiv.ts +1 -1
  90. package/src/raise/storesign.ts +153 -0
  91. package/src/raise/struct-arrays.ts +30 -11
  92. package/src/raise/structs.ts +396 -33
  93. package/src/raise/truncload.ts +4 -3
  94. package/src/raise/unpre.ts +594 -0
  95. package/src/raise/unthread.ts +400 -0
  96. package/src/rank-declare.ts +118 -12
  97. package/src/rank-variations.ts +186 -35
  98. package/src/rank.ts +536 -68
  99. package/src/runtime-helpers.ts +140 -22
  100. package/src/structure/analysis.ts +217 -8
  101. package/src/structure/arith-ops.ts +23 -0
  102. package/src/structure/cross-jumped-tail.ts +216 -0
  103. package/src/structure/globalaccess.ts +157 -10
  104. package/src/structure/hazards.ts +16 -64
  105. package/src/structure/local-statics.ts +145 -9
  106. package/src/structure/namecoalesce.ts +1 -1
  107. package/src/structure/pointer-spelling.ts +1106 -0
  108. package/src/structure/redundant-test.ts +1 -1
  109. package/src/structure/structure.ts +6192 -1213
  110. package/src/structure/switch-recover.ts +583 -120
  111. package/src/structure/unfolded-branch-constant.ts +90 -0
  112. package/src/symbols.ts +58 -15
  113. package/src/target.ts +1028 -54
  114. package/src/trace.ts +49 -17
  115. package/src/variation-definitions.ts +333 -20
  116. package/src/variation-gates.ts +4 -0
  117. package/src/variation-tokens.ts +68 -3
@@ -24,10 +24,11 @@ import {
24
24
  ORDERBASE_GATES,
25
25
  UNFOLDED_GATES,
26
26
  } from './l3/basecse';
27
+ import { jumpToCaseEnds } from './l3/caseendgoto';
27
28
  import type { Gate } from './l3/gates';
28
29
  import type { HoistPlacement } from './l3/hoist';
29
30
  import { initFirstGuards } from './l3/initfirst';
30
- import { narrowDeclarations, narrowReadDeclarations } from './l3/narrowdecl';
31
+ import { narrowDeclarations, narrowLoadDeclarations, narrowReadDeclarations } from './l3/narrowdecl';
31
32
  import { pollGuards, pollReads } from './l3/pollguard';
32
33
  import { unmergeJoins } from './l3/unmerge';
33
34
  // TYPE-ONLY, and deliberately: the structure-variation table types its `options` as
@@ -55,7 +56,11 @@ import type { VariationName } from './variation-tokens';
55
56
  * that setting's own lifted fn (a map-lifted shared lift spells const bases as gaddr, which would
56
57
  * blind the /raw-globals siblings — the /addr-home lesson). `strip` opts the variation into the
57
58
  * dropped-sibling closure: a candidate that applies it is skipped when its default sibling (the
58
- * same setting without it) failed the boundary contracts. Which of these entries `structure()`'s
59
+ * same setting without it) failed the boundary contracts. `offWitness` is the exact gate where
60
+ * only the structuring itself knows whether the variation can change the tree: the OFF sibling
61
+ * (the same setting without it, structured earlier in the same pass) runs with that hook, and a
62
+ * setting whose OFF sibling never fired it is skipped, since it would build that sibling's tree
63
+ * again. Which of these entries `structure()`'s
59
64
  * `assertDefaultAccepts` guard does NOT reset — the ones that cannot unlock a function the default
60
65
  * declines, and so carry a written argument in place of the guard — is stated ONCE, beside that
61
66
  * guard in structure.ts, and derived from the guard's own reset list in
@@ -71,14 +76,18 @@ export interface StructureVariation {
71
76
  | 'derivedHome'
72
77
  | 'mergeHome'
73
78
  | 'escapeHome'
74
- | 'unsCmp'
79
+ | 'signedCmp'
75
80
  | 'freshMerge'
76
81
  | 'copyDefPos'
77
- | 'siteSense';
82
+ | 'siteSense'
83
+ | 'tailCopies'
84
+ | 'forContinue'
85
+ | 'caseGoto';
78
86
  name: VariationName;
79
87
  options: (on: boolean) => Parameters<typeof structureChecked>[1];
80
88
  sharedGate?: (sharedLift: Fn, defs: Map<Value, Op>) => boolean;
81
89
  perLiftGate?: (fn: Fn) => boolean;
90
+ offWitness?: 'onTailFallerKept' | 'onForOffered' | 'onCaseCopied';
82
91
  strip: boolean;
83
92
  }
84
93
  export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
@@ -225,17 +234,22 @@ export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
225
234
  perLiftGate: hasEscapingExtension,
226
235
  strip: true,
227
236
  },
228
- // `/uns-cmp` — spell unsigned compares unsigned (structure.ts unsignedCompareSpelling): an
229
- // icmp_u* operand takes a (u32) cast where the rendered operands do not guarantee the
230
- // unsignedness, and a mixed-claimant declaration reconciles to u32 when nothing under the
231
- // name needs signed. Which side the source spelled is genuinely ambiguous: a signed spelling
232
- // that byte-matched was PROVED non-negative by the compiler (only then does it emit the
233
- // unsigned branch from a signed compare), and emission's provable set is smaller than the
234
- // compiler's. Gated on the function having an unsigned compare at all.
237
+ // `/signed-cmp` — spell an unsigned compare SIGNED (structure.ts unsignedCompareSpelling OFF): no
238
+ // `(u32)` cast at an icmp_u* and no u32 reconciliation of a mixed-claimant declaration. The
239
+ // default is the one spelling value-faithful without a proof; this one is a real source where the
240
+ // compiler PROVED both sides non-negative (only then does it emit the unsigned branch from a
241
+ // signed compare), which emission cannot always repeat. Where the proof does not hold it is a
242
+ // different program, so rank.ts judges every emitted tree (`signedOverUnsignedCompare`) and makes
243
+ // a signed rendering `Candidate.matchOnly`, whichever setting or respell spelled it. A tie or a
244
+ // better nonmatch score is no evidence for it (`synthetic:selloop:gcc2.7.2kmc`).
245
+ // KNOWN GAP, in the safe direction: the judgment reads the tree's declarations, and globals are
246
+ // not among them, so a compare that renders unsigned only through a `void *` global the backend
247
+ // declares (ptrcell.test.ts KR1, `g >= gLimit`) is still withheld on a nonmatch.
248
+ // Gated on the function having an unsigned compare at all.
235
249
  {
236
- flag: 'unsCmp',
237
- name: 'uns-cmp',
238
- options: (on) => ({ unsignedCompareSpelling: on }),
250
+ flag: 'signedCmp',
251
+ name: 'signed-cmp',
252
+ options: (on) => ({ unsignedCompareSpelling: !on }),
239
253
  sharedGate: (sharedLift) => sharedLift.blocks.some((b) => b.ops.some((op) => op.opcode.startsWith('icmp_u'))),
240
254
  strip: true,
241
255
  },
@@ -371,26 +385,107 @@ export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
371
385
  ),
372
386
  strip: false,
373
387
  },
388
+ // `/tail-copies` — WHICH ARM KEEPS A CROSS-JUMPED TAIL (structure.ts `copyEveryTailEntrant`,
389
+ // switch-recover.ts `copySharedFallTargets`). A compiler that cross-jumps keeps one copy of the
390
+ // code two or more jump-table arms end in, so those arms all enter one sibling arm's body. The
391
+ // default writes the body back into every entrant but the one the arm-order policy puts directly
392
+ // above the sibling, which keeps its fall-through; this writes it into every entrant. Which arm
393
+ // the source wrote the tail under is not in the asm, and one compiler answers both ways at one
394
+ // set of flags: on KMC `-O2` and gcc 2.7.2 `-O1`, `synthetic:jt_xjcase` (case 1 written above
395
+ // case 2 and falling into it) recompiles only from the default, while the same arms written
396
+ // out of case order (`case 3`, `case 2`, `case 1`) are 24 objdump lines off from the default
397
+ // and 4 from this. So the differ referees it per row, like `/copy-defpos`.
398
+ //
399
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): the two settings read
400
+ // different exits only where an entrant is left falling into a cross-jumped sibling
401
+ // (`copySharedFallTargets`' `onFallerKept`), so where the OFF sibling never left one the ON setting
402
+ // is not structured at all. No question of the raised fn answers that ahead of the pass: a block
403
+ // two arms reach is no sibling where it is the switch's merge (nearly every switch's post-switch
404
+ // code), and two entrants can reach their sibling through one shared cross-jumped block, so one
405
+ // in-edge. In the strip closure, and reset by `assertDefaultAccepts`: where the target language
406
+ // has no fall-through (Pascal) the default can still decline on the one fall-through it keeps,
407
+ // and copying every entrant would structure what the default refused.
408
+ {
409
+ flag: 'tailCopies',
410
+ name: 'tail-copies',
411
+ options: (on) => ({ copyEveryTailEntrant: on }),
412
+ offWitness: 'onTailFallerKept',
413
+ strip: true,
414
+ },
415
+ // `/for-continue` — A COUNTED LOOP AS `for` WHERE THE DEFAULT LIFTS IT AS `while (1)` (structure.ts
416
+ // `spellCountedLoopsFor`, `forPlanAt`). A loop whose latch steps one counter and tests it, entered
417
+ // through the same test, is one asm under both spellings, and the `for` is the one a jump-table arm
418
+ // inside it can `continue` and leave by `break;` after the switch. structure() reaches that `for`
419
+ // on its own only where every `while (1)` structuring declined (`ForRanking`); here it is ranked
420
+ // where the default lifts. Measured on agbcc, objdump instruction lines off the target, both lifts
421
+ // 0/20000 against the source in a host differential: a `for` whose five jump-table arms each
422
+ // `continue` or call and leave lifts as `while (1)` with the code after the loop copied into every
423
+ // arm at 54, and as the `for` at 27; two nested-loop probes go 81 → 68 and 55 → 44.
424
+ //
425
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): only a `while (1)` loop that passes
426
+ // every one of `forPlanAt`'s checks can change, and the OFF sibling reports meeting one
427
+ // (`onForOffered`). In the strip closure, and reset by `assertDefaultAccepts`: a first run that
428
+ // spells every such loop `for` can structure a function the default (with its own reruns) refuses.
429
+ {
430
+ flag: 'forContinue',
431
+ name: 'for-continue',
432
+ options: (on) => ({ spellCountedLoopsFor: on }),
433
+ offWitness: 'onForOffered',
434
+ strip: true,
435
+ },
436
+ // `/case-goto` — TWO ARMS ENTERING ONE CASE'S BODY AS A `goto` TO ITS LABEL WHERE THE DEFAULT
437
+ // COPIES IT (structure.ts `jumpToSharedCases`, switch-recover.ts `jumpSharedFallTargets`). Where
438
+ // two or more jump-table arms enter one case arm's body, the default writes that body back into the
439
+ // entrants but one (`copySharedFallTargets`) and reads the goto only where every goto-free
440
+ // structuring of the function declines, in a switch none of them spelled goto-free
441
+ // (`JumpRanking`); this reads the goto first. The asm does not
442
+ // say which: a compiler that cross-jumps merges a source copy into the goto's bytes when the whole
443
+ // copy matches, and on agbcc it stops short where CSE has the arm re-use an address the copy
444
+ // re-forms, so the copy compiles to more code than the goto (`synthetic:sw_gotocase2`, case 1's copy
445
+ // re-reading `gE[i]`). The differ referees it per row, like `/tail-copies`, and a tie goes to the
446
+ // candidate without the goto (rank.ts `gotoCount`). It does not take `/tail-copies`' faller choice:
447
+ // the entrant directly above the case always falls, since a jump from it would land on the label
448
+ // right below it, a path its fall-through already spells — so `/tail-copies/case-goto` is no
449
+ // candidate of its own.
450
+ //
451
+ // Gated EXACTLY, by its OFF sibling's structuring (`offWitness`): the two settings read different
452
+ // exits only where the copies write a case arm's body into an entrant (`onCaseCopied`). In the
453
+ // strip closure, and reset by `assertDefaultAccepts`: read first, the jumps can leave a chain the
454
+ // copies after them structure where the default (copies, then jumps) declined.
455
+ {
456
+ flag: 'caseGoto',
457
+ name: 'case-goto',
458
+ options: (on) => ({ jumpToSharedCases: on }),
459
+ offWitness: 'onCaseCopied',
460
+ strip: true,
461
+ },
374
462
  ];
375
463
 
376
- /** The STACKED variations (sanctioned in the POLICY note at rank.ts's respell site): each entry is
377
- * a statement-order/shape or declaration-width respell variation orthogonal to every respell
378
- * variation outside this table, derived onto every source. Two members need not commute
379
- * (`/narrow-decl` and `/narrow-read` compete for one cast), and table order then decides which one
380
- * takes it in the all-together candidate. Each fires alone, plus
381
- * all of them together in table order — not the full subset lattice; the pairs question is
382
- * settled by applyStacked' skip-on-decline below, and a row demanding a true EXCLUSION pair —
383
- * every member fires, the match needs a strict subset of them — is what would earn the lattice.
384
- * So a member appended here also changes the all-together candidate wherever it fires beside two
385
- * or more of the others. */
386
- export const STACKED_VARIATIONS: { name: VariationName; apply: (sfn: SFn) => SFn | null }[] = [
464
+ /** The statement-order/shape members. */
465
+ const STACKED_SHAPES: { name: VariationName; apply: (sfn: SFn) => SFn | null }[] = [
387
466
  { name: 'initfirst', apply: initFirstGuards },
388
467
  { name: 'pollguard', apply: pollGuards },
389
468
  { name: 'pollread', apply: pollReads },
469
+ ];
470
+
471
+ /** The declaration-width members: one dimension, the width each kind of local is declared at
472
+ * (l3/narrowdecl.ts). A function holding a cast-written local,
473
+ * load-written locals and call-result locals may need any combination of the three kinds narrow
474
+ * while all three fire (the matching suite compiles all eight), so every subset is offered. */
475
+ const STACKED_WIDTHS: { name: VariationName; apply: (sfn: SFn) => SFn | null }[] = [
390
476
  { name: 'narrow-decl', apply: narrowDeclarations },
477
+ { name: 'narrow-load', apply: narrowLoadDeclarations },
391
478
  { name: 'narrow-read', apply: narrowReadDeclarations },
392
479
  ];
393
480
 
481
+ /** The STACKED variations (sanctioned in the POLICY note at rank.ts's respell site): each entry is
482
+ * a statement-order/shape or declaration-width respell variation orthogonal to every respell
483
+ * variation outside this table, derived onto every source. Two members need not commute
484
+ * (`/narrow-decl` and `/narrow-read` compete for one cast, `/narrow-load` and `/narrow-read` for
485
+ * one local), and table order then decides which one takes it in a candidate holding both. Which
486
+ * of them a candidate applies is STACKED_SUBSETS. */
487
+ export const STACKED_VARIATIONS = [...STACKED_SHAPES, ...STACKED_WIDTHS];
488
+
394
489
  /** The PRE-RESPELL variations (sanctioned in the POLICY note at rank.ts's respell site): a tree
395
490
  * rewrite applied BEFORE the respell set, so the whole set derives from its output instead of
396
491
  * composing onto it. Same record type as STACKED_VARIATIONS above, and deliberately so — the only difference is
@@ -455,20 +550,60 @@ export const STACKED_VARIATIONS: { name: VariationName; apply: (sfn: SFn) => SFn
455
550
  * `synthetic:ladidx1`, `synthetic:ladidx2`, `synthetic:revlad5s`. `bench regression` is an
456
551
  * OUTCOME gate over all tiers, so every one of them turns it red — but `benchmark.yml` is
457
552
  * `workflow_dispatch`, manual, with no cron, so nothing runs that gate on a PR. The scores and the
458
- * method are in `apps/benchmark/dataset/synthetic.ts`'s `/unmerge` block. */
459
- export const PRE_RESPELL_VARIATIONS: typeof STACKED_VARIATIONS = [{ name: 'unmerge', apply: unmergeJoins }];
460
-
461
- export const STACKED_SUBSETS: (typeof STACKED_VARIATIONS)[number][][] = [
462
- ...STACKED_VARIATIONS.map((x) => [x]),
463
- ...(STACKED_VARIATIONS.length > 1 ? [STACKED_VARIATIONS] : []),
553
+ * method are in `apps/benchmark/dataset/synthetic.ts`'s `/unmerge` block.
554
+ *
555
+ * `/case-end-goto` (l3/caseendgoto.ts) is the second entry: a search loop's hit flag read where the
556
+ * code after its loop ends the switch arm, rewritten as the case-end goto the source wrote. Here
557
+ * rather than among the stacked members because the rest of the respell set must derive from the
558
+ * goto tree (an `initfirst` or `sinkinit` over a tree that no longer declares the flag), and here
559
+ * rather than as a structure variation for the cost l3/caseendgoto.ts's header gives. Each entry applies to
560
+ * the structured tree on its own, so `/unmerge` and `/case-end-goto` never compose. */
561
+ export const PRE_RESPELL_VARIATIONS: typeof STACKED_VARIATIONS = [
562
+ { name: 'unmerge', apply: unmergeJoins },
563
+ { name: 'case-end-goto', apply: jumpToCaseEnds },
464
564
  ];
465
565
 
566
+ /** Every subset of `xs`, the empty one first, each in `xs`' order. */
567
+ const subsets = <X>(xs: readonly X[]): X[][] =>
568
+ xs.reduce<X[][]>((acc, x) => [...acc, ...acc.map((s) => [...s, x])], [[]]);
569
+
570
+ /** The stacked members a candidate applies: the table's two dimensions crossed, a shape choice
571
+ * (none, each shape alone, or every shape) by a width choice (any subset of the widths, none
572
+ * included), the shapes first. A product, because a function may need a shape AND a strict subset
573
+ * of the widths that fire: `initfirst` beside a narrow cast-written local and wide load-written
574
+ * ones (the matching suite compiles that pair both ways). The shapes are not a lattice of their own:
575
+ * applyStacked's skip-on-decline makes "every shape" each pair of them wherever the third declines,
576
+ * and a row demanding a strict subset of the shapes while all fire is what would earn one. Fewest
577
+ * members first, so where everything else ties the smaller set is enumerated first and wins. */
578
+ export const STACKED_SUBSETS: (typeof STACKED_VARIATIONS)[number][][] = subsets(STACKED_WIDTHS)
579
+ .flatMap((widths) =>
580
+ [[], ...STACKED_SHAPES.map((x) => [x]), ...(STACKED_SHAPES.length > 1 ? [STACKED_SHAPES] : [])].map((shapes) => [
581
+ ...shapes,
582
+ ...widths,
583
+ ]),
584
+ )
585
+ .filter((subset) => subset.length > 0)
586
+ .sort((a, b) => a.length - b.length);
587
+
588
+ /** A stacked member that threw, named by the members that fired before it and then itself: the
589
+ * step that threw, which every subset reaching it shares. Reported under that name rather than
590
+ * the whole subset's, so a consumer deduping on the step prints one broken member once and not
591
+ * once per subset holding it. */
592
+ export class StackedMemberThrew extends Error {
593
+ constructor(
594
+ readonly variations: readonly VariationName[],
595
+ cause: unknown,
596
+ ) {
597
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
598
+ }
599
+ }
600
+
466
601
  /** The subset applied in table order, SKIP-ON-DECLINE: a member that declines contributes
467
602
  * nothing rather than killing the combination — the all-shapes candidate is "everything that
468
603
  * fires", so a pair is reachable whenever the third declines. The variations are the members that
469
- * actually FIRED, so they never name a variation that declined; a fired-set that
470
- * duplicates a smaller subset emits identical source and the dedup collapses it. Null when
471
- * nothing fired. */
604
+ * actually FIRED, so they never name a variation that declined; a fired set that
605
+ * duplicates a smaller subset's derives the same tree, which the caller emits once
606
+ * (`firstFired`). Null when nothing fired. A member that throws throws StackedMemberThrew. */
472
607
  export const applyStacked = (
473
608
  subset: readonly (typeof STACKED_VARIATIONS)[number][],
474
609
  from: SFn,
@@ -476,7 +611,12 @@ export const applyStacked = (
476
611
  let cur = from;
477
612
  const fired: VariationName[] = [];
478
613
  for (const sp of subset) {
479
- const r = sp.apply(cur);
614
+ let r: SFn | null;
615
+ try {
616
+ r = sp.apply(cur);
617
+ } catch (e) {
618
+ throw new StackedMemberThrew([...fired, sp.name], e);
619
+ }
480
620
  if (r) {
481
621
  cur = r;
482
622
  fired.push(sp.name);
@@ -485,6 +625,17 @@ export const applyStacked = (
485
625
  return fired.length > 0 ? { out: cur, variations: fired } : null;
486
626
  };
487
627
 
628
+ /** Whether `fired` is a fired set not yet in `seen`, recording it. Two subsets that fire the same
629
+ * members derive the same tree, so a caller emits only the first. */
630
+ export const firstFired = (seen: Set<string>, fired: readonly VariationName[]): boolean => {
631
+ const key = fired.join('/');
632
+ if (seen.has(key)) {
633
+ return false;
634
+ }
635
+ seen.add(key);
636
+ return true;
637
+ };
638
+
488
639
  /** The locals a variation added — a NAME diff rather than a positional slice, so a pass that ever
489
640
  * reorders locals cannot silently empty the set. It is what scopes `/volatile` to the pointers
490
641
  * the variation itself created (volatilePtrLocals' `only`), leaving the tree's own locals alone. */