@asmlift/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -167
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +194 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +54 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +337 -2
  16. package/src/ir/opcodes.ts +140 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/argbase.ts +2 -1
  24. package/src/l3/ast.ts +464 -57
  25. package/src/l3/basecse.ts +664 -76
  26. package/src/l3/coalesce.ts +429 -43
  27. package/src/l3/dce.ts +31 -9
  28. package/src/l3/gates.ts +21 -0
  29. package/src/l3/hoist.ts +293 -14
  30. package/src/l3/homesplit.ts +285 -0
  31. package/src/l3/initfirst.ts +301 -0
  32. package/src/l3/inlinebase.ts +193 -0
  33. package/src/l3/mentions.ts +113 -0
  34. package/src/l3/mulfirst.ts +42 -0
  35. package/src/l3/nearbase.ts +152 -0
  36. package/src/l3/offmember.ts +371 -0
  37. package/src/l3/parkfirst.ts +96 -0
  38. package/src/l3/pollguard.ts +154 -0
  39. package/src/l3/ptrfield.ts +227 -0
  40. package/src/l3/regspell.ts +110 -85
  41. package/src/l3/reindex.ts +715 -78
  42. package/src/l3/scopebase.ts +644 -218
  43. package/src/l3/sinkinit.ts +40 -0
  44. package/src/l3/slotorder.ts +123 -0
  45. package/src/l3/storage.ts +48 -0
  46. package/src/l3/symbol-refs.ts +41 -8
  47. package/src/l3/tailmerge.ts +15 -0
  48. package/src/l3/typing.ts +198 -9
  49. package/src/l3/unmerge.ts +263 -0
  50. package/src/l3/unreduce.ts +971 -0
  51. package/src/l3/volatileptr.ts +207 -0
  52. package/src/l3/volatileval.ts +130 -0
  53. package/src/l3/volstore.ts +229 -0
  54. package/src/l3/zerosub.ts +62 -0
  55. package/src/pattern/engine.ts +236 -13
  56. package/src/pipeline.ts +157 -56
  57. package/src/proto.ts +112 -14
  58. package/src/raise/arrays.ts +6 -1
  59. package/src/raise/divpow2.ts +2 -2
  60. package/src/raise/globalshape.ts +1038 -0
  61. package/src/raise/gvn.ts +33 -18
  62. package/src/raise/latch.ts +126 -0
  63. package/src/raise/memberarrays.ts +594 -0
  64. package/src/raise/narrow.ts +124 -0
  65. package/src/raise/narrowlocal.ts +556 -0
  66. package/src/raise/paramwidth.ts +179 -0
  67. package/src/raise/pre-recovery.ts +97 -14
  68. package/src/raise/recover.ts +56 -23
  69. package/src/raise/retsink.ts +210 -10
  70. package/src/raise/shortcircuit.ts +474 -74
  71. package/src/raise/struct-arrays.ts +19 -2
  72. package/src/raise/structs.ts +33 -3
  73. package/src/rank-axes.ts +630 -0
  74. package/src/rank-declare.ts +256 -0
  75. package/src/rank.ts +1723 -272
  76. package/src/structure/analysis.ts +1392 -141
  77. package/src/structure/bitfields.ts +332 -0
  78. package/src/structure/globalaccess.ts +274 -0
  79. package/src/structure/hazards.ts +411 -20
  80. package/src/structure/loops.ts +2 -49
  81. package/src/structure/namecoalesce.ts +435 -0
  82. package/src/structure/structure.ts +2678 -526
  83. package/src/structure/switch-recover.ts +616 -144
  84. package/src/symbols.ts +62 -1
  85. package/src/target.ts +367 -24
  86. package/src/trace.ts +111 -32
@@ -0,0 +1,971 @@
1
+ // L3 re-spelling lever: UN-REDUCE a loop-carried accumulator — delete `v = INIT; … v = v + K;`
2
+ // and spell each read as the closed form `INIT[start := counter]`.
3
+ //
4
+ // v0 = (a0 << 6) + a1; while (v1 <= 31) {
5
+ // while (v1 <= 31) { ⇒ *(s32 *)REG = (v1 << 6) + a1;
6
+ // *(s32 *)REG = v0; v1 = v1 + 1;
7
+ // v0 = v0 + 64; v1 = v1 + 1; } }
8
+ //
9
+ // WHY IT IS A SPELLING AND NOT A FIX. Strength reduction is a compiler pass, so the accumulated
10
+ // form is what the ASM shows whichever form the source had — a source `a[i]` and a source
11
+ // `p = a; … p++` compile to the same induction variable. asmlift recovers the reduced form
12
+ // because that is what the machine ran; the un-reduced form is the other pre-image, and the differ
13
+ // referees. (l3/reindex.ts makes the same argument for a POINTER WALK; this is its scalar-value
14
+ // sibling, and the two do not overlap — `reindexWalks` refuses a function with no pointer local or
15
+ // param.)
16
+ //
17
+ // WHAT IT BUYS, and it is not readability. A compiler-created giv init is emitted by
18
+ // `emit_iv_add_mult` at `loop_start` (gcc/loop.c:4761, inserted at :6985) — during
19
+ // `strength_reduce`, which agbcc runs AFTER `move_movables` hoists the loop invariants
20
+ // (gcc/loop.c:1151 then :1173). Both insert immediately before the loop, so the giv's init lands
21
+ // BELOW everything the invariant hoist put there. No C statement can reach that slot: statement
22
+ // order forces a user-written assignment above the whole preheader. Measured on
23
+ // synthetic:dmafill, holding the rest fixed — a plain statement before the loop scores 19, the
24
+ // same statement under an explicit guard 15, and letting the compiler create the giv 0.
25
+ //
26
+ // ONE RELATION, TWO SPELLINGS — and getting that the right way round is what keeps a third
27
+ // spelling from becoming a third function. From the driver's two facts alone — `acc` starts at
28
+ // `INIT` and is stepped by the constant `k`; `ctr` starts at `start` and is stepped by the
29
+ // constant `d` — the identity
30
+ //
31
+ // acc(ctr) = INIT + (ctr - start) * (k / d)
32
+ //
33
+ // follows, and it says NOTHING about the init's shape. `rec` is not a second relation: it is a
34
+ // prettier spelling of that same value, and its `+`-spine walk exists precisely to prove that
35
+ // `INIT[start := ctr]` equals it. `relateFolded` is the identity's degenerate corner written out.
36
+ //
37
+ // WHICH SPELLING APPLIES IS DECIDED BY CONSTANT FOLDING. The substitution needs the counter's
38
+ // start to still be present in the init. A source `for (i = 0; …) use(base + (i << 6))` gives the
39
+ // giv the init `base + (0 << 6)`, and the compiler folds that to `base` long before any of it
40
+ // reaches the asm — the start term is GONE and there is nothing to substitute for. Every corpus
41
+ // inhabitant of the substitutional spelling starts its counter at a PARAMETER (`dmafill`'s `lo`),
42
+ // which is exactly why none of them needed the other: a symbolic start cannot fold.
43
+ // `synthetic:offloop`, `offgiv`, `offgiv2` and `offgiv3` are the shape that does, and for them
44
+ // `relateFolded` carries the ADDITIVE form — `INIT + (ctr << s)`, the init kept whole — as a
45
+ // FALLBACK reached only where the substitutional rule already declined.
46
+ //
47
+ // AND ITS REFUSALS ARE NOT OF A KIND. `relateFolded`'s own three are SPELLINGS of a form that is
48
+ // already sound: the identity holds, this file just does not write `(ctr - start) * (k / d)`, so
49
+ // a round that needs `d = 2` widens it rather than writing a third function beside it. The one
50
+ // SOUNDNESS question is a start that is not a constant at all — it would put a NEW read of the
51
+ // start expression at every use — and `relate` refuses that before `relateFolded` is reached,
52
+ // because none of the five re-evaluation gates asks about it.
53
+ //
54
+ // THE ARITHMETIC. The rewrite rests on one invariant: at every read, `acc == g(ctr)`, where `g` is
55
+ // the init expression with the counter's own start substituted by the counter. It holds at entry
56
+ // because `ctr == start` there, and is preserved because `g` is linear with exactly the
57
+ // accumulator's stride — `g(x + D) - g(x) = K`, checked structurally rather than assumed
58
+ // (`relate`: a `+` spine down to a shift by `s` with `K = D · 2^s`, a product by `M` with
59
+ // `K = D · M`, or the bare counter with `K = D`; any other enclosing operator refuses, because
60
+ // under it the stride is not `K`). Every way the invariant could be broken DECLINES: another
61
+ // write to either name, an address taken, a read outside the loop, a read at or below the
62
+ // accumulator's own step (where it stands one stride ahead of the counter), a `continue` (which
63
+ // runs a `for`'s increment but skips the body's tail), and a step this file cannot relate.
64
+ //
65
+ // THE RE-EVALUATION is the dangerous half, because the closed form is spelled at each read and
66
+ // carries whatever the init READ with it. All five gates below read the ORIGINAL init rather than
67
+ // the substituted form: the init STATEMENT is deleted, so anything inside the counter-start
68
+ // subterm the substitution replaces would be DROPPED rather than moved, which no gate reading the
69
+ // closed form could see.
70
+ //
71
+ // AND THEY ALL ASK ABOUT THE MOTION REGION, which is not the loop. The init is deleted where it
72
+ // stood and re-evaluated at every read, so the distance the values travel is everything between
73
+ // the two — and BOTH ENDS of that span move. The counter's start is the other anchor, because the
74
+ // substitution reads the closed form through it: taken after the init, off a value that has since
75
+ // changed, the closed form is off by exactly that change. So the region opens at whichever anchor
76
+ // comes FIRST and runs to the loop's last iteration, the loop entering whole so the walk reaches
77
+ // its condition and a `for`'s own init and inc as well as its body.
78
+ // • INIT-LOOP-VAR — the init names something the region assigns, so re-evaluating it below that
79
+ // assignment reads a different value. Three shapes, all of which compiled, scored, and
80
+ // computed something else: `for (a0 = a1; …; a0 = a0 + 1)` over `acc = (a1 << 6) + a0` closed
81
+ // to `(a0 << 6) + a0` (a `for`'s counter is stepped in `loop.inc`, which is not in
82
+ // `loop.body`); `acc = (a0 << 6) + a1; a1 = a1 + 100;` above the loop; and the counter's start
83
+ // taken last, `acc = (a0 << 6) + a1; a0 = a0 + 1; i = a0;`. A semantic fuzz over both trees on
84
+ // the same inputs — 18 seeds × 2 generator modes, the second of which emits a statement
85
+ // between the anchors, 1.08M trees and 111707 fired candidates — finds no divergence; asking
86
+ // the LOOP alone put 351 of every 2940 firings on this one hole.
87
+ // • INIT-NAME-ESCAPES — the other half of the same question, for a write no assignment spells.
88
+ // A name whose address the function hands out can be rewritten by a callee or through a
89
+ // stashed pointer, which `assignCount` cannot see: `w = 1; i = a0; acc = (a0 << 6) + w;` over
90
+ // a loop calling `bump(&w)` re-reads `w` once per iteration and picks up whatever the callee
91
+ // left. Refused function-wide, because a stashed pointer outlives the statement that made it.
92
+ // • MOVED-EFFECT — a call or a marker would run once per read instead of once. Refused.
93
+ // • MOVED-VOLATILE — a `volatile` access is one the source pinned precisely so it would not be
94
+ // duplicated or moved. Refused. No corpus row reaches it today (nothing on the base spelling
95
+ // this lever rides carries a qualifier on a READ), so it is guarded by its unit test alone.
96
+ // • MOVED-READ-ALIASABLE — an ordinary memory read moved down the region sees whatever the
97
+ // region wrote. asmlift can only rule that out for writes it can NAME, so a moved read is
98
+ // admitted on one configuration: every write the region evaluates goes to a compile-time
99
+ // constant address INSIDE the target's declared device-register window, and every read lands
100
+ // OUTSIDE it. A device register is not an object a C program declares (target.ts
101
+ // `deviceRegisters`), so no STORE the C performs there can change what an ordinary read sees;
102
+ // the read-side half is what keeps a DEVICE read from being
103
+ // duplicated into N of them, and it resolves an access's WHOLE address where the subscripts
104
+ // are constant, falling back to the chain's root only where they are not (a read rooted at
105
+ // 0x03FFFFF0 whose element is 0x04000010 is a device read, and the root alone does not say
106
+ // so). Anything else — a store through a local pointer, a call, a read rooted at no constant
107
+ // at all — REFUSES, which is `ir/alias.ts`'s posture ("unknown BARS") applied where there is
108
+ // no symbol map to resolve a name through.
109
+ //
110
+ // AND THE PREMISE THAT IS NOT ENOUGH, which this file recorded as a fact about the board and which
111
+ // is FALSE. "A write to a hardware register is not a write to any object a C program declares" is
112
+ // true, and it does not finish the argument: a DMA controller READS a control word and then WRITES
113
+ // ordinary memory on the program's behalf. On the GBA, storing `0x84000020` to `DMA3CNT`
114
+ // (0x040000DC) starts a 32-word transfer into `[DMA3DAD]` — and every row this lever reaches
115
+ // drives exactly that register. Modelled and executed, the admitted candidate turns a clean walk
116
+ // over a destination table into wild writes: the first transfer clobbers the table the init reads,
117
+ // and every later iteration recomputes its destination from the garbage.
118
+ //
119
+ // So the function's device writes are checked against `capabilities.deviceMemoryWriters` — the
120
+ // four DMA channel-enable halfwords on this board — and a moved read under one is NOT admitted on
121
+ // the gates alone. That scan is the WHOLE PREFIX up to and including the loop, wider than the
122
+ // motion region every other gate reads, because a repeating transfer keeps writing for as long as
123
+ // it is enabled: where the arming store STANDS says nothing about when the device writes, and a
124
+ // channel armed above the init is as asynchronous as one armed inside the loop.
125
+ //
126
+ // Such a read is admitted only where the differ PROVES it: `needsProof` rides out with the
127
+ // candidate, and rank.ts publishes such a spelling only at a byte-exact score,
128
+ // withholding it (loudly, in `RankedResult.withheld`) at every other. That is not a softening of
129
+ // the rule but the only evidence that settles it — a candidate whose object equals the target's
130
+ // IS the program, whatever a gate could have proved about it, and the one corpus inhabitant
131
+ // (synthetic:dmaptrsrc) is exactly that: a byte-exact match whose reference source really does
132
+ // read `gBg[bg].pTilemap` inside the loop. Barring it instead costs that match and buys nothing —
133
+ // the sound alternative, the read hoisted into a local above the loop, scores 16, because a C
134
+ // statement lands above the loop's ENTRY GUARD while the compiler's own hoist lands below it.
135
+ //
136
+ // AND THE STRIDE'S UNITS, which is the half of the arithmetic the invariant above hides. `k` is
137
+ // read off `acc = acc + K`, so it counts in the units of the ACCUMULATOR's declared type: on a
138
+ // `u16 *` a step of 32 advances 64 BYTES. The closed form spells that stride onto the INIT, whose
139
+ // `+` scales by whatever the INIT's own C type says. Where the two disagree the candidate
140
+ // addresses the wrong byte, compiles clean, and carries no marker — structure.ts's `bytePtr`
141
+ // states the same rule from the other end ("a `u16 *` walked by a computed offset addresses TWICE
142
+ // the intended byte, and nothing downstream can see the error"). `stride-units` refuses unless
143
+ // both scales are KNOWN and equal; a narrow integer accumulator is the same question in the other
144
+ // direction, since `u16 acc` wraps at 65536 where `init + (i << 6)` does not.
145
+ //
146
+ // THAT GATE HAS NO BENCHMARK REACH AT ALL, and neither tier can see it. Censused at the
147
+ // `firstRejection` call site over both — 750 synthetic trees and all 252 real-tier rows, the real
148
+ // tier being the SYMBOL-MAPPED configuration since every row carries its authored map —
149
+ // `unitsDisagree` is true on NO row in either, and `/unreduce` fires on no real-tier row at all,
150
+ // so no zero-flip gate reaches this lever. Its one known inhabitant is outside the benchmark:
151
+ // klonoa's `UpdateHUDTimePanel`, where WITH a symbol map the accumulator lifts `u16 *` against an
152
+ // integer init and the gate refuses it, and with RAW ADDRESSES the same asm lifts all-integer and
153
+ // the candidate is correct and survives (`50335396 + (v15 << 6)`, 64 bytes an iteration, which is
154
+ // the ROM's own `adds r1, #0x40`). Same assembly, same loop, same stride — a raw-address sweep is
155
+ // BLIND to the defect and reports the lever as correct. The checkout sweep behind that datum
156
+ // covered klonoa's 467 functions in BOTH configurations; a second checkout was swept raw-only and
157
+ // found nothing, and since raw-only is the blind arm that null result carries no weight. Only the
158
+ // klonoa half of the sweep is evidence, and it is quoted here without the other.
159
+ //
160
+ // SCOPE, stated because a decline outside it names no gate and so looks exactly like a gate that
161
+ // refused. This pass walks TOP-LEVEL loops only: the counter's start and the accumulator's init are
162
+ // found by scanning `sfn.body` above the loop, which is a flat list. A loop under an `if` — or
163
+ // inside another loop — is never reached, even when both statements do stand above it in the
164
+ // enclosing block. Measured over the corpus in both symbol-map configurations: of 834 trees, 189
165
+ // carry a loop, 98 carry a TOP-LEVEL one, and 91 carry only nested ones — `arraysum`, `memcpy1`,
166
+ // `revarr`, `dotprod`, `findfirst`, `mergeloop` and `synthetic:dmanest` among them. On klonoa's
167
+ // `LoadBGTilemapData` the count is zero, over all 1344 trees its enumeration produces: a decline
168
+ // there names no gate, and a reader will attribute one anyway. Widening the scan is a REACH change
169
+ // and belongs to a row that demands it, not to a soundness pass.
170
+ //
171
+ // AND THE TABLE ANSWERS FOR A SMALLER POPULATION STILL. Censused at the `firstRejection` call
172
+ // site over the benchmark's two tiers, counting FIRST rejections rather than reach — short-circuit
173
+ // order hides a later gate behind an earlier one. Of 750 synthetic trees, 21 (loop, accumulator)
174
+ // pairs reach the table: 8 admit, `acc-live-outside` 7, `acc-read-at-step` 4, `unrelated-start` 2.
175
+ // Of 252 real-tier rows, 6 reach it: `acc-live-outside` 3, and one each of `acc-multi-assign`,
176
+ // `acc-read-at-step` and `unrelated-start`. FOUR of the twenty gates decide anything; the rest —
177
+ // `moved-read-aliasable`, which the device-memory argument above rests on, among them — are held
178
+ // by their unit tests and by the fuzz, and by nothing either tier has yet shown them.
179
+ //
180
+ // AND ITS SIBLING. `l3/reindex.ts` un-reduces a POINTER WALK over the same argument, with the same
181
+ // shape of gate table, and it already handles the `if (guard) do {} while` rotation this file
182
+ // cannot see. The split is by the induction variable's TYPE rather than by the question asked, and
183
+ // it costs the duplication a reader will notice — `counter-roles` ≈ `acc-multi-assign` +
184
+ // `acc-live-outside`, `walk-stride` ≈ `scale-mismatch`, `body-exit` ≈ `continue-in-body`. Folding
185
+ // them into one pass over one table is a real improvement and a real refactor; the gate this file
186
+ // was actually MISSING from that table (`volatile-counter`) is in it now, which is the part that
187
+ // could not wait.
188
+ //
189
+ // Nothing qualifying ⇒ decline (null), never a duplicate of the primary.
190
+ import { type IrType } from '../ir/types';
191
+ import { cellAddress, inRange, rootConst } from './address';
192
+ import {
193
+ type Expr,
194
+ type SFn,
195
+ type Stmt,
196
+ exprEquals,
197
+ exprHasEffect,
198
+ exprReadsVolatile,
199
+ mapExprChildren,
200
+ stmtChildren,
201
+ stmtExprs,
202
+ walkExprs,
203
+ } from './ast';
204
+ import { type Gate, firstRejection } from './gates';
205
+ import { type VarTypes, declaredTypes, exprCType, ptrElemBytes } from './typing';
206
+
207
+ /** Why `relate` refused — one tag per question it asks. `relate` is the only place that knows
208
+ * which question failed, so it says so; the five gates below test one tag each. */
209
+ type RelDecline = 'scale-mismatch' | 'unrelated-start' | 'nonzero-start' | 'step-ratio' | 'stride-not-shift';
210
+
211
+ /** `relate`'s answer: the closed form, or the reason there is none. */
212
+ type Relation = { readonly ok: Expr } | { readonly declined: RelDecline };
213
+
214
+ /** One (loop, accumulator) pair as the gates read it. */
215
+ export interface AccCtx {
216
+ /** the accumulator is assigned exactly twice: its init above the loop and its step inside */
217
+ assigns: number;
218
+ addrTaken: boolean;
219
+ /** the local's DECLARATION carries an asm fact — a qualifier, a frame slot, an `undef` */
220
+ pinned: boolean;
221
+ /** the accumulator is mentioned outside the loop, other than by its own init */
222
+ liveOutside: boolean;
223
+ /** the accumulator is mentioned at or below its own step, or in the loop's control parts */
224
+ readAtOrBelowStep: boolean;
225
+ /** the counter is assigned exactly twice: its init above the loop and its step inside */
226
+ counterAssigns: number;
227
+ counterAddrTaken: boolean;
228
+ /** the counter local carries a volatility qualifier — every closed form is a new read of it */
229
+ counterVolatile: boolean;
230
+ /** a `continue` anywhere in the body */
231
+ hasContinue: boolean;
232
+ /** the accumulator's own step and its init count in DIFFERENT C units, so the closed form's
233
+ * `+` would scale by the wrong element size (or by none) */
234
+ unitsDisagree: boolean;
235
+ /** WHY `relate` refused, or null where it produced a closed form — one tag per question it
236
+ * asks, so each of the five relation gates tests exactly one reason. */
237
+ declined: RelDecline | null;
238
+ /** the init reads a name something in the motion region assigns */
239
+ initLoopVar: boolean;
240
+ /** the init reads a name whose address the function hands out */
241
+ initNameEscapes: boolean;
242
+ /** the closed form contains a call or a marker */
243
+ movedEffect: boolean;
244
+ /** the closed form reads a `volatile` object */
245
+ movedVolatile: boolean;
246
+ /** the closed form reads memory the region's own writes cannot be told apart from */
247
+ movedAliasable: boolean;
248
+ }
249
+
250
+ export const UNREDUCE_GATES: readonly Gate<AccCtx>[] = [
251
+ {
252
+ id: 'acc-multi-assign',
253
+ why: 'a name written anywhere but its init and its step is not one induction sequence',
254
+ sound: true,
255
+ guardedBy: 'unreduce.test.ts: a third assignment to the accumulator declines',
256
+ rejects: (c) => c.assigns !== 2,
257
+ },
258
+ {
259
+ id: 'acc-addr-taken',
260
+ why: 'a deleted local has no address to take',
261
+ sound: true,
262
+ guardedBy: 'unreduce.test.ts: an address-taken accumulator declines',
263
+ rejects: (c) => c.addrTaken,
264
+ },
265
+ {
266
+ id: 'acc-pinned',
267
+ why: 'a declaration that carries an asm fact cannot be deleted without dropping the fact',
268
+ sound: true,
269
+ guardedBy: 'unreduce.test.ts: a pinned accumulator declines, on every pin a local can carry',
270
+ rejects: (c) => c.pinned,
271
+ },
272
+ {
273
+ id: 'stride-units',
274
+ why: 'a step counted in the accumulator’s own units is not the init’s, and the closed form would scale by the wrong one',
275
+ sound: true,
276
+ guardedBy: 'unreduce.test.ts: an accumulator whose step counts different units than its init declines',
277
+ rejects: (c) => c.unitsDisagree,
278
+ },
279
+ {
280
+ id: 'acc-live-outside',
281
+ why: 'a read outside the loop wants a value the closed form no longer computes',
282
+ sound: true,
283
+ guardedBy: 'unreduce.test.ts: an accumulator read after the loop declines',
284
+ rejects: (c) => c.liveOutside,
285
+ },
286
+ {
287
+ id: 'acc-read-at-step',
288
+ why: 'below its own step the accumulator is one stride ahead of the counter',
289
+ sound: true,
290
+ guardedBy: 'unreduce.test.ts: a read below the step declines',
291
+ rejects: (c) => c.readAtOrBelowStep,
292
+ },
293
+ {
294
+ id: 'counter-multi-assign',
295
+ why: 'a counter written elsewhere breaks the relation the closed form is read through',
296
+ sound: true,
297
+ guardedBy: 'unreduce.test.ts: a counter assigned inside an arm declines',
298
+ rejects: (c) => c.counterAssigns !== 2,
299
+ },
300
+ {
301
+ id: 'counter-addr-taken',
302
+ why: 'an address-taken counter can be stepped by anything the address reaches',
303
+ sound: true,
304
+ guardedBy: 'unreduce.test.ts: an address-taken counter declines',
305
+ rejects: (c) => c.counterAddrTaken,
306
+ },
307
+ {
308
+ // The accumulator's pin is `acc-pinned` above; this is the COUNTER's, and it is a different
309
+ // fact: substitution puts the counter where every accumulator read used to be, so a loop that
310
+ // read it once per iteration reads it once per USE. For a volatile object the access COUNT is
311
+ // the semantics (l3/volatileval.ts states the same rule), which is why l3/reindex.ts's
312
+ // `volatile-counter` sibling exists.
313
+ id: 'counter-volatile',
314
+ why: 'the closed form re-reads the counter at every use, and a volatile object counts its reads',
315
+ sound: true,
316
+ guardedBy: 'unreduce.test.ts: a volatile counter declines',
317
+ rejects: (c) => c.counterVolatile,
318
+ },
319
+ {
320
+ id: 'continue-in-body',
321
+ why: 'a `continue` runs a `for`’s increment but skips the body’s tail, desynchronizing the pair',
322
+ sound: true,
323
+ guardedBy: 'unreduce.test.ts: a `continue` in the body declines',
324
+ rejects: (c) => c.hasContinue,
325
+ },
326
+ // ── the five refusals `relate` can produce, one gate each ─────────────────────────────────
327
+ //
328
+ // These five PARTITION the non-null `declined` tags, and each is keyed on the decision `relate`
329
+ // took rather than on a tree fact that stands for it (`the start is a constant`): a proxy makes
330
+ // the `why` drift the moment a second reason shares the fact.
331
+ {
332
+ id: 'scale-mismatch',
333
+ why: 'an init whose scale does not carry the accumulator’s whole stride proves nothing',
334
+ sound: true,
335
+ guardedBy: 'unreduce.test.ts: a stride that does not match the init’s scale declines',
336
+ rejects: (c) => c.declined === 'scale-mismatch',
337
+ },
338
+ {
339
+ id: 'unrelated-start',
340
+ why: 'a symbolic start the init does not name exactly once leaves nothing to substitute for',
341
+ sound: true,
342
+ guardedBy: 'unreduce.test.ts: an init that never names the counter declines',
343
+ rejects: (c) => c.declined === 'unrelated-start',
344
+ },
345
+ {
346
+ id: 'nonzero-start',
347
+ why: 'a counter starting at a nonzero constant leaves a bias term this file does not spell',
348
+ sound: true,
349
+ guardedBy: 'unreduce.test.ts: a counter-free init declines unless its start is the constant 0',
350
+ rejects: (c) => c.declined === 'nonzero-start',
351
+ },
352
+ {
353
+ id: 'step-ratio',
354
+ why: 'a counter stepping by more than one leaves the ratio K/d, which is not a shift',
355
+ sound: true,
356
+ guardedBy: 'unreduce.test.ts: a counter-free init declines when the counter does not step by one',
357
+ rejects: (c) => c.declined === 'step-ratio',
358
+ },
359
+ {
360
+ id: 'stride-not-shift',
361
+ why: 'a stride that is not a constant power of two has no shift to carry it',
362
+ sound: true,
363
+ guardedBy: 'unreduce.test.ts: a counter-free init declines when the accumulator’s stride is not a power of two',
364
+ rejects: (c) => c.declined === 'stride-not-shift',
365
+ },
366
+ {
367
+ id: 'init-loop-var',
368
+ why: 'a name the region assigns reads differently once the init is evaluated below it',
369
+ sound: true,
370
+ guardedBy: 'unreduce.test.ts: an init reading a name the region assigns declines, in every part of it',
371
+ rejects: (c) => c.initLoopVar,
372
+ },
373
+ {
374
+ // `init-loop-var`'s other half. That gate reads C-LEVEL assignment, and a local whose address
375
+ // the function hands out is written where no assignment spells it — by a callee, or through a
376
+ // pointer the region stores into. The address is taken function-wide because a stashed pointer
377
+ // outlives the statement that made it.
378
+ id: 'init-name-escapes',
379
+ why: 'an address-escaped name is written where no assignment names it',
380
+ sound: true,
381
+ guardedBy: 'unreduce.test.ts: an init reading an address-escaped local declines',
382
+ rejects: (c) => c.initNameEscapes,
383
+ },
384
+ {
385
+ id: 'moved-effect',
386
+ why: 'a call or a marker in the closed form would run once per read instead of once',
387
+ sound: true,
388
+ guardedBy: 'unreduce.test.ts: a call in the init declines',
389
+ rejects: (c) => c.movedEffect,
390
+ },
391
+ {
392
+ id: 'moved-volatile',
393
+ why: 'a volatile access is one the source pinned so it would not be duplicated or moved',
394
+ sound: true,
395
+ guardedBy: 'unreduce.test.ts: a volatile read in the init declines',
396
+ rejects: (c) => c.movedVolatile,
397
+ },
398
+ {
399
+ id: 'moved-read-aliasable',
400
+ why: 'a read moved down the region sees whatever writes the region cannot be proved apart from',
401
+ sound: true,
402
+ guardedBy: 'unreduce.test.ts: a moved read declines unless the region writes only device cells',
403
+ rejects: (c) => c.movedAliasable,
404
+ },
405
+ ];
406
+
407
+ // ── the induction shapes ────────────────────────────────────────────────────────────────────
408
+
409
+ /** The BYTE scale of one unit of C arithmetic on a value of this type — what `x + 1` advances
410
+ * `x` by. A pointer scales by its pointee; a full-width integer scales by one. Everything else is
411
+ * `null` = NOT ADMISSIBLE rather than a guess: a struct/void/array pointee has no scale this file
412
+ * can name (`ptrElemBytes` returns 0 for exactly those), a narrow integer WRAPS where the closed
413
+ * form does not, and an unknown type is unknown. `stride-units` compares the accumulator's
414
+ * declared scale against the init expression's, and refuses unless both are known and equal. */
415
+ function arithScale(t: IrType | undefined): number | null {
416
+ if (t === undefined) {
417
+ return null;
418
+ }
419
+ if (t.kind === 'ptr') {
420
+ const bytes = ptrElemBytes(t.to);
421
+ return bytes > 0 ? bytes : null;
422
+ }
423
+ return t.kind === 'int' && t.width === 32 ? 1 : null;
424
+ }
425
+
426
+ /** Does this local's DECLARATION pin it against deletion? Every flag `SFn.locals` can carry, because
427
+ * each is a fact about the ASM that only the declaration states: two qualifiers (deleting a
428
+ * `volatile u16 *` local re-spells `*p = 0` as a raw cast with no qualifier on it — l3/inlinebase.ts
429
+ * carries it onto the minted cast instead, and this lever has no local left to carry anything), a
430
+ * frame home, an `undef` whose whole content is the assignment that is MISSING, and the SPILL HOMES.
431
+ *
432
+ * WHY `slots` PINS, which is not the obvious reading. Deleting a slot-carrying local does not
433
+ * mis-order the survivors: they stay a subset of one total order and rank correctly among
434
+ * themselves. What it can do is flip a REFUSAL into an ordering. `l3/slotorder.ts` refuses the whole
435
+ * function when two declared locals share one offset, because reload hands each spilled pseudo a
436
+ * fresh slot and a duplicate proves the offsets did not come from reload. Delete one sharer and the
437
+ * survivors are injective — so the ordering fires on a frame whose evidence was already known not to
438
+ * be declaration ranks, and it fires silently. Refusing to delete keeps the duplicate, and keeps the
439
+ * refusal.
440
+ *
441
+ * MEASURED, so this is a stated zero and not an assumption: instrumented at the deletion site, it
442
+ * fires on none of the three agbcc rows that spill AND lift — `spillorder`, `dma_fill_uninit`,
443
+ * `uninit_spill` — so the clause costs no candidate today. (`spill10` spills too but declines in the
444
+ * Thumb frontend, so it never reaches this pass and its zero says nothing.) It is here for the day
445
+ * one does. */
446
+ function declarationPins(l: SFn['locals'][number]): boolean {
447
+ return (
448
+ l.volatile !== undefined ||
449
+ l.pointeeVolatile !== undefined ||
450
+ l.frame !== undefined ||
451
+ l.uninit !== undefined ||
452
+ l.slots !== undefined
453
+ );
454
+ }
455
+
456
+ /** Read a constant the FRONTEND spelled as arithmetic as its VALUE. Thumb's `add rd, #imm8`
457
+ * cannot spell 256, so agbcc emits `mov #128 / lsl #1` and the recovered node is `128 << 1`.
458
+ * Every question the relation asks about a constant is about its value — the accumulator's
459
+ * stride, the counter's start, the counter's step, a product's invariant multiplier — so each is
460
+ * folded before it is read; unfolded, the same number refuses on its spelling and names a gate
461
+ * whose `why` is about the value (synthetic:offgiv3 is the row, on the stride).
462
+ *
463
+ * ONLY all-constant nodes fold, which is what keeps this from being a general simplifier: nothing
464
+ * that mentions a name is touched, so `acc-read-at-step` — which reads the same expression for
465
+ * the accumulator's own name — sees exactly the names it saw before. */
466
+ function foldConsts(e: Expr): Expr {
467
+ if (e.k !== 'bin') {
468
+ return e;
469
+ }
470
+ const l = foldConsts(e.l);
471
+ const r = foldConsts(e.r);
472
+ if (l.k !== 'const' || r.k !== 'const') {
473
+ return e;
474
+ }
475
+ // int32 arithmetic, because that is what the machine did and what the C will do
476
+ switch (e.op) {
477
+ case '+':
478
+ return { k: 'const', value: (l.value + r.value) | 0 };
479
+ case '*':
480
+ return { k: 'const', value: Math.imul(l.value, r.value) };
481
+ case '<<':
482
+ return r.value >= 0 && r.value < 32 ? { k: 'const', value: l.value << r.value } : e;
483
+ default:
484
+ return e;
485
+ }
486
+ }
487
+
488
+ /** `name = name + <expr>` as a step, or null. */
489
+ function stepOf(s: Stmt, name: string): Expr | null {
490
+ if (s.k !== 'assign' || s.name !== name || s.value.k !== 'bin' || s.value.op !== '+') {
491
+ return null;
492
+ }
493
+ const { l, r } = s.value;
494
+ return l.k === 'var' && l.name === name ? r : r.k === 'var' && r.name === name ? l : null;
495
+ }
496
+
497
+ /** THE relation check: is `init` a function of `start` whose value grows by `k` per `d` of the
498
+ * counter? Returns the closed form (init with the counter's start replaced by the counter
499
+ * variable) or null. The three accepted shapes are the three ways a compiler's own giv is
500
+ * spelled — a scaled shift, a product, and the bare index — and each is verified rather than
501
+ * assumed: the substituted subterm must be structurally the counter's start, and the stride must
502
+ * come out of the scale. */
503
+ function relate(init: Expr, start: Expr, ctr: string, k: Expr, d: number): Relation {
504
+ const kConst = k.k === 'const' ? k.value : null;
505
+ const idx = (): Expr => ({ k: 'var', name: ctr });
506
+ const holds = (e: Expr): boolean => [...subterms(e)].some((x) => exprEquals(x, start));
507
+ // The path from the init's root down to the occurrence, one node at a time. Every node on it
508
+ // must be a `+` — so the init is a SUM of the scaled counter and terms that do not mention it,
509
+ // and the whole expression's stride is the scaled term's. Any other enclosing operator refuses:
510
+ // under a `-` on the right the stride flips sign, and under a second scale it multiplies.
511
+ const rec = (x: Expr): Expr | null => {
512
+ // (a) `start << s` — the stride is `d << s`, so `k` has to be that constant
513
+ if (x.k === 'bin' && x.op === '<<' && exprEquals(x.l, start) && x.r.k === 'const') {
514
+ const sh = x.r.value;
515
+ return sh >= 0 && sh < 31 && kConst === d * 2 ** sh ? { k: 'bin', op: '<<', l: idx(), r: x.r } : null;
516
+ }
517
+ // (b) `start * M` in either order — the stride is `d · M`, checkable when `d` is 1 and `M` is
518
+ // structurally `k`, or when both are constants. The OPERAND ORDER is kept: which side a
519
+ // product's index sits on is a spelling the differ referees on its own (`/mulfirst`), so
520
+ // rebuilding it in a canonical order would answer that question here instead.
521
+ if (x.k === 'bin' && x.op === '*' && (exprEquals(x.l, start) || exprEquals(x.r, start))) {
522
+ const startLeft = exprEquals(x.l, start);
523
+ const m = foldConsts(startLeft ? x.r : x.l);
524
+ const ok = d === 1 ? exprEquals(m, k) : m.k === 'const' && kConst !== null && d * m.value === kConst;
525
+ return ok ? { k: 'bin', op: '*', l: startLeft ? idx() : m, r: startLeft ? m : idx() } : null;
526
+ }
527
+ // (c) the counter standing on its own in the sum — the stride is `d` itself
528
+ if (exprEquals(x, start)) {
529
+ return kConst === d ? idx() : null;
530
+ }
531
+ // (d) a `+` node: descend into whichever side carries the occurrence. There is exactly one.
532
+ if (x.k === 'bin' && x.op === '+') {
533
+ const left = holds(x.l);
534
+ const inner = rec(left ? x.l : x.r);
535
+ return inner === null ? null : left ? { ...x, l: inner } : { ...x, r: inner };
536
+ }
537
+ return null; // anything else between the root and the counter, and the stride is not `k`
538
+ };
539
+ // THE SUBSTITUTIONAL FORM FIRST, unchanged: it is the shipped spelling every corpus inhabitant
540
+ // of this lever rides, and trying it first makes the branch below strictly additive — it is
541
+ // reached only where the old rule already declined, so it can admit candidates but never
542
+ // re-spell one.
543
+ //
544
+ // `!== 1` — zero: the init does not depend on the counter. two: which one is the index?
545
+ const occurrences = startOccurrences(init, start);
546
+ const substituted = occurrences === 1 ? rec(init) : null;
547
+ if (substituted !== null) {
548
+ return { ok: substituted };
549
+ }
550
+ // …AND THE FOLDED FORM AS THE FALLBACK. The occurrence COUNT cannot route this on its own:
551
+ // where the start is the constant 0, every unrelated literal zero in the init — an `[0]`
552
+ // subscript, a `+ 0` — `exprEquals` the start and counts as one. `rec` never substitutes for
553
+ // such a literal (it verifies an all-`+` spine down to the occurrence at the accumulator's own
554
+ // scale, and an `index` node on that path refuses), so running it FIRST and asking about the
555
+ // start's SHAPE second misroutes nothing.
556
+ // The start's SHAPE is what routes this, so it is read as a VALUE: `128 << 1` is the constant
557
+ // 256, and taking it for a symbolic start would name a gate whose `why` says the start is one.
558
+ // `rec` above keeps matching the init's subterms against the ORIGINAL spelling, which is what
559
+ // the init carries.
560
+ const startConst = foldConsts(start);
561
+ if (startConst.k !== 'const') {
562
+ // A symbolic start cannot have folded, so the init either does not name it at all or names it
563
+ // twice (and which occurrence is the index is not decidable) — `unrelated-start`; or it names
564
+ // it once and `rec` refused the scale — `scale-mismatch`.
565
+ return { declined: occurrences === 1 ? 'scale-mismatch' : 'unrelated-start' };
566
+ }
567
+ return relateFolded(init, startConst.value, ctr, k, d);
568
+ }
569
+
570
+ /** THE OTHER SPELLING of the identity above: ADDITIVE, `INIT + (ctr << s)`, with the init kept
571
+ * whole and the scaled counter added to it. It applies where the counter's start has been folded
572
+ * out of the init and there is nothing left to substitute for (see the header). `start` is the
573
+ * start's VALUE — `relate` has already established that it is a constant.
574
+ *
575
+ * The invariant is the header's, and holds for the same reason: at entry `ctr == 0`, so
576
+ * `INIT + (0 << s) == INIT`; per iteration `ctr` grows by 1 and the closed form by `2^s`, which
577
+ * is the accumulator's own stride `k`. All five re-evaluation gates read `initStmt.value` — the
578
+ * ORIGINAL init — and that is exactly what this form re-evaluates at each read, so none of them
579
+ * needs a second reading here.
580
+ *
581
+ * SCOPE. Three refusals, each with its own tag and so its own gate and its own `why`:
582
+ * • `nonzero-start` — the start is a constant other than 0. Sound and merely unspelled: it
583
+ * wants the bias term `- start * k`, which no corpus row asks for. A start that is not a
584
+ * constant AT ALL never arrives here; `relate` answers that one, with `unrelated-start` or
585
+ * `scale-mismatch`.
586
+ * • `step-ratio` — `d` is not 1, so the closed form would carry the ratio `K / d`.
587
+ * • `stride-not-shift` — `k` is not a power of two once folded. `INIT + ctr * k` is the general
588
+ * spelling and compiles identically at agbcc (both were compiled and diffed: byte-identical),
589
+ * so a second spelling would double the fan and buy no score; the shift is what this class's
590
+ * references spell.
591
+ * All three first-reject on NO row in either tier — the census above lists the four gates that
592
+ * decide anything, and none of these is one of them — so all three are held by their unit tests
593
+ * alone, on the shipped precedent of `moved-volatile`. `offgiv3` is not backing for them: it
594
+ * MATCHes, so it rejects at no gate at all. */
595
+ function relateFolded(init: Expr, start: number, ctr: string, k: Expr, d: number): Relation {
596
+ if (start !== 0) {
597
+ return { declined: 'nonzero-start' };
598
+ }
599
+ if (d !== 1) {
600
+ return { declined: 'step-ratio' };
601
+ }
602
+ const step = k.k === 'const' ? k.value : 0;
603
+ // not a constant power of two in shift range, so no shift carries the stride
604
+ if (step <= 0 || (step & (step - 1)) !== 0 || Math.log2(step) >= 31) {
605
+ return { declined: 'stride-not-shift' };
606
+ }
607
+ const sh = Math.log2(step);
608
+ const idx: Expr = { k: 'var', name: ctr };
609
+ // `sh === 0` is the counter standing on its own — `i << 0` is the same value spelled worse, and
610
+ // `rec`'s branch (c) already writes the bare counter for the substitutional case.
611
+ const scaled: Expr = sh === 0 ? idx : { k: 'bin', op: '<<', l: idx, r: { k: 'const', value: sh } };
612
+ // A ZERO init contributes nothing and `0 + (i << 3)` is a spelling no source writes, so the
613
+ // scaled counter stands alone — this is `rec`'s branch (c) reached the other way.
614
+ // (synthetic:nestedloop:mwcc_242_81 is such an accumulator.)
615
+ //
616
+ // Otherwise the INIT stays on the LEFT: it is the base the source names, and `rec` builds the
617
+ // same shape for the same loop where the fold did not happen. Product operand order is
618
+ // `/mulfirst`'s question, not this file's.
619
+ return { ok: init.k === 'const' && init.value === 0 ? scaled : { k: 'bin', op: '+', l: init, r: scaled } };
620
+ }
621
+
622
+ /** how many times the counter's start stands as a subterm of the init. ONE is the substitutional
623
+ * case; two is ambiguous (which occurrence is the index?); zero is the folded case. */
624
+ const startOccurrences = (init: Expr, start: Expr): number =>
625
+ [...subterms(init)].filter((x) => exprEquals(x, start)).length;
626
+
627
+ /** every node of an expression tree, itself included */
628
+ function* subterms(e: Expr): Generator<Expr> {
629
+ yield e;
630
+ for (const c of exprChildrenOf(e)) {
631
+ yield* subterms(c);
632
+ }
633
+ }
634
+
635
+ const exprChildrenOf = (e: Expr): Expr[] => {
636
+ const out: Expr[] = [];
637
+ mapExprChildren(e, (c) => {
638
+ out.push(c);
639
+ return c;
640
+ });
641
+ return out;
642
+ };
643
+
644
+ // ── what a tree does to a name ──────────────────────────────────────────────────────────────
645
+
646
+ /** assignments to `name` anywhere in these statements, `for` init/inc included */
647
+ function assignCount(stmts: readonly Stmt[], name: string): number {
648
+ let n = 0;
649
+ for (const s of stmts) {
650
+ if (s.k === 'assign' && s.name === name) {
651
+ n++;
652
+ }
653
+ n += assignCount(stmtChildren(s), name);
654
+ }
655
+ return n;
656
+ }
657
+
658
+ /** does any expression in these statements read `name`? */
659
+ function mentions(stmts: readonly Stmt[], name: string): boolean {
660
+ for (const e of walkExprs(stmts as Stmt[])) {
661
+ if (e.k === 'var' && e.name === name) {
662
+ return true;
663
+ }
664
+ }
665
+ return false;
666
+ }
667
+
668
+ const mentionsIn = (e: Expr, name: string): boolean => [...subterms(e)].some((x) => x.k === 'var' && x.name === name);
669
+
670
+ const addrTakenIn = (stmts: readonly Stmt[], name: string): boolean => {
671
+ for (const e of walkExprs(stmts as Stmt[])) {
672
+ if (e.k === 'addr' && e.name === name) {
673
+ return true;
674
+ }
675
+ }
676
+ return false;
677
+ };
678
+
679
+ const hasContinueIn = (stmts: readonly Stmt[]): boolean =>
680
+ stmts.some((s) => s.k === 'continue' || hasContinueIn(stmtChildren(s)));
681
+
682
+ // ── the re-evaluation gates ─────────────────────────────────────────────────────────────────
683
+
684
+ /** every memory access in an expression, as its own node */
685
+ const accessesIn = (e: Expr): (Extract<Expr, { k: 'index' }> | Extract<Expr, { k: 'field' }>)[] =>
686
+ [...subterms(e)].filter((x): x is Extract<Expr, { k: 'index' | 'field' }> => x.k === 'index' || x.k === 'field');
687
+
688
+ const namesUnder = (e: Expr): string[] =>
689
+ [...subterms(e)].filter((y): y is Extract<Expr, { k: 'var' }> => y.k === 'var').map((y) => y.name);
690
+
691
+ /** Does a READ land on a device register? Two readings, and neither is enough alone. The chain's
692
+ * ROOT is what places an access whose subscripts are not constant — `((struct E *)0x03003430)
693
+ * [a1].field_4` has no compile-time address at all — and a read with no root is unplaceable, so
694
+ * it bars. The WHOLE address is what places one whose subscripts are: `((s32 *)0x03FFFFF0)[8]`
695
+ * denotes 0x04000010, BG0HOFS, which the root alone reports as EWRAM. The residual is stated
696
+ * rather than hidden: a RUNTIME subscript can still carry an access from an out-of-window root
697
+ * into the window, and nothing here bounds it — the write side has no such gap because it
698
+ * resolves the whole address or refuses. */
699
+ const readsDevice = (r: Expr, window?: readonly [number, number]): boolean => {
700
+ const root = rootConst(r);
701
+ return root === null || inRange(root, window) || inRange(cellAddress(r), window);
702
+ };
703
+
704
+ /** Can the region's writes change what a MEMORY read in the closed form sees? Only "no" when every
705
+ * write it evaluates goes to a constant address inside the declared device window, and every read
706
+ * lands outside it. See the file header, including the premise this does NOT establish (a device
707
+ * that writes memory itself: `deviceWritesMemory` below).
708
+ *
709
+ * MEMORY ONLY. The NAMES the closed form reads are `init-loop-var`'s question and
710
+ * `init-name-escapes`', and a closed form with no memory access still has names — which is why
711
+ * the early return below is not "nothing can change this". */
712
+ function movedReadAliasable(closed: Expr, evaluated: readonly Stmt[], window?: readonly [number, number]): boolean {
713
+ const reads = accessesIn(closed);
714
+ if (reads.length === 0) {
715
+ return false; // no access ⇒ no write can reach it; its NAMES are the two gates above
716
+ }
717
+ for (const s of allStmts(evaluated)) {
718
+ // a call or an unmodelled instruction may write anything
719
+ if (stmtExprs(s).some(exprHasEffect)) {
720
+ return true;
721
+ }
722
+ if (s.k === 'store' && !inRange(cellAddress(s.lval), window)) {
723
+ return true;
724
+ }
725
+ }
726
+ return reads.some((r) => readsDevice(r, window));
727
+ }
728
+
729
+ /** Does the loop write a register the DEVICE answers by writing ordinary memory? The premise
730
+ * `movedReadAliasable` rests on covers the CPU's own stores and nothing else; a DMA trigger is a
731
+ * store whose effect is a write the C never spells. A store counts when its BYTE RANGE touches
732
+ * one of the target's declared ranges, so the 32-bit `DMA3CNT` write reaches the enable halfword
733
+ * four bytes into it. NO declared ranges ⇒ every device store counts, which is the conservative
734
+ * direction and what a target that has said nothing gets. */
735
+ function deviceWritesMemory(evaluated: readonly Stmt[], triggers?: readonly (readonly [number, number])[]): boolean {
736
+ for (const s of allStmts(evaluated)) {
737
+ if (s.k !== 'store' || s.lval.k !== 'index') {
738
+ continue;
739
+ }
740
+ const at = cellAddress(s.lval);
741
+ if (at === null) {
742
+ continue; // `movedReadAliasable` has already refused this loop
743
+ }
744
+ if (triggers === undefined) {
745
+ return true;
746
+ }
747
+ const end = at + s.lval.width;
748
+ if (triggers.some(([lo, hi]) => at < hi && end > lo)) {
749
+ return true;
750
+ }
751
+ }
752
+ return false;
753
+ }
754
+
755
+ function* allStmts(stmts: readonly Stmt[]): Generator<Stmt> {
756
+ for (const s of stmts) {
757
+ yield s;
758
+ yield* allStmts(stmtChildren(s));
759
+ }
760
+ }
761
+
762
+ // ── the pass ────────────────────────────────────────────────────────────────────────────────
763
+
764
+ /** the loop's counter step statement (a `for`'s `inc`, or the body's last statement) */
765
+ const counterStepStmt = (loop: Extract<Stmt, { k: 'while' | 'dowhile' | 'for' }>): Stmt | undefined =>
766
+ loop.k === 'for' ? loop.inc : loop.body[loop.body.length - 1];
767
+
768
+ /** the statements a loop evaluates outside its body — the parts a substitution must not touch */
769
+ const controlStmts = (loop: Extract<Stmt, { k: 'while' | 'dowhile' | 'for' }>): Stmt[] =>
770
+ loop.k === 'for' ? [loop.init, loop.inc] : [];
771
+
772
+ /** The statements a DEVICE armed in could still be writing memory from while the moved reads run:
773
+ * the loop, and the WHOLE prefix above it — deliberately wider than the motion region below,
774
+ * because a device armed anywhere before the reads happen keeps writing memory WHILE they happen,
775
+ * so a repeating transfer armed above the init is as asynchronous as one armed inside the loop. */
776
+ function armedPrefix(body: readonly Stmt[], loop: Stmt, li: number): Stmt[] {
777
+ return [...body.slice(0, li), loop];
778
+ }
779
+
780
+ /** THE MOTION REGION: everything that runs between where the init stood and the reads that replace
781
+ * it. Both endpoints move — the init is DELETED, and the counter's start is what the substitution
782
+ * reads the closed form through — so the region opens at whichever of the two comes first and runs
783
+ * to the loop's last iteration. The loop enters WHOLE, so a walk over this region reaches its
784
+ * condition and a `for`'s own init and inc as well as its body. */
785
+ function motionRegion(body: readonly Stmt[], loop: Stmt, li: number, initIdx: number, startIdx: number): Stmt[] {
786
+ return [...body.slice(Math.min(initIdx, startIdx) + 1, li), loop];
787
+ }
788
+
789
+ /** The `/unreduce` candidate. `sfn` is a fresh tree, the input left untouched; `needsProof` says
790
+ * the closed form re-reads memory over a loop whose device writes may THEMSELVES write memory
791
+ * (see the header), so rank.ts may publish it only at a byte-exact score. */
792
+ export interface UnreduceResult {
793
+ sfn: SFn;
794
+ needsProof: boolean;
795
+ }
796
+
797
+ /** The `/unreduce` candidate, or null when no accumulator qualifies. `window` is the target's
798
+ * declared device-register range (TargetDescription.capabilities.deviceRegisters) — absent, the
799
+ * lever still fires on a closed form that reads no memory. `triggers` is
800
+ * `capabilities.deviceMemoryWriters`; absent, EVERY device store is treated as one. */
801
+ export function unreduceAccumulators(
802
+ sfn: SFn,
803
+ window?: readonly [number, number],
804
+ triggers?: readonly (readonly [number, number])[],
805
+ gates: readonly Gate<AccCtx>[] = UNREDUCE_GATES,
806
+ ): UnreduceResult | null {
807
+ const body = [...sfn.body];
808
+ const vt: VarTypes = declaredTypes(sfn);
809
+ const deletedInits = new Set<Stmt>();
810
+ const deletedLocals = new Set<string>();
811
+ let changed = false;
812
+ let needsProof = false;
813
+
814
+ for (let li = 0; li < body.length; li++) {
815
+ const loop = body[li];
816
+ if (loop.k !== 'while' && loop.k !== 'dowhile' && loop.k !== 'for') {
817
+ continue;
818
+ }
819
+ // the counter: one name stepped by a constant, whose start stands above the loop
820
+ const ctrStep = counterStepStmt(loop);
821
+ if (ctrStep === undefined || ctrStep.k !== 'assign') {
822
+ continue;
823
+ }
824
+ const ctr = ctrStep.name;
825
+ const dStep = stepOf(ctrStep, ctr);
826
+ const d = dStep === null ? null : foldConsts(dStep);
827
+ if (d === null || d.k !== 'const' || d.value === 0) {
828
+ continue;
829
+ }
830
+ const startStmt =
831
+ loop.k === 'for'
832
+ ? loop.init
833
+ : [...sfn.body.slice(0, li)].reverse().find((s) => s.k === 'assign' && s.name === ctr);
834
+ if (startStmt === undefined || startStmt.k !== 'assign' || startStmt.name !== ctr) {
835
+ continue;
836
+ }
837
+ const outside = [...sfn.body.slice(0, li), ...sfn.body.slice(li + 1)];
838
+ const startIdx = loop.k === 'for' ? li : sfn.body.indexOf(startStmt);
839
+ const armed = armedPrefix(sfn.body, loop, li);
840
+ const rewrites = new Map<string, Expr>();
841
+ for (const cand of sfn.locals) {
842
+ const initStmt = sfn.body.slice(0, li).find((s) => s.k === 'assign' && s.name === cand.name);
843
+ const stepIdx = loop.body.findIndex((s) => stepOf(s, cand.name) !== null);
844
+ if (initStmt === undefined || initStmt.k !== 'assign' || stepIdx < 0 || cand.name === ctr) {
845
+ continue;
846
+ }
847
+ const k = foldConsts(stepOf(loop.body[stepIdx], cand.name)!);
848
+ const closed = relate(initStmt.value, startStmt.value, ctr, k, d.value);
849
+ const initIdx = sfn.body.indexOf(initStmt);
850
+ const evaluated = motionRegion(sfn.body, loop, li, initIdx, startIdx);
851
+ const ctrLocal = sfn.locals.find((l) => l.name === ctr);
852
+ // The units the accumulator's step counts in, against the units the closed form's `+` would
853
+ // scale by. Both sides must be KNOWN and equal — see `stride-units`.
854
+ const accScale = arithScale(cand.type);
855
+ const initScale = arithScale(exprCType(initStmt.value, vt));
856
+ const ctx: AccCtx = {
857
+ unitsDisagree: accScale === null || initScale === null || accScale !== initScale,
858
+ assigns: assignCount(sfn.body, cand.name),
859
+ addrTaken: addrTakenIn(sfn.body, cand.name),
860
+ pinned: declarationPins(cand),
861
+ liveOutside: mentions(
862
+ outside.filter((s) => s !== initStmt),
863
+ cand.name,
864
+ ),
865
+ readAtOrBelowStep:
866
+ mentions(loop.body.slice(stepIdx + 1), cand.name) ||
867
+ mentionsIn(k, cand.name) ||
868
+ mentionsIn(loop.cond, cand.name) ||
869
+ mentions(controlStmts(loop), cand.name),
870
+ counterAssigns: assignCount(sfn.body, ctr),
871
+ counterAddrTaken: addrTakenIn(sfn.body, ctr),
872
+ counterVolatile: ctrLocal?.volatile === true || ctrLocal?.pointeeVolatile === true,
873
+ hasContinue: hasContinueIn(loop.body),
874
+ declined: 'ok' in closed ? null : closed.declined,
875
+ // `evaluated`, not `loop.body`: a `for`'s counter is stepped in `loop.inc`
876
+ initLoopVar: [...namesUnder(initStmt.value)].some((n) => assignCount(evaluated, n) > 0),
877
+ initNameEscapes: [...namesUnder(initStmt.value)].some((n) => addrTakenIn(sfn.body, n)),
878
+ // read off the ORIGINAL init, not the substituted form: the init STATEMENT is deleted, so
879
+ // an effect inside the counter-start subterm the substitution replaces would be dropped
880
+ // rather than moved — one fewer execution, which no gate reading `closed` could see.
881
+ movedEffect: exprHasEffect(initStmt.value),
882
+ movedVolatile: exprReadsVolatile(initStmt.value, sfn),
883
+ movedAliasable: movedReadAliasable(initStmt.value, evaluated, window),
884
+ };
885
+ if (firstRejection(gates, ctx) !== null) {
886
+ continue;
887
+ }
888
+ // AND THE CLOSED FORM ITSELF, which the gates alone do not establish: five of them reject a
889
+ // reason `relate` declined for, so only the FULL table implies one exists. `gates.ts`'s
890
+ // differential ablation drops an entry and re-runs this pass, and with one of those five
891
+ // gone there is nothing to substitute while the init statement and the declaration are
892
+ // deleted regardless — C naming a variable that is no longer there, with no marker on it.
893
+ if (!('ok' in closed)) {
894
+ continue;
895
+ }
896
+ // The gates have placed every write the C performs; what they cannot place is a write the
897
+ // DEVICE performs in answer to one. A moved read over such a loop is offered under PROOF.
898
+ if (accessesIn(initStmt.value).length > 0 && deviceWritesMemory(armed, triggers)) {
899
+ needsProof = true;
900
+ }
901
+ rewrites.set(cand.name, closed.ok);
902
+ deletedInits.add(initStmt);
903
+ deletedLocals.add(cand.name);
904
+ }
905
+ if (rewrites.size === 0) {
906
+ continue;
907
+ }
908
+ changed = true;
909
+ const sub = (e: Expr): Expr => {
910
+ if (e.k === 'var') {
911
+ const hit = rewrites.get(e.name);
912
+ if (hit !== undefined) {
913
+ return clone(hit); // a FRESH node per use — identity-keyed rules downstream read it
914
+ }
915
+ }
916
+ return mapExprChildren(e, sub);
917
+ };
918
+ const strip = (stmts: readonly Stmt[]): Stmt[] =>
919
+ stmts.filter((s) => !(s.k === 'assign' && rewrites.has(s.name))).map((s) => mapStmts(s, sub, strip));
920
+ body[li] = { ...loop, body: strip(loop.body) } as Stmt;
921
+ }
922
+ if (!changed) {
923
+ return null;
924
+ }
925
+ return {
926
+ sfn: {
927
+ ...sfn,
928
+ locals: sfn.locals.filter((l) => !deletedLocals.has(l.name)),
929
+ body: body.filter((s) => !deletedInits.has(s)),
930
+ },
931
+ needsProof,
932
+ };
933
+ }
934
+
935
+ const clone = (e: Expr): Expr => mapExprChildren({ ...e }, clone);
936
+
937
+ /** map a statement's own expressions and its nested lists in one step */
938
+ function mapStmts(s: Stmt, f: (e: Expr) => Expr, list: (l: readonly Stmt[]) => Stmt[]): Stmt {
939
+ switch (s.k) {
940
+ case 'assign':
941
+ return { ...s, value: f(s.value) };
942
+ case 'store':
943
+ return { ...s, lval: f(s.lval), value: f(s.value) };
944
+ case 'exprstmt':
945
+ return { ...s, value: f(s.value) };
946
+ case 'return':
947
+ return s.value === undefined ? s : { ...s, value: f(s.value) };
948
+ case 'if':
949
+ return { ...s, cond: f(s.cond), then: list(s.then), else: list(s.else) };
950
+ case 'while':
951
+ case 'dowhile':
952
+ return { ...s, cond: f(s.cond), body: list(s.body) };
953
+ case 'for':
954
+ return {
955
+ ...s,
956
+ cond: f(s.cond),
957
+ init: mapStmts(s.init, f, list),
958
+ inc: mapStmts(s.inc, f, list),
959
+ body: list(s.body),
960
+ };
961
+ case 'switch':
962
+ return {
963
+ ...s,
964
+ scrutinee: f(s.scrutinee),
965
+ cases: s.cases.map((c) => ({ ...c, body: list(c.body) })),
966
+ ...(s.default ? { default: list(s.default) } : {}),
967
+ };
968
+ default:
969
+ return s;
970
+ }
971
+ }