@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/unmerge.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// L3
|
|
2
|
-
// of — the dual of l3/tailmerge.ts, and a
|
|
1
|
+
// L3 respell variation: duplicate a join statement back into the arms the compiler merged it out
|
|
2
|
+
// of — the dual of l3/tailmerge.ts, and a VARIATION where that one is unconditional.
|
|
3
3
|
//
|
|
4
4
|
// agbcc cross-jumps a store the source wrote in both arms into the join block, so the lifted CFG
|
|
5
5
|
// carries the address and the value on merge parameters and SSA destruction mints a temp per
|
|
@@ -12,9 +12,25 @@
|
|
|
12
12
|
// `*(u16 *)A2 = B2;` inside the other — two whole statements, no temps. Substituting each arm's
|
|
13
13
|
// own definitions into the join statement and duplicating it back recovers that spelling.
|
|
14
14
|
//
|
|
15
|
-
// A
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// A VARIATION, NOT A DEFAULT, and the reason is the tower's: the LIFTED TREE underdetermines the
|
|
16
|
+
// source. This is a compiler claim, so it was compiled — agbcc `gcc 2.9-arm-000512` at its
|
|
17
|
+
// canonical flags, both spellings, `diff` on the `.s`:
|
|
18
|
+
//
|
|
19
|
+
// - the example above (an ADDRESS temp and a VALUE temp, against `*gA1 = gB1;` in one arm and
|
|
20
|
+
// `*gA2 = gB2;` in the other, `v17` typed as the store's own type) is BYTE-IDENTICAL. There
|
|
21
|
+
// the merge really is the compiler's own, and the variation costs one candidate to say so.
|
|
22
|
+
// - `synthetic:armcb2`'s shape is NOT. Only the VALUE merges there — the store's address,
|
|
23
|
+
// `gSlot[1]`, is common to both arms — and the per-arm spelling keeps TWO literal-pool
|
|
24
|
+
// islands and a `b` to the join where the merged one has a single island and no `b`.
|
|
25
|
+
//
|
|
26
|
+
// So the mapping from this tree back to a source is not a function, and it is not uniformly
|
|
27
|
+
// many-to-one either: which way it goes is a property of the SHAPE, which no gate here can read
|
|
28
|
+
// off the tree. That is exactly the tower's test for a variation rather than a default. Both
|
|
29
|
+
// spellings are emitted and the differ referees.
|
|
30
|
+
//
|
|
31
|
+
// Re-compile before restating either half: the VALUE TEMP'S TYPE alone changes the answer. Give
|
|
32
|
+
// `v17` a type wider than the store and the byte-identical case above grows an `ldr`/`ldrh`
|
|
33
|
+
// divergence that has nothing to do with the merge.
|
|
18
34
|
//
|
|
19
35
|
// SOUND BY ITS DUAL'S ARGUMENT, READ BACKWARDS. The join runs on every path out of the `if`,
|
|
20
36
|
// immediately after that arm's own tail, with nothing between; a copy at the end of each arm runs
|
|
@@ -27,10 +43,10 @@
|
|
|
27
43
|
// (tailmerge's scope, for its reason: control flow duplicated into an arm changes what the arm
|
|
28
44
|
// still reaches);
|
|
29
45
|
// - the join statement reads no local the arms define — there is no merge to undo;
|
|
30
|
-
// - a local it reads is neither a merge temp (assigned EXACTLY ONCE IN EACH ARM
|
|
31
|
-
// the join statement, never address-taken — the counts are
|
|
32
|
-
// anywhere refuses) nor untouched by both arms (a name the
|
|
33
|
-
// substitute would read a different value at the arm's end);
|
|
46
|
+
// - a local it reads is neither a merge temp (assigned EXACTLY ONCE IN EACH TERMINAL ARM and
|
|
47
|
+
// nowhere else, read only by the join statement, never address-taken — the counts are
|
|
48
|
+
// function-wide, so a second reader anywhere refuses) nor untouched by both arms (a name the
|
|
49
|
+
// arms DO write and this cannot substitute would read a different value at the arm's end);
|
|
34
50
|
// - anything but an EFFECT-FREE assignment TO A DECLARED LOCAL stands between the first
|
|
35
51
|
// definition and the arm's end: the substituted values are evaluated where the copy lands, so
|
|
36
52
|
// an intervening store or call could answer a load inside one of them differently. All three
|
|
@@ -56,7 +72,64 @@
|
|
|
56
72
|
// - a definition's value performs a VOLATILE access. `exprHasEffect` above answers "a call, or a
|
|
57
73
|
// marker" and says nothing about a qualifier, so it is not the test for this: the refusal is
|
|
58
74
|
// asked of the qualifier's own model (`exprReadsVolatile`), which knows all three spellings —
|
|
59
|
-
// the cast, the pointee-volatile pointer local, and the volatile local object
|
|
75
|
+
// the cast, the pointee-volatile pointer local, and the volatile local object;
|
|
76
|
+
// - an arm is neither TERMINAL (a run of assignments defining the names) nor a RUNG (its last
|
|
77
|
+
// statement is an `if`, whose arms are asked the same question one level down — any nested
|
|
78
|
+
// `if` TREE qualifies, not only a right-nested else-if chain);
|
|
79
|
+
// - the terminal arms are not ALL of the name's definitions, or a merge name is still mentioned
|
|
80
|
+
// in the rewritten statement — the two halves of totality, below.
|
|
81
|
+
//
|
|
82
|
+
// THESE REFUSALS ARE AN `l3/gates.ts` TABLE — five of them, at four contexts, because a `Gate<Ctx>`
|
|
83
|
+
// is per-context and this pass judges four: the SITE (an `if` and the statement after it), one
|
|
84
|
+
// ARM's trailing run, one moved VALUE, and an arm as a RUNG. The fifth judges the REWRITTEN site,
|
|
85
|
+
// which exists only once the other four have admitted. What licensed the conversion is the
|
|
86
|
+
// instrument episode recorded at `pushJoin`'s tail refusal below (56 firings, split 40/16), which
|
|
87
|
+
// is the trigger `docs/level-tower.md` states — `grep -n "THE UNIT OF THAT DECISION" docs/level-tower.md`.
|
|
88
|
+
// The 40/16 split is now two gates, so that census is `pnpm bench gates --pass unmerge` rather than
|
|
89
|
+
// a patch — 291 agbcc synthetic rows in ~11 s, and it prints all five tables at once.
|
|
90
|
+
//
|
|
91
|
+
// TWO COLUMNS, AND THEY DISAGREE HERE. A census counts FIRST REJECTIONS; what an ablation of the
|
|
92
|
+
// same rule MOVES is a different number, and `raise/globalshape.ts` ships the convention for
|
|
93
|
+
// reporting both (`grep -n "ON ITS OWN" packages/core/src/raise/globalshape.ts`). Measured
|
|
94
|
+
// 2026-09-09, every `sound: false` gate in these five tables ablated ONE AT A TIME against the
|
|
95
|
+
// shipped table:
|
|
96
|
+
//
|
|
97
|
+
// population rules moved
|
|
98
|
+
// 40,000 generated trees (unmerge-fuzz's `gen`/`gen2`, 20,000 seeds each, 13,099 firings) 0 of 6
|
|
99
|
+
// 291 agbcc synthetic rows, through `enumerateRanked`, whole fan compared variations+source 1 of 6
|
|
100
|
+
//
|
|
101
|
+
// The one is `site/no-merge-name`, and it moves exactly one row: `synthetic:mergeloop:agbcc`, whose
|
|
102
|
+
// fan grows 16 -> 24 candidates when it is ablated, and which is also where it fires 40 of its 80
|
|
103
|
+
// times (`pnpm bench gates --pass unmerge --only synthetic:mergeloop:agbcc` — the full id, because a
|
|
104
|
+
// bare symbol censuses every toolchain that spec targets and pools the counts). The other five
|
|
105
|
+
// change NOTHING, in two different ways. Three of them refuse and are overruled, because a later
|
|
106
|
+
// rule or `pushJoin`'s own narrowing refuses the same sites: `empty-arm` 168, `tail-is-not-an-if`
|
|
107
|
+
// 40, `empty-arm-has-no-tail` 16. The two VALUE gates never fire at all — the census prints
|
|
108
|
+
// `value: (never fired)`, and each of them carries the proof below that it cannot. So
|
|
109
|
+
// `ablateHeuristic` has reach into exactly one rule here, and a variation built on any of the other five
|
|
110
|
+
// would measure 0 rows moved for a reason that has nothing to do with the variation. RE-RUN BOTH before
|
|
111
|
+
// quoting either: the generated population says 0 of 6 and the corpus says 1 of 6, which is itself
|
|
112
|
+
// the lesson — a fuzz generator's shapes are not the corpus's.
|
|
113
|
+
//
|
|
114
|
+
// THE RESIDUE, NAMED. `list`'s `s.k === 'if' && isJoinable(join)` is the ENUMERATOR — which pairs
|
|
115
|
+
// are judged at all — and enumeration is not residue. Nor are the `null`s `unmergeAt` and
|
|
116
|
+
// `pushJoin` propagate out of a nested call: that refusal was already delivered, and counting it
|
|
117
|
+
// twice would make the census read as if a second rule fired.
|
|
118
|
+
//
|
|
119
|
+
// THE ARMS ARE THE PATHS, WHICH IS WHY THE LADDER IS THE SAME REWRITE. agbcc cross-jumps the shared
|
|
120
|
+
// tail of an else-if CHAIN exactly as it cross-jumps a two-armed `if`'s, and the lifted tree then
|
|
121
|
+
// hands this pass an outer `if` whose `else` is another `if`. Every path out of that ladder leaves
|
|
122
|
+
// through exactly one TERMINAL arm, so a copy at the end of each runs exactly once per path — the
|
|
123
|
+
// two-arm argument above, read inductively. `pushJoin`'s own note carries which shapes that admits
|
|
124
|
+
// (any nested `if` tree, not only a chain) and why it needs no cap.
|
|
125
|
+
//
|
|
126
|
+
// TOTALITY, NOT AN ARM COUNT, is what pins "and nowhere else" — a ladder assigns its merge name
|
|
127
|
+
// once per arm, so no number belongs in the gate. Two checks say it without one: every assignment
|
|
128
|
+
// in the function is one of the terminal arms this rewrite consumed, AND no merge name is still
|
|
129
|
+
// mentioned in the result. The second is not redundant — `localMentions` is read once, before any
|
|
130
|
+
// rewriting, and this pass duplicates statements, so an earlier site can leave the map short by
|
|
131
|
+
// exactly the number the ladder consumes and make the first check agree by coincidence. Only
|
|
132
|
+
// re-reading the result catches that, and `test/unmerge.test.ts` builds the tree where it must.
|
|
60
133
|
//
|
|
61
134
|
// AND THE SCOPE OF THE VOLATILE ONE IS WHAT MOVES, which is exactly one thing. A kept statement
|
|
62
135
|
// holds its position, and the join runs where it already ran (immediately after that arm), so the
|
|
@@ -73,7 +146,7 @@
|
|
|
73
146
|
// reading `*p` and a kept assignment to an address-taken local can name the same object under two
|
|
74
147
|
// spellings, and the name-keyed refusal below (an intervening assignment writes a name one of
|
|
75
148
|
// those values READS) cannot see it. That is one question further out than this pass models —
|
|
76
|
-
// every other
|
|
149
|
+
// every other respell variation here defers it to the same name-keyed model — and the sweep behind this note
|
|
77
150
|
// found no inhabitant: 0 arms this pass ACCEPTS hold a kept assignment to an address-taken local
|
|
78
151
|
// after the first definition, the same sweep that found 0 holding one to a global. That sweep's
|
|
79
152
|
// population was the agbcc rows whose BASE TREE the rig could build, which is not the corpus's
|
|
@@ -89,7 +162,8 @@ import {
|
|
|
89
162
|
stmtChildren,
|
|
90
163
|
walkExprs,
|
|
91
164
|
} from './ast';
|
|
92
|
-
import {
|
|
165
|
+
import { type Gate, firstRejection } from './gates';
|
|
166
|
+
import { localMentions, mentionsAnyLocal, readsOf } from './mentions';
|
|
93
167
|
|
|
94
168
|
/** every statement under `body`, itself included */
|
|
95
169
|
function* walkStmts(body: Stmt[]): Generator<Stmt> {
|
|
@@ -126,12 +200,248 @@ function readsIn(e: Expr): Set<string> {
|
|
|
126
200
|
return out;
|
|
127
201
|
}
|
|
128
202
|
|
|
203
|
+
// ── THE REFUSALS, AS DATA ────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** What the SITE table judges: one `if` and the statement that follows it.
|
|
206
|
+
*
|
|
207
|
+
* THE FIELDS ARE LAZY. Classifying the join's reads walks both arms and reads the function-wide
|
|
208
|
+
* mention counts, and a site with an empty arm is refused before either is asked. */
|
|
209
|
+
export interface UnmergeSite {
|
|
210
|
+
readonly iff: Extract<Stmt, { k: 'if' }>;
|
|
211
|
+
/** the merge temps a rewrite here would substitute */
|
|
212
|
+
readonly merge: ReadonlySet<string>;
|
|
213
|
+
/** names the arms write that this cannot substitute — a copy would read a different value */
|
|
214
|
+
readonly unsubstitutable: ReadonlySet<string>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const UNMERGE_SITE_GATES: readonly Gate<UnmergeSite>[] = [
|
|
218
|
+
{
|
|
219
|
+
id: 'empty-arm',
|
|
220
|
+
why: 'an `if` with an empty arm would leave that path with no copy of the join',
|
|
221
|
+
// Not sound only because it is not the LAST word: an empty arm defines nothing, so the rung
|
|
222
|
+
// table below refuses it a second time. This one is the scope statement, stated where a reader
|
|
223
|
+
// looks for it.
|
|
224
|
+
sound: false,
|
|
225
|
+
// THE GUARD IS THE CENSUS, not the decline. Deleted from this table, the whole core suite
|
|
226
|
+
// stays green — the rung table refuses an empty arm a second time — so the only test that can
|
|
227
|
+
// tell this gate apart from its shadow is the one that reads the ID back.
|
|
228
|
+
guardedBy: 'unmerge.test.ts: a census names the rule that refused each site, and counts it',
|
|
229
|
+
rejects: (c) => c.iff.then.length === 0 || c.iff.else.length === 0,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'arm-writes-a-name-this-cannot-substitute',
|
|
233
|
+
why: 'a name an arm writes that the rewrite cannot substitute would be read by the copy at the arm’s end, where it holds a different value',
|
|
234
|
+
sound: true,
|
|
235
|
+
// THE GUARD IS NOT THE TEST THAT NAMES THIS REFUSAL. Ablated alone, `a join reading a local
|
|
236
|
+
// the arms WRITE but this cannot substitute refuses` stays GREEN — that fixture is refused a
|
|
237
|
+
// second time further down — while three others go red. The one named here is the informative
|
|
238
|
+
// one: it is the shape `moved-value-reads-another-merge-name` claims to catch, and this gate
|
|
239
|
+
// is what actually catches it (see that gate).
|
|
240
|
+
guardedBy: 'unmerge.test.ts: a definition whose value reads ANOTHER merge temp refuses',
|
|
241
|
+
rejects: (c) => c.unsubstitutable.size > 0,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'no-merge-name',
|
|
245
|
+
why: 'the join reads nothing the arms define — there is no merge to undo',
|
|
246
|
+
sound: false,
|
|
247
|
+
// Same shadowing as `empty-arm` above, and the same guard: ablated alone, the named decline
|
|
248
|
+
// test stays green (a join reading no merge name defines nothing to move, so the arm table
|
|
249
|
+
// refuses it next), and only the census distinguishes which rule spoke.
|
|
250
|
+
guardedBy: 'unmerge.test.ts: a census names the rule that refused each site, and counts it',
|
|
251
|
+
rejects: (c) => c.merge.size === 0,
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
/** What the ARM table judges: whether one arm is TERMINAL — a trailing run of assignments defining
|
|
256
|
+
* every merge name, with nothing beside them that could answer a moved read differently.
|
|
257
|
+
*
|
|
258
|
+
* THE FIELDS ARE LAZY for the same reason: `first` is `Math.min` over an empty map when the arm
|
|
259
|
+
* does not define them all, and `clobbersAMovedRead` reads every definition's value. */
|
|
260
|
+
export interface UnmergeArm {
|
|
261
|
+
/** where in the arm each merge name is defined */
|
|
262
|
+
readonly at: ReadonlyMap<string, number>;
|
|
263
|
+
readonly names: ReadonlySet<string>;
|
|
264
|
+
/** the tree's own locals and params — the objects a kept assignment may write */
|
|
265
|
+
readonly declared: ReadonlySet<string>;
|
|
266
|
+
/** the arm from its FIRST definition on: the run the copy is appended to */
|
|
267
|
+
readonly trailing: readonly Stmt[];
|
|
268
|
+
/** does a kept assignment after that point write a name one of the moved values reads? */
|
|
269
|
+
readonly clobbersAMovedRead: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const UNMERGE_ARM_GATES: readonly Gate<UnmergeArm>[] = [
|
|
273
|
+
{
|
|
274
|
+
id: 'arm-does-not-define-them-all',
|
|
275
|
+
why: 'a merge name an arm does not define leaves that arm nothing to substitute',
|
|
276
|
+
sound: true,
|
|
277
|
+
// Ablated, `a merge temp assigned in only ONE arm (or three times) refuses` stays green —
|
|
278
|
+
// totality catches that one — and the LADDERS break instead, which is the real cost: this is
|
|
279
|
+
// the gate that says an arm is not terminal, so without it a rung is rewritten as though it
|
|
280
|
+
// were a leaf.
|
|
281
|
+
guardedBy: 'unmerge.test.ts: a THREE-arm ladder puts the join in all three',
|
|
282
|
+
rejects: (c) => c.at.size !== c.names.size,
|
|
283
|
+
},
|
|
284
|
+
// The three halves of "nothing but an EFFECT-FREE assignment TO A DECLARED LOCAL stands between
|
|
285
|
+
// the first definition and the arm's end", one gate each — the header says all three are tested,
|
|
286
|
+
// and separating them is what lets a census say WHICH one a corpus actually hits. A disjunction
|
|
287
|
+
// of existentials is the existential of the disjunction, so the split is exact.
|
|
288
|
+
{
|
|
289
|
+
id: 'trailing-run-holds-a-non-assignment',
|
|
290
|
+
why: 'a store or a call between a definition and the arm’s end would run before the value moved past it',
|
|
291
|
+
sound: true,
|
|
292
|
+
guardedBy: 'unmerge.test.ts: a definition that is NOT in the arm',
|
|
293
|
+
rejects: (c) => c.trailing.some((s) => s.k !== 'assign'),
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'trailing-run-writes-an-undeclared-name',
|
|
297
|
+
why: 'a write to a global scalar is spelled as an assignment, and it writes memory',
|
|
298
|
+
sound: true,
|
|
299
|
+
guardedBy: 'unmerge.test.ts: an intervening assignment to a GLOBAL refuses',
|
|
300
|
+
rejects: (c) => c.trailing.some((s) => s.k === 'assign' && !c.declared.has(s.name)),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'trailing-run-holds-an-effectful-value',
|
|
304
|
+
why: 'a call assigned between a definition and the arm’s end would run before the load moved past it',
|
|
305
|
+
sound: true,
|
|
306
|
+
guardedBy: 'unmerge.test.ts: an intervening assignment whose VALUE is a CALL refuses',
|
|
307
|
+
rejects: (c) => c.trailing.some((s) => s.k === 'assign' && exprHasEffect(s.value)),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'intervening-write-to-a-moved-read',
|
|
311
|
+
why: 'an assignment between a definition and the arm’s end that writes what the definition reads would change the moved value',
|
|
312
|
+
sound: true,
|
|
313
|
+
guardedBy: 'unmerge.test.ts: an intervening assignment that CLOBBERS what a definition reads refuses',
|
|
314
|
+
rejects: (c) => c.clobbersAMovedRead,
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
/** What the VALUE table judges: ONE definition's value, asked whether the substitution may
|
|
319
|
+
* relocate it to the arm's end. Per value, not per arm — a table entry names the reason. */
|
|
320
|
+
export interface UnmergeMovedValue {
|
|
321
|
+
readonly value: Expr;
|
|
322
|
+
readonly names: ReadonlySet<string>;
|
|
323
|
+
readonly sfn: SFn;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export const UNMERGE_VALUE_GATES: readonly Gate<UnmergeMovedValue>[] = [
|
|
327
|
+
{
|
|
328
|
+
id: 'moved-value-has-an-effect',
|
|
329
|
+
why: 'C fixes no order between one statement’s operands, so the backend would choose one',
|
|
330
|
+
// SHADOWED, not sound — and PROVABLY, not just unwitnessed. Every definition sits at a
|
|
331
|
+
// position in `at`, all of which are `>= first`, so every definition is in the arm ctx's
|
|
332
|
+
// `trailing`; an effectful one is therefore already refused by
|
|
333
|
+
// `trailing-run-holds-an-effectful-value` one context up. Ablated on its own, no test in
|
|
334
|
+
// unmerge.test.ts or unmerge-fuzz.test.ts reddens — `a definition carrying an EFFECT
|
|
335
|
+
// refuses` included. It stays because the scope it states is the pass's, and deleting a rule
|
|
336
|
+
// that is correct-but-shadowed is how the shadowing rule silently becomes load-bearing.
|
|
337
|
+
sound: false,
|
|
338
|
+
rejects: (c) => exprHasEffect(c.value),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
id: 'moved-value-reads-volatile',
|
|
342
|
+
why: 'a moved value that reads a `volatile` access moves a read the source pinned, and its order is observable',
|
|
343
|
+
sound: true,
|
|
344
|
+
guardedBy: 'unmerge.test.ts: a definition reading a DEVICE REGISTER refuses',
|
|
345
|
+
rejects: (c) => exprReadsVolatile(c.value, c.sfn),
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'moved-value-reads-another-merge-name',
|
|
349
|
+
why: 'the substitutions would need an order between them that the join statement does not fix',
|
|
350
|
+
// SHADOWED BY THE SITE TABLE, provably. A merge name is one the join reads and that
|
|
351
|
+
// `readsOf(m) === 1` says is read NOWHERE ELSE; a definition value reading it is a second
|
|
352
|
+
// read, so it was never in `merge` — it landed in `unsubstitutable` and
|
|
353
|
+
// `arm-writes-a-name-this-cannot-substitute` refused the whole site. Ablated on its own, no
|
|
354
|
+
// test reddens, its own namesake fixture included, which is why that fixture is the guard
|
|
355
|
+
// named on the site gate instead. Kept for the same reason as the gate above.
|
|
356
|
+
sound: false,
|
|
357
|
+
rejects: (c) => [...readsIn(c.value)].some((n) => c.names.has(n)),
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
/** What the RUNG table judges: an arm that is not terminal, asked whether the copy belongs one
|
|
362
|
+
* level down instead. Two gates rather than one because the instrument episode the header cites
|
|
363
|
+
* measured them SEPARATELY — 40 firings on a non-`if` tail, 16 on an empty arm — and a table that
|
|
364
|
+
* fused them could not reproduce that split. */
|
|
365
|
+
export interface UnmergeRung {
|
|
366
|
+
readonly arm: readonly Stmt[];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export const UNMERGE_RUNG_GATES: readonly Gate<UnmergeRung>[] = [
|
|
370
|
+
{
|
|
371
|
+
id: 'empty-arm-has-no-tail',
|
|
372
|
+
why: 'an empty arm has no statement to pass the copy down into, and defines none of the names',
|
|
373
|
+
sound: false,
|
|
374
|
+
// REDUNDANT WITH `tail-is-not-an-if` below (`arm[len - 1]` of an empty arm is `undefined`, whose
|
|
375
|
+
// `?.k` is not `'if'`) — not SHADOWED in `gates.ts`'s sense, which is about an id being ABSENT
|
|
376
|
+
// from a census because an EARLIER rule refused first. This rule is the earlier one and it is
|
|
377
|
+
// rank 2 in the census (16 firings); what it shares with the next is its VERDICT, so ablating it
|
|
378
|
+
// moves nothing. The census test is therefore the guard — it is also what the SPLIT this table
|
|
379
|
+
// exists to reproduce is asserted by.
|
|
380
|
+
guardedBy: 'unmerge.test.ts: the RUNG census reproduces the split the instrumented run measured, without the patch',
|
|
381
|
+
rejects: (c) => c.arm.length === 0,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: 'tail-is-not-an-if',
|
|
385
|
+
why: 'an arm that does not define every merge name can pass the copy down only into the `if` it ends with',
|
|
386
|
+
sound: false,
|
|
387
|
+
// REDUNDANT WITH the type narrowing in `pushJoin` — which has to stand there whatever this table
|
|
388
|
+
// says, because both gates here are ablatable. Not shadowed in `gates.ts`'s sense either: this
|
|
389
|
+
// rule fires 40 times in the census and is the table's top row; the narrowing outside the table
|
|
390
|
+
// is what makes ablating it move nothing. The census test is the guard that survives that.
|
|
391
|
+
guardedBy: 'unmerge.test.ts: the RUNG census reproduces the split the instrumented run measured, without the patch',
|
|
392
|
+
rejects: (c) => c.arm[c.arm.length - 1]?.k !== 'if',
|
|
393
|
+
},
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
/** What the TOTALITY table judges: the REWRITTEN site. It exists only once every other table has
|
|
397
|
+
* admitted, which is why it is a table of its own rather than three more site gates.
|
|
398
|
+
*
|
|
399
|
+
* `out` IS LAZY: the first gate reads only counts, and building the rewritten `if` for a site
|
|
400
|
+
* that fails it would be a spread nothing reads. */
|
|
401
|
+
export interface UnmergeTotality {
|
|
402
|
+
readonly merge: ReadonlySet<string>;
|
|
403
|
+
readonly mentions: ReturnType<typeof localMentions>;
|
|
404
|
+
/** how many copies of the join the rewrite took — one per terminal arm */
|
|
405
|
+
readonly used: number;
|
|
406
|
+
readonly out: Stmt;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const UNMERGE_TOTALITY_GATES: readonly Gate<UnmergeTotality>[] = [
|
|
410
|
+
{
|
|
411
|
+
id: 'a-definition-the-rewrite-did-not-consume',
|
|
412
|
+
why: 'a definition the rewrite did not turn into a copy would survive with nothing left to read it, and its local is deleted',
|
|
413
|
+
sound: true,
|
|
414
|
+
guardedBy: 'unmerge.test.ts: a definition OUTSIDE the terminal arms refuses',
|
|
415
|
+
rejects: (c) => [...c.merge].some((n) => c.mentions.get(n)?.assigns !== c.used),
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: 'merge-name-survives-the-rewrite',
|
|
419
|
+
why: 'the merge variables are deleted, so the rewritten code must mention none of them, whatever the counts taken before the rewrite said',
|
|
420
|
+
sound: true,
|
|
421
|
+
guardedBy: 'unmerge.test.ts: a definition an earlier rewrite duplicated leaves the count agreeing',
|
|
422
|
+
rejects: (c) => mentionsAnyLocal([c.out], c.merge),
|
|
423
|
+
},
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
/** The tables `unmergeJoins` consults, each overridable — which is how a caller outside core takes
|
|
427
|
+
* a per-id refusal census (`tallying`) or an ablation (`without`) off this pass without editing
|
|
428
|
+
* it. Test and diagnostic seam; the shipped path passes nothing. */
|
|
429
|
+
export interface UnmergeGates {
|
|
430
|
+
readonly site?: readonly Gate<UnmergeSite>[];
|
|
431
|
+
readonly arm?: readonly Gate<UnmergeArm>[];
|
|
432
|
+
readonly value?: readonly Gate<UnmergeMovedValue>[];
|
|
433
|
+
readonly rung?: readonly Gate<UnmergeRung>[];
|
|
434
|
+
readonly totality?: readonly Gate<UnmergeTotality>[];
|
|
435
|
+
}
|
|
436
|
+
|
|
129
437
|
/** The arm's definitions of `names` and the statements that survive beside them, or null when the
|
|
130
|
-
* substituted copy would not evaluate to the same values at the arm's end
|
|
438
|
+
* substituted copy would not evaluate to the same values at the arm's end — `UNMERGE_ARM_GATES`
|
|
439
|
+
* carries every reason. */
|
|
131
440
|
function armDefs(
|
|
132
441
|
arm: Stmt[],
|
|
133
442
|
names: ReadonlySet<string>,
|
|
134
443
|
declared: ReadonlySet<string>,
|
|
444
|
+
gates: readonly Gate<UnmergeArm>[],
|
|
135
445
|
): { defs: Map<string, Expr>; keep: Stmt[] } | null {
|
|
136
446
|
const at = new Map<string, number>();
|
|
137
447
|
arm.forEach((s, i) => {
|
|
@@ -139,34 +449,33 @@ function armDefs(
|
|
|
139
449
|
at.set(s.name, i);
|
|
140
450
|
}
|
|
141
451
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
452
|
+
const valueAt = (i: number): Expr => (arm[i] as Extract<Stmt, { k: 'assign' }>).value;
|
|
453
|
+
let first: number | undefined;
|
|
454
|
+
const firstDef = (): number => (first ??= Math.min(...at.values()));
|
|
455
|
+
let clobbers: boolean | undefined;
|
|
456
|
+
const ctx: UnmergeArm = {
|
|
457
|
+
at,
|
|
458
|
+
names,
|
|
459
|
+
declared,
|
|
460
|
+
get trailing() {
|
|
461
|
+
return arm.slice(firstDef());
|
|
462
|
+
},
|
|
463
|
+
get clobbersAMovedRead() {
|
|
464
|
+
if (clobbers === undefined) {
|
|
465
|
+
// `declared` is the tree's own locals and params, so a kept assignment's target is an
|
|
466
|
+
// object no moved read can reach except by the name this keys on.
|
|
467
|
+
const read = new Set([...at.values()].flatMap((i) => [...readsIn(valueAt(i))]));
|
|
468
|
+
clobbers = arm.some((s, i) => i > firstDef() && s.k === 'assign' && !names.has(s.name) && read.has(s.name));
|
|
469
|
+
}
|
|
470
|
+
return clobbers;
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
if (firstRejection(gates, ctx) !== null) {
|
|
156
474
|
return null;
|
|
157
475
|
}
|
|
158
|
-
const defs = new Map([...at].map(([n, i]) => [n, (
|
|
159
|
-
|
|
160
|
-
const keep
|
|
161
|
-
for (const [i, s] of arm.entries()) {
|
|
162
|
-
if (i >= first && s.k === 'assign' && names.has(s.name)) {
|
|
163
|
-
continue; // the definition itself, consumed by the substitution
|
|
164
|
-
}
|
|
165
|
-
if (i > first && s.k === 'assign' && read.has(s.name)) {
|
|
166
|
-
return null; // it would change a value this moves past it
|
|
167
|
-
}
|
|
168
|
-
keep.push(s);
|
|
169
|
-
}
|
|
476
|
+
const defs = new Map([...at].map(([n, i]) => [n, valueAt(i)] as const));
|
|
477
|
+
// Everything but the definitions themselves, which the substitution consumed.
|
|
478
|
+
const keep = arm.filter((s, i) => !(i >= firstDef() && s.k === 'assign' && names.has(s.name)));
|
|
170
479
|
return { defs, keep };
|
|
171
480
|
}
|
|
172
481
|
|
|
@@ -176,9 +485,81 @@ function substitute(s: Stmt, defs: ReadonlyMap<string, Expr>): Stmt {
|
|
|
176
485
|
return mapStmtExprs(s, rec);
|
|
177
486
|
}
|
|
178
487
|
|
|
488
|
+
/** One arm with `join` pushed into it, and how many COPIES of the join that took — the count the
|
|
489
|
+
* caller needs to prove every definition of every merge name was consumed. One arm, one copy.
|
|
490
|
+
*
|
|
491
|
+
* The value guard lives here rather than at the call site because the values it judges are the
|
|
492
|
+
* ones THIS arm moves: an effectful or volatile value, or one reading another merge name, is a
|
|
493
|
+
* value the substitution may not relocate to the arm's end. Same set of values as before, asked
|
|
494
|
+
* one arm earlier. */
|
|
495
|
+
function pushJoin(
|
|
496
|
+
arm: Stmt[],
|
|
497
|
+
names: ReadonlySet<string>,
|
|
498
|
+
declared: ReadonlySet<string>,
|
|
499
|
+
join: Joinable,
|
|
500
|
+
sfn: SFn,
|
|
501
|
+
gates: UnmergeGates,
|
|
502
|
+
): { arm: Stmt[]; used: number } | null {
|
|
503
|
+
const here = armDefs(arm, names, declared, gates.arm ?? UNMERGE_ARM_GATES);
|
|
504
|
+
if (here !== null) {
|
|
505
|
+
for (const v of here.defs.values()) {
|
|
506
|
+
if (firstRejection(gates.value ?? UNMERGE_VALUE_GATES, { value: v, names, sfn }) !== null) {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return { arm: [...here.keep, substitute(join, here.defs)], used: 1 };
|
|
511
|
+
}
|
|
512
|
+
// NOT a terminal arm. It is a RUNG when its LAST statement is an `if` — then the copy belongs
|
|
513
|
+
// one level down, in that `if`'s own arms, and the statements before it are untouched: they run
|
|
514
|
+
// before the rung is entered, so no value this moves crosses them.
|
|
515
|
+
//
|
|
516
|
+
// THE SHAPE ADMITTED IS ANY NESTED `if` TREE, not only a right-nested else-if chain. Nothing
|
|
517
|
+
// here restricts the recursion to one side, and nothing should: the soundness argument is "every
|
|
518
|
+
// path out leaves through exactly one terminal arm", which a BALANCED tree satisfies as fully as
|
|
519
|
+
// a chain — `if (c) { if (d) A else B } else { if (e) C else D }` fires and takes four copies.
|
|
520
|
+
// Call it a ladder because that is what the corpus holds, not because the code tests for one.
|
|
521
|
+
//
|
|
522
|
+
// NO CAP, and none is wanted: the copy count is exactly the number of TERMINAL ARMS, so the work
|
|
523
|
+
// and the emitted source are LINEAR in the subtree the recursion walks — a depth-6 balanced tree
|
|
524
|
+
// is 64 leaves and 64 copies, the same one-copy-per-path the two-arm case makes. The corpus
|
|
525
|
+
// maximum is 5 (`synthetic:armcb`). A refusal above some arm count would be a gate with no
|
|
526
|
+
// inhabitant, which this file's own standard rejects.
|
|
527
|
+
//
|
|
528
|
+
// REFUSES when the tail is anything else, and THIS REFUSAL HAS INHABITANTS — do not read the
|
|
529
|
+
// success count as evidence about it. Instrumented at this return and re-run over the synthetic
|
|
530
|
+
// agbcc tier (281 rows, exit 0): 56 firings in 2 functions — 40 on an `assign` tail (32 in
|
|
531
|
+
// `maskchain`, 8 in `dmascope2`) and 16 on an EMPTY arm (`maskchain`). `dmascope2` is not one of
|
|
532
|
+
// the four rows that reach the ladder SUCCESSFULLY, so it appears in no success count at all.
|
|
533
|
+
// Only `while` and `switch` tails are unwitnessed; a plain statement is not. That episode is the
|
|
534
|
+
// instrument evidence this pass's tables were converted on, and the two counts it produced are
|
|
535
|
+
// now the two `UNMERGE_RUNG_GATES` ids.
|
|
536
|
+
//
|
|
537
|
+
// An EMPTY arm is delivered its refusal HERE, not by `armDefs`: `armDefs` declines it first
|
|
538
|
+
// (`names` is never empty, so no run of statements in an empty arm can supply it) and the tail
|
|
539
|
+
// check is what turns that decline into the site's. Same for an arm whose statements simply do
|
|
540
|
+
// not define the names.
|
|
541
|
+
if (firstRejection(gates.rung ?? UNMERGE_RUNG_GATES, { arm }) !== null) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
// The gate above owns the ATTRIBUTION; this owns the TYPE. They are not the same job: both rung
|
|
545
|
+
// gates are `sound: false`, so `ablateHeuristic` sanctions a shipped table without
|
|
546
|
+
// `tail-is-not-an-if`, and a cast here would then read `.then` off an `assign` — a TypeError
|
|
547
|
+
// inside `rank.ts`'s `try { pf.apply(sfn) } catch {}`, i.e. a silent zero-candidate decline.
|
|
548
|
+
const last = arm[arm.length - 1];
|
|
549
|
+
if (last === undefined || last.k !== 'if') {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const t = pushJoin(last.then, names, declared, join, sfn, gates);
|
|
553
|
+
const e = pushJoin(last.else, names, declared, join, sfn, gates);
|
|
554
|
+
if (t === null || e === null) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
return { arm: [...arm.slice(0, -1), { ...last, then: t.arm, else: e.arm }], used: t.used + e.used };
|
|
558
|
+
}
|
|
559
|
+
|
|
179
560
|
/** The tree with every eligible join statement pushed back into its arms, or null when no site
|
|
180
|
-
* qualified — the
|
|
181
|
-
export function unmergeJoins(sfn: SFn): SFn | null {
|
|
561
|
+
* qualified — the variation declines rather than re-emitting the default spelling. */
|
|
562
|
+
export function unmergeJoins(sfn: SFn, gates: UnmergeGates = {}): SFn | null {
|
|
182
563
|
const mentions = localMentions(sfn);
|
|
183
564
|
const localNames = new Set(sfn.locals.map((l) => l.name));
|
|
184
565
|
// Locals AND params — both name an automatic object, and an assignment to either is the write
|
|
@@ -187,42 +568,85 @@ export function unmergeJoins(sfn: SFn): SFn | null {
|
|
|
187
568
|
const declaredNames = new Set([...localNames, ...sfn.params.map((p) => p.name)]);
|
|
188
569
|
const consumed = new Set<string>();
|
|
189
570
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
571
|
+
/** The join's reads, split into the merge temps a rewrite would substitute and the names the
|
|
572
|
+
* arms write that it could not. ONE walk, because both gates read the same classification and
|
|
573
|
+
* the site's first gate may refuse before either is asked. */
|
|
574
|
+
const classify = (iff: Extract<Stmt, { k: 'if' }>, join: Joinable) => {
|
|
194
575
|
const read = namesRead(join, localNames);
|
|
195
576
|
const written = new Set(
|
|
196
577
|
[...iff.then, ...iff.else].flatMap((s) => [...walkStmts([s])].filter((x) => x.k === 'assign').map((x) => x.name)),
|
|
197
578
|
);
|
|
198
579
|
const merge = new Set<string>();
|
|
580
|
+
const unsubstitutable = new Set<string>();
|
|
199
581
|
for (const n of read) {
|
|
200
582
|
const m = mentions.get(n);
|
|
201
|
-
|
|
583
|
+
// `written.has(n)` is a CANDIDATE condition, not only the bystander test, and it ADMITS
|
|
584
|
+
// sites an arity gate alone declines. A candidate the arms cannot define refuses the WHOLE
|
|
585
|
+
// SITE instead of being ignored, and `m.assigns` is `localMentions`'s FUNCTION-WIDE count,
|
|
586
|
+
// never arm-scoped — so without this conjunct a name assigned only OUTSIDE the arms reaches
|
|
587
|
+
// the candidate set and sinks the site. Measured, against the same pass with the conjunct
|
|
588
|
+
// removed:
|
|
589
|
+
//
|
|
590
|
+
// n = 1; n = 2; if (c) x = 1; else x = 2; n[0] = x; without: NULL · here: FIRES
|
|
591
|
+
// y = 1; y = 2; if (c) p = a; else p = b; *p = y; without: NULL · here: FIRES
|
|
592
|
+
//
|
|
593
|
+
// (`y` is a bystander read carried into both copies, `p` is the merge; nothing `y` reads
|
|
594
|
+
// moves, because the copy is always LAST in its arm.) Both admissions are pinned as RULE
|
|
595
|
+
// tests in unmerge.test.ts.
|
|
596
|
+
if (m && written.has(n) && m.assigns >= 2 && readsOf(m) === 1 && m.addrTaken === 0) {
|
|
202
597
|
merge.add(n);
|
|
203
598
|
} else if (written.has(n)) {
|
|
204
|
-
|
|
599
|
+
unsubstitutable.add(n);
|
|
205
600
|
}
|
|
206
601
|
}
|
|
207
|
-
|
|
602
|
+
return { merge, unsubstitutable };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const unmergeAt = (iff: Extract<Stmt, { k: 'if' }>, join: Joinable): Stmt | null => {
|
|
606
|
+
let split: { merge: Set<string>; unsubstitutable: Set<string> } | undefined;
|
|
607
|
+
const of = () => (split ??= classify(iff, join));
|
|
608
|
+
const site: UnmergeSite = {
|
|
609
|
+
iff,
|
|
610
|
+
get merge() {
|
|
611
|
+
return of().merge;
|
|
612
|
+
},
|
|
613
|
+
get unsubstitutable() {
|
|
614
|
+
return of().unsubstitutable;
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
if (firstRejection(gates.site ?? UNMERGE_SITE_GATES, site) !== null) {
|
|
208
618
|
return null;
|
|
209
619
|
}
|
|
210
|
-
const
|
|
211
|
-
const
|
|
620
|
+
const merge = of().merge;
|
|
621
|
+
const then = pushJoin(iff.then, merge, declaredNames, join, sfn, gates);
|
|
622
|
+
const els = pushJoin(iff.else, merge, declaredNames, join, sfn, gates);
|
|
212
623
|
if (then === null || els === null) {
|
|
213
624
|
return null;
|
|
214
625
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
626
|
+
// TOTALITY: every assignment to a merge name ANYWHERE in the function has to be one of the
|
|
627
|
+
// terminal arms just rewritten, AND the counts are STALE — an earlier site inside this same
|
|
628
|
+
// tree can turn one assignment into two, leaving `mentions` short by exactly the number of
|
|
629
|
+
// arms the ladder then consumes, so the two counts agree by coincidence. Only re-reading the
|
|
630
|
+
// RESULT catches that, which is why the second gate exists and why `out` is what it reads.
|
|
631
|
+
//
|
|
632
|
+
// What this does NOT cover, stated rather than implied: a mention OUTSIDE `out`, which
|
|
633
|
+
// `readsOf(m) === 1` answers from the same stale map. A sibling site duplicating a read is the
|
|
634
|
+
// shape that would reach it; no inhabitant is known. That is the pass's standing model, not
|
|
635
|
+
// this table's job.
|
|
636
|
+
let out: Stmt | undefined;
|
|
637
|
+
const totality: UnmergeTotality = {
|
|
638
|
+
merge,
|
|
639
|
+
mentions,
|
|
640
|
+
used: then.used + els.used,
|
|
641
|
+
get out() {
|
|
642
|
+
return (out ??= { ...iff, then: then.arm, else: els.arm });
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
if (firstRejection(gates.totality ?? UNMERGE_TOTALITY_GATES, totality) !== null) {
|
|
646
|
+
return null;
|
|
219
647
|
}
|
|
220
648
|
merge.forEach((n) => consumed.add(n));
|
|
221
|
-
return
|
|
222
|
-
...iff,
|
|
223
|
-
then: [...then.keep, substitute(join, then.defs)],
|
|
224
|
-
else: [...els.keep, substitute(join, els.defs)],
|
|
225
|
-
};
|
|
649
|
+
return totality.out;
|
|
226
650
|
};
|
|
227
651
|
|
|
228
652
|
const list = (xs: Stmt[]): Stmt[] => {
|