@asmlift/core 0.6.0 → 0.8.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.
- package/README.md +48 -24
- package/package.json +1 -1
- package/src/backend/cfamily.ts +39 -11
- package/src/backend/pascal.ts +2 -2
- package/src/codegen-flags.ts +640 -0
- package/src/contracts.ts +60 -11
- package/src/frontend/disasm.ts +141 -11
- package/src/frontend/high-half.ts +149 -0
- package/src/frontend/mips.ts +458 -209
- package/src/frontend/ppc.ts +332 -67
- package/src/frontend/reloc-symbol.ts +109 -0
- package/src/frontend/splat.ts +56 -18
- package/src/frontend/ssa.ts +127 -30
- package/src/frontend/stackargs.ts +420 -0
- package/src/frontend/thumb.ts +209 -232
- package/src/ir/alias.ts +24 -0
- package/src/ir/core.ts +70 -3
- package/src/ir/opcodes.ts +52 -7
- package/src/ir/parse.ts +7 -1
- package/src/ir/simplify.ts +1 -1
- package/src/l3/address.ts +2 -2
- package/src/l3/advance.ts +373 -0
- package/src/l3/argbase.ts +6 -6
- package/src/l3/argcopy.ts +269 -0
- package/src/l3/ast.ts +110 -22
- package/src/l3/basecse.ts +50 -30
- package/src/l3/coalesce.ts +118 -61
- package/src/l3/gates.ts +75 -1
- package/src/l3/hoist.ts +1 -1
- package/src/l3/homesplit.ts +13 -13
- package/src/l3/initfirst.ts +3 -3
- package/src/l3/inlinebase.ts +16 -16
- package/src/l3/mentions.ts +68 -5
- package/src/l3/mulfirst.ts +3 -3
- package/src/l3/nearbase.ts +4 -4
- package/src/l3/offmember.ts +5 -5
- package/src/l3/parkfirst.ts +6 -6
- package/src/l3/pollguard.ts +3 -3
- package/src/l3/ptrfield.ts +4 -4
- package/src/l3/regspell.ts +8 -8
- package/src/l3/reindex.ts +22 -17
- package/src/l3/scopebase.ts +32 -29
- package/src/l3/sinkinit.ts +7 -7
- package/src/l3/slotorder.ts +3 -3
- package/src/l3/storage.ts +1 -1
- package/src/l3/tailmerge.ts +2 -2
- package/src/l3/tailret.ts +70 -0
- package/src/l3/typing.ts +3 -3
- package/src/l3/unmerge.ts +483 -59
- package/src/l3/unreduce.ts +15 -14
- package/src/l3/volatileptr.ts +11 -11
- package/src/l3/volatileval.ts +11 -11
- package/src/l3/volstore.ts +16 -16
- package/src/l3/zerosub.ts +6 -6
- package/src/mangle.ts +49 -0
- package/src/pattern/engine.ts +132 -17
- package/src/pipeline.ts +39 -16
- package/src/proto.ts +2 -2
- package/src/raise/const.ts +203 -3
- package/src/raise/divpow2.ts +2 -2
- package/src/raise/extscale.ts +345 -0
- package/src/raise/globalshape.ts +32 -12
- package/src/raise/gvn.ts +2 -2
- package/src/raise/magicdiv.ts +2 -2
- package/src/raise/memberarrays.ts +4 -4
- package/src/raise/narrowlocal.ts +18 -2
- package/src/raise/paramwidth.ts +133 -3
- package/src/raise/pre-recovery.ts +100 -25
- package/src/raise/retsink.ts +389 -19
- package/src/raise/shortcircuit.ts +595 -34
- package/src/raise/structs.ts +4 -4
- package/src/raise/tailsink.ts +141 -0
- package/src/rank-declare.ts +21 -13
- package/src/{rank-axes.ts → rank-variations.ts} +319 -189
- package/src/rank.ts +1176 -805
- package/src/structure/analysis.ts +87 -90
- package/src/structure/bitfields.ts +130 -30
- package/src/structure/globalaccess.ts +30 -4
- package/src/structure/namecoalesce.ts +32 -13
- package/src/structure/retspell.ts +95 -0
- package/src/structure/structure.ts +1425 -201
- package/src/structure/switch-recover.ts +101 -8
- package/src/symbols.ts +127 -6
- package/src/target.ts +374 -44
- package/src/trace.ts +28 -19
- package/src/variation-definitions.ts +1590 -0
- package/src/variation-gates.ts +92 -0
- package/src/variation-tokens.ts +356 -0
package/src/l3/basecse.ts
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
// question the source answered per BASE — one register file spelled as a pointer local beside
|
|
14
14
|
// scalar cells spelled as bare derefs. The `single-cell` gate is what makes the narrower answer
|
|
15
15
|
// reachable: under `LIVEBASE_BLOCK_GATES` a base every access of which is ONE fixed offset stays
|
|
16
|
-
// inline, and rank's
|
|
16
|
+
// inline, and rank's LIVEBASE_HOISTS roster emits each table's hoist — and every composition onto
|
|
17
17
|
// it — as its own candidate family, for the differ to referee between them. The unit is
|
|
18
18
|
// the (base, width, signedness) KEY, not the base — a base read at two widths is two keys, and the
|
|
19
19
|
// gate can leave one of them inline while the other binds.
|
|
20
20
|
//
|
|
21
21
|
// COVERAGE: the roster (rank.ts) is SEVEN rows over FIVE gate tables — two PAIRS share a table and
|
|
22
22
|
// differ only in placement, `/basefold` with `/basefold/sinkinit` and `/orderbase` with
|
|
23
|
-
// `/orderbase
|
|
23
|
+
// `/orderbase-scoped` — and it is a set of hand-picked SUBSETS rather than a narrowness ranking;
|
|
24
24
|
// only `/livebase` ⊇ `/livebase-block` are ordered by inclusion. A table
|
|
25
25
|
// whose predicate cuts across the others therefore carves out a PARTIAL answer, which is what
|
|
26
26
|
// `UNFOLDED_GATES` does. Measured at ONE stated scope, `decompile()`'s default structuring,
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
// and `ldrb r0, [r4, #0x3]` — agbcc CSEs the symbol reference where it re-materializes the integer
|
|
62
62
|
// — so the inline subscript spelling produces exactly the shape this rule reads as evidence
|
|
63
63
|
// against it. asmlift lifts that asm back to the correct `((u8 *)&gS)[3]` and then offers the
|
|
64
|
-
// named
|
|
64
|
+
// spelling through a named base anyway. Measured reach: of the 21 keys the symbol half newly admits over
|
|
65
65
|
// the artifact's agbcc rows in both symbol-map configurations, 4 are on a base whose address the
|
|
66
66
|
// tree also uses as a value (2 distinct keys, on `kleod:ProcessInputAndUpdateEntities` and
|
|
67
67
|
// `pokeemerald:TrySetCantSelectMoveBattleScript`).
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
// `synthetic:foldhead` its match — which is why `index.operandOff` is carried from the lift
|
|
75
75
|
// instead of re-derived, and why a committed pass that can drop it is worth a test
|
|
76
76
|
// (test/basecse.test.ts, the "`operandOff` is provenance" describe). WHAT EACH ROW IS WORTH is
|
|
77
|
-
// measured in rank.ts's note on `
|
|
77
|
+
// measured in rank.ts's note on `BASEFOLD_HOISTS`, not here, and the two rows are not worth
|
|
78
78
|
// the same thing. Promoting the hint to a default would need this paragraph to say something it
|
|
79
79
|
// does not.
|
|
80
80
|
//
|
|
81
|
-
// It is EVIDENCE and not proof, which is why `BASEFOLD_GATES` below
|
|
81
|
+
// It is EVIDENCE and not proof, which is why `BASEFOLD_GATES` below backs a variation rather than a
|
|
82
82
|
// relaxation of the default table. agbcc folds a subscript but keeps an aggregate MEMBER offset in
|
|
83
83
|
// the memory operand: `((struct S *)0x3001100)->b` emits `.word 0x3001100` + `ldr [r0, #0x4]`,
|
|
84
|
-
// byte-identical to the named
|
|
84
|
+
// byte-identical to the spelling through a named base, and the same holds for a union member and for a
|
|
85
85
|
// store. So the shape has two sources and asmlift can spell only one of them; rank.ts offers both
|
|
86
86
|
// and the differ referees.
|
|
87
87
|
//
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
// AGGREGATE base (F9 spells a SCALAR global as a bare `var`, which is never an `index`-of-leaf, so
|
|
98
98
|
// scalar recovery is untouched). Non-leaf bases (a local, a struct-element `p[a0]`,
|
|
99
99
|
// arithmetic) are excluded: agbcc may re-derive those, so hoisting them can
|
|
100
|
-
// MISMATCH (empirically confirmed) — the differ-refereed `/addr-home`
|
|
100
|
+
// MISMATCH (empirically confirmed) — the differ-refereed `/addr-home` variation
|
|
101
101
|
// (structure/analysis.ts homeSharedAddresses) serves the shared gaddr-free ARITHMETIC bases
|
|
102
102
|
// instead.
|
|
103
103
|
// The hoisted local carries the access's pointer type, so the
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
import { assertHoistsDominate } from '../contracts';
|
|
109
109
|
import { type IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
|
|
110
110
|
import type { Expr, SFn, Stmt } from './ast';
|
|
111
|
-
import { exprChildren, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
111
|
+
import { exprChildren, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
112
112
|
import { type Gate, ablateHeuristic, firstRejection } from './gates';
|
|
113
113
|
import { type BaseInit, type HoistPlacement, nameAllocator, placeBaseLocals } from './hoist';
|
|
114
114
|
|
|
@@ -149,8 +149,8 @@ const keyOf = (base: HoistableBase, width: number, signed: boolean): string => `
|
|
|
149
149
|
/** The key's own grammar, read back — `<leafId>[ <type>] <width> <signed>`.
|
|
150
150
|
*
|
|
151
151
|
* IT LIVES BESIDE `keyOf` BECAUSE THAT IS THE ONLY THING THAT MAKES IT SAFE. The key is a string
|
|
152
|
-
* and its readers are elsewhere — `l3/homesplit.ts` builds a candidate
|
|
153
|
-
*
|
|
152
|
+
* and its readers are elsewhere — `l3/homesplit.ts` builds a candidate's VARIATION out of it, and a
|
|
153
|
+
* candidate's variations are its identity — so a second file knowing this grammar is a collision waiting
|
|
154
154
|
* for the next base kind (`homeSplitTag` states the one the cast form causes).
|
|
155
155
|
*
|
|
156
156
|
* The one space inside a cast's base id is this grammar's own separator, not the type's: every
|
|
@@ -240,7 +240,7 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
|
|
|
240
240
|
for (const s of stmts) {
|
|
241
241
|
// A loop's OWN condition (`stmtExprs` of a while/do-while/for) runs every iteration, so a base
|
|
242
242
|
// there is loop-invariant just like a body use — visit it with `nested`, not the outer flag.
|
|
243
|
-
const nested = loop || s
|
|
243
|
+
const nested = loop || isLoop(s);
|
|
244
244
|
for (const e of stmtExprs(s)) {
|
|
245
245
|
visitExpr(e, nested);
|
|
246
246
|
}
|
|
@@ -357,7 +357,7 @@ export interface BaseKey {
|
|
|
357
357
|
* what a pointer local's own initializer STATEMENT produces (see raise/globalshape.ts's header
|
|
358
358
|
* for the compile that separates the two), while the inline cast produces the other order — so
|
|
359
359
|
* it is evidence a home is what the source wrote. Read only by `ORDERBASE_GATES` (rank.ts);
|
|
360
|
-
* false for every base a compiler that has not opted in produced, which is what keeps the
|
|
360
|
+
* false for every base a compiler that has not opted in produced, which is what keeps the variation
|
|
361
361
|
* off those targets. */
|
|
362
362
|
orderLicensed: boolean;
|
|
363
363
|
}
|
|
@@ -380,13 +380,13 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
|
|
|
380
380
|
// Censused at `decompile()`'s default structuring, map-less, one tree per row over the
|
|
381
381
|
// artifact's 404 agbcc rows: `ORDERBASE_GATES` admits 12 keys on 10 rows, 11 of them cast keys.
|
|
382
382
|
id: 'cast-base',
|
|
383
|
-
why: 'a struct element’s reinterpret cast is the inline spelling unless the assembly says the base
|
|
383
|
+
why: 'a struct element’s reinterpret cast is the inline spelling unless the assembly says the base was held in a local',
|
|
384
384
|
sound: false,
|
|
385
385
|
rejects: (c) => c.castBase,
|
|
386
386
|
},
|
|
387
387
|
{
|
|
388
388
|
id: 'single-use',
|
|
389
|
-
why: '
|
|
389
|
+
why: 'a base accessed once is as cheap to load again as to hold in a named local',
|
|
390
390
|
sound: false,
|
|
391
391
|
rejects: reachedOnce,
|
|
392
392
|
},
|
|
@@ -397,8 +397,27 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
|
|
|
397
397
|
rejects: (c) => c.inLoop,
|
|
398
398
|
},
|
|
399
399
|
{
|
|
400
|
+
// A KNOWN COUNTEREXAMPLE, recorded here rather than fixed. `LIVEBASE_GATES` already names it —
|
|
401
|
+
// an MMIO wait or poll stores and re-reads ONE fixed offset through ONE register the whole
|
|
402
|
+
// time — so this gate rejects a base the target demonstrably held in a register, and the row
|
|
403
|
+
// it costs is carried by `/livebase`, which is this table minus the placement gates.
|
|
404
|
+
//
|
|
405
|
+
// NOT FIXED BY EXCLUDING A VALUELESS READ FROM THE CENSUS: that hides a real access to make
|
|
406
|
+
// the gate right for the wrong reason — the premise is falsified by the function's assembly,
|
|
407
|
+
// not by how the access is spelled. NOT FIXED BY EXEMPTING DEVICE-REGISTER BASES EITHER: that
|
|
408
|
+
// is the sound rule, and its blast radius is every MMIO row in the corpus against a gate this
|
|
409
|
+
// comment records was bought with a real match. It wants its own round and its own zero-flip
|
|
410
|
+
// gate.
|
|
411
|
+
//
|
|
412
|
+
// THE STRUCTURER'S DEAD-READ SPELLING (structure.ts `unreadResult`) INTERACTS, but not on the
|
|
413
|
+
// map-fed default: that spelling requires a qualifier to reach the access, a CAST spelling
|
|
414
|
+
// (`((s32 *)®_DMA3SAD)[2]`) carries none, and a map-fed DMA tree spells it exactly that
|
|
415
|
+
// way — so no statement is emitted there, offset 8 is touched once, and the base local
|
|
416
|
+
// survives. The inhabitant is the `/raw-globals` subtree, where the read IS spelled and this
|
|
417
|
+
// gate DOES demote; that subtree's own winner is a `/livebase` candidate, so nothing on the
|
|
418
|
+
// ranked path loses by it. Stated because the demotion is invisible from either file alone.
|
|
400
419
|
id: 'repeated-const-offset',
|
|
401
|
-
why: 'a fixed offset
|
|
420
|
+
why: 'a fixed offset read and then written is one scalar update, and the compiler loads its address again for it',
|
|
402
421
|
sound: false,
|
|
403
422
|
rejects: (c) => c.repeatedConstOffset,
|
|
404
423
|
},
|
|
@@ -427,14 +446,14 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
|
|
|
427
446
|
export const BASEFOLD_GATES: readonly Gate<BaseKey>[] = [
|
|
428
447
|
{
|
|
429
448
|
id: 'single-use-unfolded',
|
|
430
|
-
why: '
|
|
449
|
+
why: 'a base accessed once is as cheap to load again as to hold in a named local, unless its offset survived the fold',
|
|
431
450
|
sound: false,
|
|
432
451
|
rejects: (c) => reachedOnce(c) && !c.unfoldedOffset,
|
|
433
452
|
},
|
|
434
453
|
...ablateHeuristic(BASECSE_GATES, 'single-use'),
|
|
435
454
|
];
|
|
436
455
|
|
|
437
|
-
/** The `/livebase`
|
|
456
|
+
/** The `/livebase` variation's admission (rank.ts): the default rules with both PLACEMENT heuristics
|
|
438
457
|
* ablated, keeping only `single-use`. `loop` and `repeated-const-offset` predict which spelling
|
|
439
458
|
* the compiler chose, and both predictions have a counterexample — an MMIO poll (`p[2] = go;
|
|
440
459
|
* while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register the whole
|
|
@@ -447,7 +466,7 @@ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
|
|
|
447
466
|
|
|
448
467
|
/** `/livebase-block`'s admission (rank.ts): `/livebase` plus `single-cell`. The two tables differ
|
|
449
468
|
* by exactly one gate, so `without(LIVEBASE_BLOCK_GATES, 'single-cell')` is `/livebase`'s own
|
|
450
|
-
* admission and this selectivity
|
|
469
|
+
* admission and this selectivity rule prices by ablation like every other.
|
|
451
470
|
*
|
|
452
471
|
* `single-cell` GENERATES a narrower candidate; it does not classify, and taking it for a compiler
|
|
453
472
|
* fact is the way to misuse it. Its counterexample is in this corpus: `synthetic:sizebound`'s
|
|
@@ -470,13 +489,13 @@ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
|
|
|
470
489
|
* whenever an admission is added to or removed from the roster.
|
|
471
490
|
* HOW: prefer the edit-free form — import this array and `splice` the gate out of it before the
|
|
472
491
|
* first `enumerateCandidates` call, since the roster holds a reference to this very object. The
|
|
473
|
-
* env-read recipe on
|
|
492
|
+
* env-read recipe on BASEFOLD_HOISTS edits files instead, and a tap reverted underneath a
|
|
474
493
|
* running process reports ZEROES rather than crashing, which reads exactly like "the rule never
|
|
475
494
|
* fires"; if you use it, hash the tree either side of the window and quote both hashes.
|
|
476
495
|
*
|
|
477
|
-
* A CENSUS OVER
|
|
496
|
+
* A CENSUS OVER WINNERS' VARIATIONS CANNOT STAND IN FOR THAT — "only a row whose winner carries
|
|
478
497
|
* `/livebase-block` can move" is unsound for the reason rank.ts's `seen` dedup spells out. This
|
|
479
|
-
* table's own
|
|
498
|
+
* table's own census over winners' variations reads 5 rows and read 7 before `/unfolded` shipped, and the
|
|
480
499
|
* two that left differ: `synthetic:foldpark` by RENAME (byte-identical source, MATCH either
|
|
481
500
|
* side), `synthetic:unfoldpark` because its winning SPELLING changed, 402 bytes at diff:9 to 397
|
|
482
501
|
* at MATCH.
|
|
@@ -597,7 +616,8 @@ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
|
|
|
597
616
|
* reading the population: where `raise/globalshape.ts` shapes a name the structurer usually spells
|
|
598
617
|
* it bare and no key exists here at all — but a shape is ONE element type for the whole name, so an
|
|
599
618
|
* access that strides something else keeps its cast and its key. `kleod:SetupBG3WindowOverlay`'s
|
|
600
|
-
* `gBgInfo`
|
|
619
|
+
* `gBgInfo` (a row retired 2026-09-13) derived `elemSize 4` and still reached this table at stride
|
|
620
|
+
* 28, in both arms.
|
|
601
621
|
*
|
|
602
622
|
* What this table admits, censused over the artifact's 370 agbcc rows: map-less 8 rows / 10 keys,
|
|
603
623
|
* map-ful 10 rows / 12 keys. TWO shapes, and the arms differ:
|
|
@@ -624,8 +644,8 @@ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
|
|
|
624
644
|
* exemption cannot have.
|
|
625
645
|
*
|
|
626
646
|
* `loop` and `repeated-const-offset` STAY. Neither is about the base's identity and both are fan
|
|
627
|
-
* control; ablating them is `/livebase`'s
|
|
628
|
-
*
|
|
647
|
+
* control; ablating them is `/livebase`'s variation, already on the roster, and a row that wants the
|
|
648
|
+
* pairing is one hoist. THE PRICE OF THAT IS A HOLE, and it is named rather than left for a
|
|
629
649
|
* reader to find: a licensed base with a use inside a loop is admitted by NO table on the roster —
|
|
630
650
|
* this one refuses it on `loop`, and every table that ablates `loop` refuses it on `cast-base` or
|
|
631
651
|
* `single-use` — which is the "a base set that is no row's stays unreachable" debt
|
|
@@ -652,7 +672,7 @@ export const ORDERBASE_GATES: readonly Gate<BaseKey>[] = [
|
|
|
652
672
|
...ablateHeuristic(ablateHeuristic(BASECSE_GATES, 'cast-base'), 'single-use'),
|
|
653
673
|
{
|
|
654
674
|
id: 'order-licensed',
|
|
655
|
-
why: 'nothing in the assembly says this base
|
|
675
|
+
why: 'nothing in the assembly says this base was held in a local: the index was scaled first, or the order says nothing',
|
|
656
676
|
sound: false,
|
|
657
677
|
rejects: (c) => !c.orderLicensed,
|
|
658
678
|
},
|
|
@@ -718,13 +738,13 @@ function admit(sfn: SFn, gates: readonly Gate<BaseKey>[]): { c: Collected; keys:
|
|
|
718
738
|
* `scope` DECLINES, and the overload is how a caller is told: `null` means the placement had
|
|
719
739
|
* nothing to say about this function, because no init landed inside a nested list. That tree is
|
|
720
740
|
* byte-for-byte the `first-use` spelling (l3/hoist.ts's `nested`), and the roster withholds the
|
|
721
|
-
* `first-use` row for this table deliberately (rank.ts,
|
|
741
|
+
* `first-use` row for this table deliberately (rank.ts, ORDERBASE_HOISTS) — so returning it
|
|
722
742
|
* ships the withheld candidate under the scoped row's name.
|
|
723
743
|
*
|
|
724
744
|
* IT WITHDRAWS A SPELLING RATHER THAN COLLAPSING A DUPLICATE, which is what the decline costs.
|
|
725
|
-
* `
|
|
745
|
+
* `ORDERBASE_HOISTS` holds exactly two rows, `head` and `scope`, so nothing is ever enumerated
|
|
726
746
|
* at `first-use` for this table and the refused tree has no twin to fold into — its shape and
|
|
727
|
-
* `/volatile`
|
|
747
|
+
* `/volatile` compositions go with it. Over each project's whole `asm` tree, map-ful: of the 48
|
|
728
748
|
* functions `ORDERBASE_GATES` admits, 7 place an init inside a nested list and 41 do not, and for
|
|
729
749
|
* 29 of the 41 the refused spelling is one the `head` row does not already produce. Instrumented
|
|
730
750
|
* on two of those, both map-ful — the `kleod:StreamCmd_SetBGScroll` row (fan 11), and
|
|
@@ -785,13 +805,13 @@ export function hoistBaseLocals(
|
|
|
785
805
|
}
|
|
786
806
|
const out = { ...sfn, body, locals };
|
|
787
807
|
// The two FLAT placements can only put the run in the top-level list, above every use of it by
|
|
788
|
-
// construction. `scope` puts an init inside a nested list, which is where a placing
|
|
808
|
+
// construction. `scope` puts an init inside a nested list, which is where a placing variation can ship
|
|
789
809
|
// the one failure the byte differ rewards — a read of a local whose assignment does not reach it —
|
|
790
810
|
// so the tree it emits is checked rather than argued (contracts.ts).
|
|
791
811
|
//
|
|
792
812
|
// THE POPULATION IS THE MOTION, and `moved` is what the placer says it moved rather than what this
|
|
793
813
|
// function minted. The leading run this pass inherits is the DEFAULT hoist's, committed by
|
|
794
|
-
// `structureChecked` before rank's
|
|
814
|
+
// `structureChecked` before rank's variations see the tree (pipeline.ts), and `scope` moves those
|
|
795
815
|
// inits too — so `newLocals` names less than half of what has to be judged. Real inhabitants, in the
|
|
796
816
|
// CHECKOUTS rather than in a benchmark row — `DecompressAndLoadLevel` in klonoa and `sub_8052474`
|
|
797
817
|
// in sa3, both map-ful — each sink one inherited `p0` beside the minted `p1`.
|
package/src/l3/coalesce.ts
CHANGED
|
@@ -6,18 +6,15 @@
|
|
|
6
6
|
// TWO admission paths live here, each with its own gate table and its own reading of loops. The
|
|
7
7
|
// SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
|
|
8
8
|
// loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
|
|
9
|
-
// ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one
|
|
10
|
-
// between them
|
|
11
|
-
// argument however the arms' own loops relate
|
|
9
|
+
// ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one BRANCH picks
|
|
10
|
+
// between them — an `if`'s two arms or a `switch`'s case bodies alike — so it asks only whether ANY
|
|
11
|
+
// loop encloses that branch (a second entry breaks the argument however the arms' own loops relate)
|
|
12
|
+
// and whether fall-through joins the two arms onto one path. `coalesceCandidates` offers both.
|
|
12
13
|
import { typeToString } from '../ir/types';
|
|
13
14
|
import type { Expr, SFn, Stmt } from './ast';
|
|
14
|
-
import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
15
|
+
import { exprChildren, isLoop, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
15
16
|
import { type Gate, firstRejection } from './gates';
|
|
16
17
|
|
|
17
|
-
/** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
|
|
18
|
-
* walk and the arm path's `visit`. */
|
|
19
|
-
const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
|
|
20
|
-
|
|
21
18
|
function namesIn(e: Expr, out: Set<string>): void {
|
|
22
19
|
// `addr` names a GLOBAL (`&gSym`) or a LOCAL — the structurer renders an `laddr` frame object
|
|
23
20
|
// as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
|
|
@@ -217,7 +214,7 @@ export interface MergePair {
|
|
|
217
214
|
* `const-fed` is what keeps three of them (ablate it and the span path offers 273). A rule
|
|
218
215
|
* refusing every in-loop local would make that bound redundant; this one does not, so any further
|
|
219
216
|
* relaxation of `const-fed` is a multiplier, and two call sites pay it (`/coalesce` and
|
|
220
|
-
* `/scopebase
|
|
217
|
+
* `/scopebase/coalesce`; the `/livebase` pairings enumerate the ARM path and pay
|
|
221
218
|
* ARM_DISJOINT_GATES' `arm-init` instead). */
|
|
222
219
|
export const COALESCE_GATES: readonly Gate<MergePair>[] = [
|
|
223
220
|
{
|
|
@@ -234,7 +231,7 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
|
|
|
234
231
|
},
|
|
235
232
|
{
|
|
236
233
|
id: 'volatile',
|
|
237
|
-
why: 'a volatile qualifier
|
|
234
|
+
why: 'a `volatile` qualifier, on the variable or on what it points to, is observable, and merging would drop or add it',
|
|
238
235
|
sound: true,
|
|
239
236
|
guardedBy: 'coalesce.test.ts: a volatile pair never merges',
|
|
240
237
|
rejects: (c) => c.eitherIsVolatile,
|
|
@@ -252,7 +249,7 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
|
|
|
252
249
|
},
|
|
253
250
|
{
|
|
254
251
|
id: 'const-fed',
|
|
255
|
-
why: 'a
|
|
252
|
+
why: 'a local set from a memory load, other than a `for` loop counter, is one the compiler had a reason to keep where it was',
|
|
256
253
|
sound: false,
|
|
257
254
|
rejects: (c) => !c.x.constFed || !c.y.constFed,
|
|
258
255
|
},
|
|
@@ -265,20 +262,20 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
|
|
|
265
262
|
},
|
|
266
263
|
{
|
|
267
264
|
id: 'first-is-write',
|
|
268
|
-
why: 'a survivor first
|
|
265
|
+
why: 'a survivor whose first mention is a read would see the absorbed value there',
|
|
269
266
|
sound: false,
|
|
270
267
|
rejects: (c) => !c.y.firstIsWrite,
|
|
271
268
|
},
|
|
272
269
|
];
|
|
273
270
|
|
|
274
|
-
/** Every legal single merge, each as its own tree — NOT one committed
|
|
271
|
+
/** Every legal single merge, each as its own tree — NOT one committed decision.
|
|
275
272
|
*
|
|
276
273
|
* Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
|
|
277
274
|
* it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
|
|
278
275
|
* and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
|
|
279
276
|
* and declaration order is the one that picks it. Emitting no merges at all costs that row its
|
|
280
277
|
* match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
|
|
281
|
-
* `/regcopy`'s "the tail
|
|
278
|
+
* `/regcopy`'s "the tail decision is allocator-ambiguous, so both are ranked" — so every candidate is
|
|
282
279
|
* emitted and the differ referees.
|
|
283
280
|
*
|
|
284
281
|
* ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
|
|
@@ -325,8 +322,11 @@ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SF
|
|
|
325
322
|
export interface ArmPair {
|
|
326
323
|
a: string;
|
|
327
324
|
b: string;
|
|
328
|
-
/** the confining
|
|
325
|
+
/** the confining branch has a loop ancestor, so it can run more than once */
|
|
329
326
|
ifInLoop: boolean;
|
|
327
|
+
/** control can run from one of the two arms INTO the other — `switch` fall-through, the one
|
|
328
|
+
* way two arms of a branch land on a single path. Always false for an `if`. */
|
|
329
|
+
armsJoined: boolean;
|
|
330
330
|
sameType: boolean;
|
|
331
331
|
/** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
|
|
332
332
|
eitherIsVolatile: boolean;
|
|
@@ -341,12 +341,13 @@ export interface ArmPair {
|
|
|
341
341
|
* different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
|
|
342
342
|
* so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
|
|
343
343
|
* there. Two locals confined to
|
|
344
|
-
*
|
|
345
|
-
* can observe the other's write — no liveness reasoning needed.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
344
|
+
* DIFFERENT arms of one branch never coexist at runtime: the branch picks one arm, so no read of
|
|
345
|
+
* either can observe the other's write — no liveness reasoning needed. `branchArms` says what an
|
|
346
|
+
* arm is, and the argument holds for a `switch`'s case bodies exactly as it does for an `if`'s two.
|
|
347
|
+
*
|
|
348
|
+
* Two gates protect it, one per way it fails. `loop` covers a SECOND ENTRY, which is why it wants
|
|
349
|
+
* ANY enclosing loop rather than the span model's shared-loop rule: never-coexisting is a claim
|
|
350
|
+
* about one entry. `fall-through` covers the failure WITHIN one entry. */
|
|
350
351
|
export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
351
352
|
{
|
|
352
353
|
id: 'type',
|
|
@@ -356,21 +357,28 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
|
356
357
|
},
|
|
357
358
|
{
|
|
358
359
|
id: 'volatile',
|
|
359
|
-
why: 'a volatile qualifier
|
|
360
|
+
why: 'a `volatile` qualifier, on the variable or on what it points to, is observable, and merging would drop or add it',
|
|
360
361
|
sound: true,
|
|
361
362
|
guardedBy: 'coalesce.test.ts: a volatile pair never merges',
|
|
362
363
|
rejects: (c) => c.eitherIsVolatile,
|
|
363
364
|
},
|
|
364
365
|
{
|
|
365
366
|
id: 'loop',
|
|
366
|
-
why: 'a loop ancestor re-enters the
|
|
367
|
+
why: 'a loop ancestor re-enters the branch, so different arms both run and a value could cross',
|
|
367
368
|
sound: true,
|
|
368
|
-
guardedBy: 'coalesce.test.ts:
|
|
369
|
+
guardedBy: 'coalesce.test.ts: never admits its arm pair',
|
|
369
370
|
rejects: (c) => c.ifInLoop,
|
|
370
371
|
},
|
|
372
|
+
{
|
|
373
|
+
id: 'fall-through',
|
|
374
|
+
why: 'a case that runs on into the other arm puts both locals on one path, so they coexist',
|
|
375
|
+
sound: true,
|
|
376
|
+
guardedBy: 'coalesce.test.ts: two arms joined by fall-through never merge',
|
|
377
|
+
rejects: (c) => c.armsJoined,
|
|
378
|
+
},
|
|
371
379
|
{
|
|
372
380
|
id: 'arm-init',
|
|
373
|
-
why: 'a local not
|
|
381
|
+
why: 'a local its arm does not first set to a constant is one the compiler had a reason to keep apart',
|
|
374
382
|
sound: false,
|
|
375
383
|
rejects: (c) => !c.bothArmConstInit,
|
|
376
384
|
},
|
|
@@ -378,7 +386,7 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
|
378
386
|
|
|
379
387
|
/** The arm-disjoint merges alone — the class the livebase pairings enumerate (rank.ts): the
|
|
380
388
|
* demanding row's shared counter is arm-disjoint, and the span-model merges already ride the
|
|
381
|
-
* plain /coalesce
|
|
389
|
+
* plain /coalesce variation, so pairing them too would multiply candidates with no row behind it. */
|
|
382
390
|
export function armDisjointCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
|
|
383
391
|
return armDisjointUnder(ARM_DISJOINT_GATES, sfn).candidates;
|
|
384
392
|
}
|
|
@@ -477,6 +485,44 @@ function mentionIndex(): {
|
|
|
477
485
|
return { mentionsOf, mentionsUnder, firstMention };
|
|
478
486
|
}
|
|
479
487
|
|
|
488
|
+
/** The MUTUALLY EXCLUSIVE arms of a branching statement, and which pairs of them control can
|
|
489
|
+
* nevertheless run through together — the one shape both admission sites read, so `if` and
|
|
490
|
+
* `switch` are one rule here rather than two walkers.
|
|
491
|
+
*
|
|
492
|
+
* An `if` has exactly two arms and no way to reach one from the other. A `switch`'s arms are its
|
|
493
|
+
* `case` bodies, and FALL-THROUGH is the one way two of them land on a single path: arm `i` runs
|
|
494
|
+
* into arm `j` exactly when every arm from `i` up to `j` falls through, so the reach is the
|
|
495
|
+
* TRANSITIVE chain and not merely the adjacent pair.
|
|
496
|
+
*
|
|
497
|
+
* ITS REACH, MEASURED, because a soundness argument with no corpus witness should say so. Counted
|
|
498
|
+
* 2026-09-19 with a throwaway counter in this walk, over `pnpm bench sweep --fan --map-modes
|
|
499
|
+
* harness,nomap` — 2,396 records over the 1,198-row corpus: the switch half ADMITS 192 pairs, all
|
|
500
|
+
* on `pokeemerald:SetMauvilleOldManLanguage:agbcc` and none on any other function. Across all five
|
|
501
|
+
* gates, `arm-init` refuses 4,024 and `type` 1,346, while `volatile`, `loop` and `fall-through`
|
|
502
|
+
* refuse **zero** — so what bounds this extension corpus-wide is a COST gate, while both SOUND
|
|
503
|
+
* rules are witnessed only by the fixtures in `coalesce.test.ts`. Re-take the count rather than
|
|
504
|
+
* quoting it: a corpus that grows falsifies the number, not the argument.
|
|
505
|
+
*
|
|
506
|
+
* A `switch`'s `default` body is deliberately NOT an arm. `defaultAt` may place the label between
|
|
507
|
+
* case labels, where the body is reachable both by dispatch and by running on into the arm below
|
|
508
|
+
* it — a path `fallsThrough` does not describe, because the flag indexes the `cases` array the
|
|
509
|
+
* label does not sit in. Rather than reason about a position this file cannot see, no pair
|
|
510
|
+
* involving the default is offered: a merge withheld is a candidate missed, a merge admitted on a
|
|
511
|
+
* path that exists is a wrong answer. */
|
|
512
|
+
const branchArms = (st: Stmt): { arms: Stmt[][]; joined: (i: number, j: number) => boolean } | null => {
|
|
513
|
+
if (st.k === 'if' && st.then.length && st.else.length) {
|
|
514
|
+
return { arms: [st.then, st.else], joined: () => false };
|
|
515
|
+
}
|
|
516
|
+
if (st.k === 'switch') {
|
|
517
|
+
const { cases } = st;
|
|
518
|
+
return {
|
|
519
|
+
arms: cases.map((c) => c.body),
|
|
520
|
+
joined: (i, j) => cases.slice(Math.min(i, j), Math.max(i, j)).every((c) => c.fallsThrough),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
};
|
|
525
|
+
|
|
480
526
|
/** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
|
|
481
527
|
* same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
|
|
482
528
|
export function armDisjointUnder(
|
|
@@ -498,44 +544,55 @@ export function armDisjointUnder(
|
|
|
498
544
|
const l = locals.get(n);
|
|
499
545
|
return l !== undefined && isVolatileLocal(l);
|
|
500
546
|
};
|
|
547
|
+
// locals only, and never a name that is ALSO a param — the span path holds the same belief as a
|
|
548
|
+
// gate, and a local shadowing a param would let rename() rewrite the param's own mentions
|
|
549
|
+
const confined = (m: Map<string, number>): string[] =>
|
|
550
|
+
[...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
|
|
501
551
|
const visit = (stmts: Stmt[], inLoop: boolean): void => {
|
|
502
552
|
for (const st of stmts) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
553
|
+
const branch = branchArms(st);
|
|
554
|
+
if (branch !== null) {
|
|
555
|
+
const { arms, joined } = branch;
|
|
556
|
+
const confinedIn = arms.map((body) => confined(mentionsOf(body)));
|
|
557
|
+
for (let i = 0; i < arms.length; i++) {
|
|
558
|
+
for (let j = i + 1; j < arms.length; j++) {
|
|
559
|
+
for (const a of confinedIn[i]) {
|
|
560
|
+
for (const b of confinedIn[j]) {
|
|
561
|
+
// the survivor is the earlier declaration, matching how a shared source local
|
|
562
|
+
// reads.
|
|
563
|
+
//
|
|
564
|
+
// THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into
|
|
565
|
+
// the target's frame order at EMIT time (l3/slotorder.ts), after this pass, so
|
|
566
|
+
// `declIdx` is the naming walk's order and the choice means "the earlier
|
|
567
|
+
// declaration in the source asmlift recovered". Ordering the list any earlier
|
|
568
|
+
// would silently change which local survives every arm-disjoint merge on a
|
|
569
|
+
// function whose frame order disagrees with its declaration order — exactly the
|
|
570
|
+
// population the ordering exists for.
|
|
571
|
+
const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
|
|
572
|
+
const refused = firstRejection(gates, {
|
|
573
|
+
a: gone,
|
|
574
|
+
b: kept,
|
|
575
|
+
ifInLoop: inLoop,
|
|
576
|
+
armsJoined: joined(i, j),
|
|
577
|
+
sameType: typeOf.get(a) === typeOf.get(b),
|
|
578
|
+
eitherIsVolatile: isVolatile(a) || isVolatile(b),
|
|
579
|
+
bothArmConstInit:
|
|
580
|
+
firstMention(arms[i], a) === 'const-write' && firstMention(arms[j], b) === 'const-write',
|
|
581
|
+
});
|
|
582
|
+
if (refused !== null) {
|
|
583
|
+
refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
out.push({
|
|
587
|
+
merged: `${gone}-${kept}`,
|
|
588
|
+
sfn: {
|
|
589
|
+
...sfn,
|
|
590
|
+
body: rename(sfn.body, gone, kept),
|
|
591
|
+
locals: localsAfterMerge(sfn.locals, gone, kept),
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
534
595
|
}
|
|
535
|
-
out.push({
|
|
536
|
-
merged: `${gone}-${kept}`,
|
|
537
|
-
sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
|
|
538
|
-
});
|
|
539
596
|
}
|
|
540
597
|
}
|
|
541
598
|
}
|
package/src/l3/gates.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
//
|
|
8
8
|
// `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
|
|
9
9
|
// which has room; duplicating it here is how a table stops paying for itself.
|
|
10
|
+
//
|
|
11
|
+
// A table a variation's definition names (`variation-gates.ts`) shows its `why` to a reader, in the
|
|
12
|
+
// webapp's variation drawer, so there the label is plain prose: no tag, file or function name, or
|
|
13
|
+
// shouted word. `variation-offers.test.ts` holds it to that.
|
|
10
14
|
export interface Gate<Ctx> {
|
|
11
15
|
/** stable, kebab-case; appears in test names and in the contract report */
|
|
12
16
|
readonly id: string;
|
|
13
|
-
/** one line: the reason the rule exists */
|
|
17
|
+
/** one line: the reason the rule exists, readable without the code */
|
|
14
18
|
readonly why: string;
|
|
15
19
|
/** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
|
|
16
20
|
* heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
|
|
@@ -86,3 +90,73 @@ export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
|
|
|
86
90
|
}
|
|
87
91
|
return out;
|
|
88
92
|
}
|
|
93
|
+
|
|
94
|
+
/** A gate table that counts its own refusals — {@link tallying}'s return. */
|
|
95
|
+
export interface Tallied<Ctx> {
|
|
96
|
+
/** Hand this to the pass, in place of the table it wraps. */
|
|
97
|
+
readonly gates: readonly Gate<Ctx>[];
|
|
98
|
+
/** The census so far, most-refused first, ties in table order. A snapshot: counts keep
|
|
99
|
+
* accumulating across every later call, which is what a corpus-wide census wants.
|
|
100
|
+
*
|
|
101
|
+
* THERE IS NO RESET, deliberately — a per-row census is two snapshots DIFFED, not a fresh
|
|
102
|
+
* wrapper per row, because a wrapper is a new table IDENTITY and a reader that keys on one
|
|
103
|
+
* (`rank.ts`'s `censuses` memo) sees a fresh key every row. Counting is keyed by `g.id`, so a
|
|
104
|
+
* table COMPOSED from several should be run past `gateTableDefects` first: two rules sharing an
|
|
105
|
+
* id sum into one number, and the contract test only checks the tables on its own roster. */
|
|
106
|
+
readonly refusals: () => readonly (readonly [string, number])[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The same table, wrapping each `rejects` in a counter — so a caller OUTSIDE core can obtain the
|
|
110
|
+
* per-id census that `l3/coalesce.ts` and `structure/namecoalesce.ts` hand-roll into their return
|
|
111
|
+
* type (`l3/scopebase.ts` reports the same attribution per KEY), from any pass that takes its
|
|
112
|
+
* table as a parameter:
|
|
113
|
+
*
|
|
114
|
+
* const t = tallying(UNMERGE_SITE_GATES);
|
|
115
|
+
* unmergeJoins(sfn, { site: t.gates });
|
|
116
|
+
* console.log(t.refusals()); // [['empty-arm', 168], ['no-merge-name', 80]]
|
|
117
|
+
*
|
|
118
|
+
* THAT IS THE API AND NOT YET A CENSUS: nothing exports a corpus of trees to loop over, and a
|
|
119
|
+
* tabled pass's only shipped caller is normally inside core. Taking the census off a REAL
|
|
120
|
+
* enumeration is `pnpm bench gates --pass <id>`, whose header
|
|
121
|
+
* (`apps/benchmark/src/run/gate-census.ts`) holds the measured reasons it is a subcommand rather
|
|
122
|
+
* than a script to copy, and what a SECOND censusable pass costs.
|
|
123
|
+
*
|
|
124
|
+
* WHAT IT COUNTS IS AN EVALUATION THAT ANSWERED TRUE, not a site. Under `firstRejection` — which
|
|
125
|
+
* short-circuits — that is the FIRST rejecter, so this produces the same census the hand-rolled
|
|
126
|
+
* maps do, with the same reading: an id absent from it is starved OR REDUNDANT WITH an earlier
|
|
127
|
+
* rule, and telling the two apart takes the same rule run with the rest of the table empty
|
|
128
|
+
* (`grep -n "ON ITS OWN" packages/core/src/raise/globalshape.ts`, whose dated table ships three
|
|
129
|
+
* rules of the second kind). A consumer that asks the table something else — `.some`, `.filter` —
|
|
130
|
+
* gets one count per evaluation instead, which is a different question and rarely the one wanted.
|
|
131
|
+
*
|
|
132
|
+
* AND IT COUNTS REFUSALS, WHICH IS NOT REACH. A rule can refuse hundreds of times and still change
|
|
133
|
+
* no output, because a later rule or a narrowing outside the table would have refused the same
|
|
134
|
+
* sites: that is the MOVED column, it costs an ablation rather than a census, and `l3/unmerge.ts`'s
|
|
135
|
+
* header carries the worked example of the two disagreeing.
|
|
136
|
+
*
|
|
137
|
+
* IT CHANGES NO BEHAVIOUR: each wrapper's predicate IS the original's, `id`/`why`/`sound`/
|
|
138
|
+
* `guardedBy` are carried, so `without`, `ablateHeuristic` and `gateTableDefects` all still hold
|
|
139
|
+
* over the result. What it does change is the table's IDENTITY, and some readers key on that —
|
|
140
|
+
* `rank.ts`'s `censuses` memo is a `Map` over `Gate<BaseKey>[]` instances — so wrap once and reuse
|
|
141
|
+
* `gates`, rather than per call. */
|
|
142
|
+
export function tallying<Ctx>(gates: readonly Gate<Ctx>[]): Tallied<Ctx> {
|
|
143
|
+
const counts = new Map<string, number>();
|
|
144
|
+
const order = new Map(gates.map((g, i) => [g.id, i]));
|
|
145
|
+
return {
|
|
146
|
+
gates: gates.map((g) => ({
|
|
147
|
+
...g,
|
|
148
|
+
rejects: (c: Ctx) => {
|
|
149
|
+
const r = g.rejects(c);
|
|
150
|
+
if (r) {
|
|
151
|
+
counts.set(g.id, (counts.get(g.id) ?? 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
return r;
|
|
154
|
+
},
|
|
155
|
+
})),
|
|
156
|
+
refusals: () =>
|
|
157
|
+
[...counts].sort((a, b) => b[1] - a[1] || (order.get(a[0]) ?? 0) - (order.get(b[0]) ?? 0)) as readonly (readonly [
|
|
158
|
+
string,
|
|
159
|
+
number,
|
|
160
|
+
])[],
|
|
161
|
+
};
|
|
162
|
+
}
|
package/src/l3/hoist.ts
CHANGED
|
@@ -208,7 +208,7 @@ function scopeSite(list: Stmt[], name: string): { list: Stmt[]; at: number } | n
|
|
|
208
208
|
* different bytes. Where no nested list holds every mention this IS `first-use`, which is what
|
|
209
209
|
* makes it a placement rather than a second policy.
|
|
210
210
|
*
|
|
211
|
-
* These three are the
|
|
211
|
+
* These three are the positions a hoist may state (rank.ts) and the only values
|
|
212
212
|
* `hoistBaseLocals` accepts. */
|
|
213
213
|
export type HoistPlacement = 'head' | 'first-use' | 'scope';
|
|
214
214
|
|