@asmlift/core 0.4.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 (87) 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 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
package/src/ir/opcodes.ts CHANGED
@@ -16,6 +16,13 @@ export interface OpSig {
16
16
  * an unconditional position. THE one effect vocabulary — DCE (pattern/engine.ts) and the
17
17
  * short-circuit hoist guard (raise/shortcircuit.ts) both derive from this flag. */
18
18
  effects?: boolean;
19
+ /** reads memory. Deletable when dead — nothing observes a load nobody reads — but NOT movable:
20
+ * a load answers whichever stores ran before it, so crossing one changes the value it yields.
21
+ * The two questions are separate flags because a load answers them differently. */
22
+ reads?: boolean;
23
+ /** may fault on operands the program never actually gave it — the integer divides, on a zero
24
+ * divisor. Deletable and movable, but not safe to run on a path that did not run it. */
25
+ traps?: boolean;
19
26
  }
20
27
 
21
28
  export const OPCODES = {
@@ -60,16 +67,29 @@ export const OPCODES = {
60
67
  zext: { operands: 1, results: 1, requiredAttrs: ['width'] },
61
68
  sext: { operands: 1, results: 1, requiredAttrs: ['width'] },
62
69
  // Division/remainder. `sdiv` is variadic like the shifts: the immediate form (1 operand +
63
- // `imm` attr) is the strength-reduced constant divisor an idiom folds to (`sdiv X {imm=2}`);
64
- // the register form (2 operands) is a real hardware divide (`div`/`divu` + `mflo`/`mfhi` on an
65
- // ISA with `capabilities.hwDivide`); the structurer branches on count. `udiv`/`smod`/`umod`
70
+ // `imm` attr) is the strength-reduced constant divisor an idiom folds to (`sdiv X {imm=2}`),
71
+ // the register form is 2 operands, and the structurer branches on count. `udiv`/`smod`/`umod`
66
72
  // are 2-operand only. `sdiv`/`udiv` = quotient, `smod`/`umod` = remainder; signedness lives in
67
- // the op (recovery types the operands to match), so the backend picks `/`/`%` over
68
- // correctly-typed operands.
69
- sdiv: { operands: 'variadic', results: 1 },
70
- udiv: { operands: 2, results: 1 },
71
- smod: { operands: 2, results: 1 },
72
- umod: { operands: 2, results: 1 },
73
+ // the op, and L3 keeps the pair apart (`/`/`%` against `/u`/`%u`) so the C backend can spell
74
+ // both with C's one token over an operand cast that says which.
75
+ //
76
+ // A register form does NOT mean the machine had a divide instruction — never read the ISA off it.
77
+ // Six recognizers at three layers build these ops, and which fires is a fact about the COMPILER's
78
+ // lowering, not the hardware. Quotient AND remainder: frontend/mips.ts (`mfhi` off a real `div`),
79
+ // raise/softdiv.ts (a `bl __modsi3` on an ISA with NO divide, gated `!hwDivide`), and
80
+ // pattern/engine.ts HWMOD_PATTERNS (`divw`+`mullw`+`subf`, where the hardware divides but has no
81
+ // remainder instruction). Quotient ONLY: raise/magicdiv.ts, raise/divpow2.ts, SDIV_POW2_2.
82
+ // KNOWN GAP, by pass ORDER rather than by decision: the quotient-only three run at stage 2.35,
83
+ // AFTER the stage-2 idiom fold, so no remainder fold can ever see the `sdiv` they build, and a
84
+ // remainder over a CONSTANT divisor stays written out on every target — visible in the artifact
85
+ // as `modc`/`umod10`, which recover `%` on agbcc+ido and the decomposition on kmc+mwcc. All four
86
+ // are byte-exact, so it costs 0 points; closing it re-prices those rows and re-derives both of
87
+ // HWMOD_PATTERNS' gates (a constant multiply carries no operand order to read), so it is its own
88
+ // round.
89
+ sdiv: { operands: 'variadic', results: 1, traps: true },
90
+ udiv: { operands: 2, results: 1, traps: true },
91
+ smod: { operands: 2, results: 1, traps: true },
92
+ umod: { operands: 2, results: 1, traps: true },
73
93
  // signed/equality comparisons (result is a boolean-valued u32)
74
94
  icmp_slt: { operands: 2, results: 1 },
75
95
  icmp_sle: { operands: 2, results: 1 },
@@ -90,13 +110,13 @@ export const OPCODES = {
90
110
  logic_and: { operands: 2, results: 1 },
91
111
  logic_or: { operands: 2, results: 1 },
92
112
  // --- memory ---
93
- load: { operands: 1, results: 1, requiredAttrs: ['off', 'width', 'signed'] },
113
+ load: { operands: 1, results: 1, requiredAttrs: ['off', 'width', 'signed'], reads: true },
94
114
  store: { operands: 2, results: 0, requiredAttrs: ['off', 'width'], effects: true },
95
115
  // Typed element-scaled array access. Unlike load/store's constant `off`, these carry an
96
116
  // explicit runtime `index` operand plus the `elemSize` the index scales by, so the base is a
97
117
  // genuine `elem *` and no byte-offset arithmetic leaks into the emitted source. Produced by
98
118
  // the array-recognition legalization pass (raise/arrays.ts).
99
- aload: { operands: 2, results: 1, requiredAttrs: ['elemSize', 'signed'] }, // aload base, index
119
+ aload: { operands: 2, results: 1, requiredAttrs: ['elemSize', 'signed'], reads: true }, // aload base, index
100
120
  astore: { operands: 3, results: 0, requiredAttrs: ['elemSize'], effects: true }, // astore base, index, value
101
121
  // --- call: operands are the argument values (r0..), result is the return value (r0),
102
122
  // `target` attr is the callee symbol. Caller-saved clobbering is implicit. ---
@@ -108,8 +128,54 @@ export const OPCODES = {
108
128
  // - an indexed or non-zero-offset AGGREGATE access → the address-cast `((T *)&gSym)[i]`;
109
129
  // - any other use (e.g. `&gSym` passed to a call) → the `{k:'addr'}` L3 node, printed `&gSym`.
110
130
  gaddr: { operands: 0, results: 1, requiredAttrs: ['sym'] },
131
+ // The address of a FRAME-LOCAL object — gaddr's local twin, for the address-taken stack local
132
+ // (`mov rD, sp` feeding a DMA register or a callee). `off` is the byte offset inside the frame's
133
+ // reserved local area; the Thumb frontend's post-lift audit stamps `name`/`width`/`signed` after
134
+ // proving every access agrees, and the structurer declares the local and renders `&name` exactly
135
+ // as it renders a gaddr's `&sym`. Operand-free and pure, so GVN numbers it like gaddr and a dead
136
+ // one is reaped.
137
+ // `width`/`signed` are stamped by the frontend's frame-object AUDIT — requiring them makes
138
+ // "the audit ran" a verifier-checkable fact instead of a convention: a frontend that emits a
139
+ // laddr and skips the audit fails verify loudly instead of rendering `&undefined`.
140
+ laddr: { operands: 0, results: 1, requiredAttrs: ['off', 'width', 'signed'] },
141
+ // An UNDEFINED value: a read of storage that carries no INPUT — nothing was entitled to hand this
142
+ // function a value there, and none of its own stores reached it on this path. Deliberately NOT
143
+ // "storage nobody could have written": a callee-saved register holds the CALLER's value at entry,
144
+ // which is exactly what the prologue pushes it for, and the read is undefined all the same
145
+ // because the ABI gives no caller a way to pass an argument in one. The C declared a local with
146
+ // no initialiser and assigned it only inside some arms of a conditional or `switch` (a `switch`
147
+ // with no `default` being the commonest source), so the read is legal to compile and the compiler
148
+ // emitted the unassigned path faithfully.
149
+ //
150
+ // "No input" is established differently in the two places a local lives, and `frontend/ssa.ts`
151
+ // (LiveInModel) is where each is declared:
152
+ // • a FRAME SLOT is storage whose only writer is this function's own stores — SOLE WRITER, not
153
+ // merely "owns the storage", because a frame the function owns can still be written by
154
+ // someone else once an address into it escapes to a callee, which fills a wider object than
155
+ // any in-function access reveals. Whoever mints one owes the retraction on escape
156
+ // (frontend/thumb.ts, after the frame-object audit).
157
+ // • a REGISTER the ABI does not pass arguments in AND this function's prologue SAVED cannot
158
+ // carry a value a caller handed over, and has no address for anything else to reach it by, so
159
+ // there is nothing to retract. The save is half of the premise, not a corroboration of it:
160
+ // asm that follows no ABI is handed live values in registers it never saved.
161
+ //
162
+ // An opcode rather than a live-in because Braun's construction resolves a def-less read to a
163
+ // live-in, and a live-in of the entry block is a PARAMETER — right for an argument register, a
164
+ // fabricated argument for anything else.
165
+ //
166
+ // Operand-free and pure like `laddr`, and out of raise/gvn.ts's NUMBERABLE set — where numbering
167
+ // it would be VACUOUS rather than harmful, since two undefs in one function always carry
168
+ // different keys. "Same key, therefore same value" is empty for a value that has none.
169
+ //
170
+ // `key` names the storage (`sp@0`, `r4`); the structurer reads it to name the local
171
+ // (`uninit_sp0`) and emits NO assignment — that absence is the recovery, and an edge argument
172
+ // that is one emits no copy either (structure.ts undefCarriesNothing).
173
+ undef: { operands: 0, results: 1, requiredAttrs: ['key'] },
111
174
  // --- black-box escape hatch (keeps lifting total) ---
112
- opaque: { operands: 'variadic', results: 1 },
175
+ // `effects: true`: an instruction asmlift could not model may do anything — write memory, trap,
176
+ // touch a system register — and `results[0]` is only the part we can name. So a dead `opaque` is
177
+ // no more reapable than a dead `call`.
178
+ opaque: { operands: 'variadic', results: 1, effects: true },
113
179
  // --- terminators ---
114
180
  ret: { operands: 'variadic', results: 0, terminator: true, successors: 0 },
115
181
  br: { operands: 0, results: 0, terminator: true, successors: 1 },
@@ -124,6 +190,13 @@ export const OPCODES = {
124
190
  * does not. */
125
191
  export type Opcode = keyof typeof OPCODES;
126
192
 
193
+ /** The widths a `zext`/`sext` carries — a fact about those two opcodes, so it lives with them
194
+ * rather than re-declared per consumer (raise/narrow.ts pairs a narrowing op with its re-widening,
195
+ * raise/paramwidth.ts declares a parameter at one). Every frontend that produces the pair produces
196
+ * one of these: agbcc's gated shift-pair fold (pattern/engine.ts CAST_PATTERNS) and PPC's
197
+ * `extsb`/`extsh` (frontend/ppc.ts). A third width would be a C type the backend cannot spell. */
198
+ export const CAST_WIDTHS: ReadonlySet<number> = new Set([8, 16]);
199
+
127
200
  /** Signature lookup by RUNTIME opcode string (Op.opcode is a plain string — IR consumers switch
128
201
  * on it); undefined for an unregistered opcode. */
129
202
  export function opSig(opcode: string): OpSig | undefined {
@@ -138,11 +211,10 @@ export function opSig(opcode: string): OpSig | undefined {
138
211
  * holds by construction (a hand-written map is one typo away from breaking it, and the symptom is a
139
212
  * plainly inverted condition in the emitted C). Completeness against the icmp family is the part
140
213
  * construction cannot give, so a test asserts it (test/pattern.test.ts) — an eleventh comparison
141
- * added to `OPCODES` would otherwise degrade three consumers three different ways.
214
+ * added to `OPCODES` would otherwise degrade its consumers several different ways.
142
215
  *
143
216
  * It lives here for the reason HOIST_UNSAFE_OPS does: every consumer that has to say "the opposite
144
- * of this compare" reads THIS one the MIPS frontend's `slt …; beqz` branch-when-false fold, the
145
- * short-circuit recognizer's diamond negation, and the idiom layer's `cmp ^ 1` fold — so they
217
+ * of this compare" reads THIS one, so they
146
218
  * cannot drift apart the way inline copies did. Two adjacent facts worth knowing: raise/
147
219
  * shortcircuit.ts derives its `BOOL_OPS` from these keys (asserting negatable-icmp == boolean-op,
148
220
  * true today), and l3/ast.ts `NEGATE_REL` is the SAME relation over the neutral L3 operator
@@ -162,25 +234,82 @@ export const NEGATED_ICMP: Readonly<Record<string, Opcode>> = Object.fromEntries
162
234
  ]),
163
235
  );
164
236
 
165
- /** Ops with an observable side effect the derived view raise/shortcircuit.ts consumes. */
237
+ /** Ops with an observable side effect: the flag on the signature, derived rather than re-listed.
238
+ * `isDceSafe` asks the same question of the FLAG through `opSig` rather than of this set, so the
239
+ * two cannot disagree. The SET's own consumers are `HOIST_UNSAFE_OPS` below, structure.ts's
240
+ * `sideEffects` walk (an effectful op whose result nobody reads is still an execution),
241
+ * analysis.ts's memory-write barrier, divpow2's bias block (which is DELETED rather than moved),
242
+ * and the idiom layer's de-sequencing guard (pattern/engine.ts). Three of them — the
243
+ * `sideEffects` walk, the barrier and the bias block — each carried a hand-written copy of this
244
+ * membership, which is how the models drifted apart before. */
166
245
  export const EFFECTFUL_OPS: ReadonlySet<string> = new Set(
167
246
  (Object.keys(OPCODES) as Opcode[]).filter((k) => (OPCODES[k] as OpSig).effects),
168
247
  );
169
248
 
170
- /** Ops that may not be REORDERED across other code `EFFECTFUL_OPS` plus `opaque`.
249
+ /** Ops that may not be SPECULATED run on a path that did not run them before. Identical to
250
+ * `EFFECTFUL_OPS`, and kept as its own name because its call sites ask the speculation question
251
+ * rather than the deletion one.
252
+ *
253
+ * A memory read is deliberately absent, and that is the one entry worth arguing: its only consumer
254
+ * is raise/shortcircuit.ts, which hoists an arm's body into the block above, and the structurer
255
+ * inlines an unnamed value back into the `&&`/`||` right-hand side, where C's own short-circuit
256
+ * re-guards it. Adding the two reads
257
+ * here costs three byte-matches (kleod:UpdateHUDCounterDisplay, synthetic:breakloop,
258
+ * synthetic:strcmp1), so the argument is load-bearing rather than merely plausible.
259
+ *
260
+ * KNOWN GAP: the trapping divides are absent too, and there the re-guard argument does NOT carry
261
+ * — a hoisted `sdiv` that the structurer NAMES becomes an unconditional statement. Left as it is
262
+ * because closing it is a separate change with its own measurement; `REEVAL_UNSAFE_OPS` does
263
+ * refuse them, so the pre-update sink is not exposed to it.
171
264
  *
172
- * `effects` is overloaded on two axes, and `opaque` is exactly the op that separates them: a dead
173
- * `opaque` MUST stay deletable (`isDceSafe` below says so deliberately giving it `effects: true`
174
- * would strand dead opaques after every pattern rewrite, and they would surface as ASMLIFT_ERROR
175
- * gaps in functions that emit cleanly today), while a LIVE one is an instruction asmlift could not
176
- * model and must not be moved past anything. So "deletable when dead" and "movable when live" are
177
- * different questions and get different views, both derived here rather than re-spelled per
178
- * consumer structure/analysis.ts and structure/structure.ts each carry their own inline copy of
179
- * this membership, which is how the two models drifted apart in the first place. */
180
- export const HOIST_UNSAFE_OPS: ReadonlySet<string> = new Set([...EFFECTFUL_OPS, 'opaque']);
265
+ * AND THE EXEMPTION IS NOT TRANSFERABLE, which is worth saying beside it: a second consumer once
266
+ * read this set to answer "would gcc have SPECULATED this arm above a compare", where nothing
267
+ * re-guards anything, and admitted single-load arms `gcc/jump.c:483`'s `! may_trap_p` refuses.
268
+ * Pointing that consumer back at this set costs `synthetic:mergeldcast:agbcc` its byte-match
269
+ * (MATCH -> diff:6), measured. That consumer is `raise/narrowlocal.ts` and it reads
270
+ * `REEVAL_UNSAFE_OPS` instead. Reach for this set only when the argument above — a C-level
271
+ * re-guard at the new point actually holds at your call site. */
272
+ export const HOIST_UNSAFE_OPS: ReadonlySet<string> = EFFECTFUL_OPS;
273
+
274
+ /** Ops whose answer depends on WHERE they run: an effect (its order against other effects is
275
+ * observable) or a memory read (it answers whichever stores ran before it). The question a pass
276
+ * asks before moving a computation to another point on the SAME path. */
277
+ export const ORDER_SENSITIVE_OPS: ReadonlySet<string> = new Set(
278
+ (Object.keys(OPCODES) as Opcode[]).filter((k) => {
279
+ const sig = OPCODES[k] as OpSig;
280
+ return sig.effects || sig.reads;
281
+ }),
282
+ );
283
+
284
+ /** Ops that may not be RE-EVALUATED at another program point — order-sensitive, or trapping. The
285
+ * trap half is what separates this from `ORDER_SENSITIVE_OPS`: it only matters when the new point
286
+ * can be reached on a path the old one was not, so a consumer that merely re-orders on one path
287
+ * wants the smaller set. Both are needed because the two consumers differ on exactly the divides:
288
+ * a collapsed switch re-renders a test block's ops AT THEIR USES, and every use is dominated by
289
+ * the def, so it evaluates them on a SUBSET of the original paths — a narrowing, never a
290
+ * speculation. */
291
+ export const REEVAL_UNSAFE_OPS: ReadonlySet<string> = new Set(
292
+ (Object.keys(OPCODES) as Opcode[]).filter((k) => {
293
+ const sig = OPCODES[k] as OpSig;
294
+ return sig.effects || sig.reads || sig.traps;
295
+ }),
296
+ );
297
+
298
+ /** Ops that MATERIALIZE a value out of nothing: no operands, no state read, no effect, no trap, no
299
+ * control flow — so where one sits in a block says nothing about what ran before it. Derived, so a
300
+ * future pure nullary opcode joins without a second edit. Consumed by raise/paramwidth.ts, whose
301
+ * prologue scan steps over them; the effect flags are what keep the EFFECTFUL nullary ops (a
302
+ * zero-argument `call`, an `opaque` with no sources) out, and an extension behind a call is body
303
+ * code rather than a prologue. */
304
+ export const MATERIALIZING_OPS: ReadonlySet<string> = new Set(
305
+ (Object.keys(OPCODES) as Opcode[]).filter((k) => {
306
+ const sig = OPCODES[k] as OpSig;
307
+ return sig.operands === 0 && !sig.terminator && !sig.effects && !sig.reads && !sig.traps;
308
+ }),
309
+ );
181
310
 
182
311
  /** May a dead result of this opcode be deleted? Registered, no observable effects, not control
183
- * flow. Deliberately includes `opaque` — a dead opaque vanishing is designed behavior. */
312
+ * flow. `opaque` is excluded via its `effects` flag see the note on its signature. */
184
313
  export function isDceSafe(opcode: string): boolean {
185
314
  const sig = opSig(opcode);
186
315
  return !!sig && !sig.effects && !sig.terminator;
package/src/ir/parse.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  // both the debug dump and the test oracle. The parser builds the graph but enforces NO
5
5
  // semantics — that is the verifier's job — so malformed-but-well-formed-syntax IR can be
6
6
  // constructed and then rejected by verify().
7
+ // The write-order annotation `print(fn, { writeOrder: true })` adds is DISCARDED, not rejected: the
8
+ // text parses (`stripWriteOrderAnnotation`) and the fn comes back with `writeOrder: undefined`.
9
+ // Both halves are deliberate — only a frontend can make that measurement, and a parsed fn that came
10
+ // back measured would structure differently from every other parsed fn, while every stage dump
11
+ // carries the annotation (pipeline.ts, trace.ts) and a dump nobody can paste back in is not an
12
+ // oracle.
7
13
  // ROUND-TRIP DOMAIN: parse(print(fn)) holds for L1/scalar types only — `unkN`/`sN`/`uN` and
8
14
  // `*`-pointers to them. STRUCT/ARRAY/VOID types print (typeToString) but do NOT parse back; a
9
15
  // post-type-recovery dump is a one-way debugging artifact, not a test oracle.
@@ -32,7 +38,7 @@ export function parse(text: string): Fn {
32
38
  if (raw[i].trim() === '') {
33
39
  continue;
34
40
  }
35
- body.push(raw[i].trim());
41
+ body.push(stripWriteOrderAnnotation(raw[i].trim()));
36
42
  }
37
43
 
38
44
  // PASS A — create all blocks and pre-declare every value by its textual name, so
@@ -113,7 +119,18 @@ export function parse(text: string): Fn {
113
119
  }
114
120
  }
115
121
 
116
- return { name, blocks: rawBlocks.map((r) => r.block) };
122
+ // No write order: the text form is the value graph, and the record is a measurement of the
123
+ // MACHINE. A parsed fn's edges are UNMEASURED, never written-nowhere (ir/core.ts `WriteOrder`).
124
+ return { name, blocks: rawBlocks.map((r) => r.block), writeOrder: undefined, slotHomes: undefined };
125
+ }
126
+
127
+ /** Drop the two annotations `print(fn, { writeOrder: true })` appends, and NOTHING else.
128
+ *
129
+ * Anchored on their exact shapes rather than on "text after a `;`", because a `;` is not a comment
130
+ * marker in this format: a string attr prints JSON-quoted (`opaque {text="mrs r0, cpsr; …"}`) and
131
+ * a list attr prints `[1;2;3]`, so a general trailing-comment strip would eat an operand. */
132
+ function stripWriteOrderAnnotation(line: string): string {
133
+ return line.replace(/^(\^\w+\(.*\):)\s+;\s+writes=\d+$/, '$1').replace(/\s+;\s+order(?:\s+\^\w+\([^()]*\))+$/, '');
117
134
  }
118
135
 
119
136
  function parseOp(line: string, refValue: (nm: string) => Value, refBlock: (label: string) => Block): Op {
package/src/ir/print.ts CHANGED
@@ -7,7 +7,17 @@
7
7
  import type { AttrVal, Block, Fn, Value } from './core';
8
8
  import { typeToString } from './types';
9
9
 
10
- export function print(fn: Fn): string {
10
+ /** WRITE-ORDER ANNOTATION (`ir/core.ts` WriteOrder) OFF by default and deliberately not part of
11
+ * the round-trip artifact. The record is a measurement `parse` cannot reconstruct, and a parsed fn
12
+ * that came back MEASURED would structure differently from every other parsed fn, so the text form
13
+ * never carries it back in. It is printed for the per-stage DUMPS, which are otherwise unable to
14
+ * explain themselves: two functions with identical `stage:lift` output emit different C when their
15
+ * records differ. */
16
+ export interface PrintOptions {
17
+ writeOrder?: boolean;
18
+ }
19
+
20
+ export function print(fn: Fn, opts: PrintOptions = {}): string {
11
21
  const blockLabel = new Map<Block, string>();
12
22
  fn.blocks.forEach((b, i) => blockLabel.set(b, `bb${i}`));
13
23
 
@@ -31,10 +41,14 @@ export function print(fn: Fn): string {
31
41
  }
32
42
  const ref = (v: Value) => name.get(v) ?? '%<undef>';
33
43
 
44
+ // Present only when asked for AND measured, so an unmeasured fn prints identically either way —
45
+ // the distinction a reader of the dump most needs to see.
46
+ const order = opts.writeOrder ? fn.writeOrder : undefined;
34
47
  const lines: string[] = [`fn ${fn.name} {`];
35
48
  for (const b of fn.blocks) {
36
49
  const params = b.params.map((p) => `${ref(p)}: ${typeToString(p.type)}`).join(', ');
37
- lines.push(`^${blockLabel.get(b)}(${params}):`);
50
+ const writes = order?.writes.get(b);
51
+ lines.push(`^${blockLabel.get(b)}(${params}):` + (writes === undefined ? '' : ` ; writes=${writes}`));
38
52
  for (const op of b.ops) {
39
53
  let s = ' ';
40
54
  if (op.results.length) {
@@ -52,6 +66,17 @@ export function print(fn: Fn): string {
52
66
  s += ' ' + args.join(', ');
53
67
  }
54
68
  s += fmtAttrs(op.attrs);
69
+ // Per SUCCESSOR, in successor-arg order: the ordinal of this block's last write to each
70
+ // destination param's key — what `structure.ts`'s edge-copy sort reads — and `-` for a
71
+ // destination this block never wrote.
72
+ if (order?.writes.has(b) && op.successors.length) {
73
+ const rec = order.lastWrite.get(b);
74
+ s +=
75
+ ' ; order ' +
76
+ op.successors
77
+ .map((su) => `^${blockLabel.get(su.block)}(${su.block.params.map((p) => rec?.get(p) ?? '-').join(', ')})`)
78
+ .join(' ');
79
+ }
55
80
  lines.push(s);
56
81
  }
57
82
  }
@@ -51,11 +51,11 @@ export function simplifyTrivialPhis(fn: Fn, onRemoved?: (param: Value) => void):
51
51
  const incoming = edgesTo(b);
52
52
  for (let i = b.params.length - 1; i >= 0; i--) {
53
53
  const param = b.params[i];
54
- const distinct = [...new Set(incoming.map((s) => s.args[i]).filter((v) => v !== param))];
55
- if (distinct.length !== 1) {
54
+ const collapsed = trivialPhiValue(incoming, i, param);
55
+ if (collapsed === null) {
56
56
  continue; // a genuine join, or unreachable (no in-edges at all)
57
57
  }
58
- replaceAllUsesWith(fn, param, distinct[0]);
58
+ replaceAllUsesWith(fn, param, collapsed);
59
59
  onRemoved?.(param);
60
60
  b.params.splice(i, 1);
61
61
  for (const s of incoming) {
@@ -70,3 +70,190 @@ export function simplifyTrivialPhis(fn: Fn, onRemoved?: (param: Value) => void):
70
70
  }
71
71
  }
72
72
  }
73
+
74
+ /** The ONE value block param `i` collapses to — the value every in-edge passes it, ignoring a
75
+ * back edge's self-reference — or null when it is a genuine join (two distinct values) or the
76
+ * block is unreachable (no in-edges at all). THE trivial-phi predicate, shared by the mutating
77
+ * fixpoint and the non-mutating check below so the two cannot come to disagree about which params
78
+ * are trivial.
79
+ *
80
+ * `=== null` IS THE ONLY SAFE TEST. A genuine collapse value is never null, but it CAN be
81
+ * `undefined` where an edge carries fewer args than the block has params — a malformed graph the
82
+ * verifier catches elsewhere, and one today's callers pass straight through rather than treat as
83
+ * "not trivial". A truthiness test would silently change that.
84
+ */
85
+ function trivialPhiValue(incoming: readonly Successor[], i: number, param: Value): Value | null {
86
+ const distinct = [...new Set(incoming.map((s) => s.args[i]).filter((v) => v !== param))];
87
+ return distinct.length === 1 ? distinct[0] : null;
88
+ }
89
+
90
+ /**
91
+ * "Is there a block parameter `simplifyTrivialPhis` would remove?" — the same predicate, asked
92
+ * without mutating. Returns the first such param and its block, or null.
93
+ *
94
+ * A BOUNDARY postcondition rather than an IR invariant, which is why `ir/verify.ts` is the wrong
95
+ * home for it: a trivial phi is well-formed IR, and two places mint one deliberately and clear it
96
+ * within their own scope — Braun's construction in `frontend/ssa.ts`, and the `addrnum`
97
+ * pre-recovery pass, whose numbering half leaves one for its own cleanup half. What no pass may do
98
+ * is leave one STANDING once the CFG stops moving (pipeline.ts `raiseRecovered`), because from
99
+ * there down the structurer reads a block parameter as a JOIN and gives it a local of its own.
100
+ * That is how a CFG-motion pass does its damage three stages away: `raise/retsink.ts` stranded a
101
+ * single-predecessor merge, the structurer spelled its alias as `v0 = 0; return v0;`, and Regime-A
102
+ * switch recovery read the block as a SECOND `default` candidate and declined every fall-through
103
+ * tree over it.
104
+ */
105
+ export function firstTrivialPhi(fn: Fn): { block: Block; param: Value } | null {
106
+ // ONE pass over the successor edges, indexed by target — `simplifyTrivialPhis` rescans the
107
+ // whole function per block, which is fine for a mutating fixpoint and not for a check on the
108
+ // raising tower's hot path (a candidate fan re-raises the same function once per lift variant).
109
+ const incomingOf = new Map<Block, Successor[]>();
110
+ for (const pb of fn.blocks) {
111
+ for (const op of pb.ops) {
112
+ for (const s of op.successors) {
113
+ const prev = incomingOf.get(s.block);
114
+ if (prev) {
115
+ prev.push(s);
116
+ } else {
117
+ incomingOf.set(s.block, [s]);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ for (const b of fn.blocks) {
123
+ if (b === fn.blocks[0]) {
124
+ continue;
125
+ }
126
+ const incoming = incomingOf.get(b) ?? [];
127
+ for (let i = 0; i < b.params.length; i++) {
128
+ const param = b.params[i];
129
+ if (trivialPhiValue(incoming, i, param) !== null) {
130
+ return { block: b, param };
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Remove block params NOTHING READS. Their edge args are dropped with them, so a dead join value
139
+ * never surfaces downstream: left in place, the structurer dutifully materializes copies for it
140
+ * on every in-edge (`a3 = v0` after a loop whose counter nobody consumes), and gates keyed on
141
+ * "does this exit carry anything" see cargo that is not there.
142
+ *
143
+ * A read is an OP OPERAND. An edge arg is not one in itself — it forwards a value into a slot, and
144
+ * that is a read exactly when the slot is live — so liveness is a LEAST FIXPOINT seeded by the op
145
+ * operands and grown backwards along the edges. Asking instead "does any arg mention it" is what a
146
+ * per-round reader scan does, and it keeps a mutually-dead CYCLE alive forever: two params feeding
147
+ * only each other across blocks each count as the other's reader. Such a cycle is what the
148
+ * frontend's on-demand construction mints around a loop for a register left holding a stale value,
149
+ * and it is not inert — `raise/struct-arrays.ts` refuses an element pointer that reaches a block
150
+ * arg, so a dead ring around one costs the struct view of an array it has a single real use of.
151
+ *
152
+ * Still conservative in one direction: a param a real op operand reads survives, however dead that
153
+ * op later proves. Liveness is over the IR as it stands, and op-level DCE belongs to
154
+ * `pattern/engine.ts`.
155
+ *
156
+ * THAT MAKES TWO LIVENESS MODELS OVER ONE GRAPH, and they now DISAGREE about the same edge.
157
+ * `pattern/engine.ts`'s `dce` still counts every successor arg as a use unconditionally — the rule
158
+ * abandoned here — and it runs after every changing pre-recovery pass where this runs once, inside
159
+ * the frontend's `finish()`. The disagreement is one-sided and safe: `dce` is the COARSER of the
160
+ * two, so it only ever keeps an op this would have let go, never the reverse. Reach today is ZERO
161
+ * and that is measured rather than argued — re-running this fixpoint over the 458 klonoa functions
162
+ * that clear the frontend, at three checkpoints (straight after the lift, after the idiom fold and
163
+ * after type recovery), removes 0 further params at every one. Booked here so a future round that
164
+ * finds a nonzero reads it as the known divergence rather than a new discovery.
165
+ *
166
+ * THE ENTRY BLOCK IS NEVER TOUCHED — its params are the function's signature, and an argument the
167
+ * body ignores is still an argument (frontend/ssa.ts `ensureParam` creates exactly those on
168
+ * purpose). They are therefore seeded LIVE rather than merely skipped: an entry block that is also
169
+ * a loop header has in-edges, and a slot that is kept has to keep whatever feeds it defined. A
170
+ * function with no blocks has no entry and no params, and is a no-op rather than a throw — this
171
+ * runs mid-construction, ahead of the verifier that rejects such a graph.
172
+ * Returns how many were removed.
173
+ */
174
+ export function pruneDeadParams(fn: Fn, onRemoved?: (param: Value) => void): number {
175
+ // Liveness travels BACKWARD — from a live slot to the args feeding it — so it is driven off a
176
+ // WORKLIST over an inverted edge index, not by re-sweeping the graph until a round adds nothing.
177
+ // Round-robin over `fn.blocks` in forward order advances one hop per round along a chain of
178
+ // block params, which is quadratic in the chain length. The worklist is linear in
179
+ // (values + edge args) whatever the block order, and it matters because this is shared L1
180
+ // substrate that runs once per lift on every ISA. Nothing in the corpus reaches the shape today
181
+ // — the largest klonoa function that lifts at all is 107 blocks — but that ceiling is a property
182
+ // of what this frontend currently accepts (274 of the checkout's 732 `.s` decline), not of the
183
+ // game's code, so `test/dead-params.test.ts` budgets both halves against `parse` of the same
184
+ // text.
185
+ //
186
+ // `edgesTo` is the same index the removal pass needs, so it is built ONCE for both: without it
187
+ // each removed param re-walks every op in the function.
188
+ const edgesTo = new Map<Block, Successor[]>();
189
+ for (const b of fn.blocks) {
190
+ for (const op of b.ops) {
191
+ for (const s of op.successors) {
192
+ const list = edgesTo.get(s.block);
193
+ if (list === undefined) {
194
+ edgesTo.set(s.block, [s]);
195
+ } else {
196
+ list.push(s);
197
+ }
198
+ }
199
+ }
200
+ }
201
+ // Which args feed a given slot. Keyed by the PARAM value (identity is the graph's own), so a
202
+ // slot that becomes live hands back exactly the values that flow into it.
203
+ const feeders = new Map<Value, Value[]>();
204
+ for (const b of fn.blocks) {
205
+ b.params.forEach((slot, i) => {
206
+ const args = (edgesTo.get(b) ?? []).map((s) => s.args[i]).filter((a): a is Value => a !== undefined);
207
+ if (args.length > 0) {
208
+ feeders.set(slot, (feeders.get(slot) ?? []).concat(args));
209
+ }
210
+ });
211
+ }
212
+
213
+ const live = new Set<Value>();
214
+ const work: Value[] = [];
215
+ const mark = (v: Value): void => {
216
+ if (!live.has(v)) {
217
+ live.add(v);
218
+ work.push(v);
219
+ }
220
+ };
221
+ for (const p of fn.blocks[0]?.params ?? []) {
222
+ mark(p);
223
+ }
224
+ for (const b of fn.blocks) {
225
+ for (const op of b.ops) {
226
+ for (const v of op.operands) {
227
+ mark(v);
228
+ }
229
+ }
230
+ }
231
+ for (let v = work.pop(); v !== undefined; v = work.pop()) {
232
+ for (const a of feeders.get(v) ?? []) {
233
+ mark(a);
234
+ }
235
+ }
236
+
237
+ // One removal pass suffices: `live` is the fixpoint over the whole graph, so dropping the slots
238
+ // outside it cannot make a surviving slot dead, and the args it drops were feeding dead slots.
239
+ let removed = 0;
240
+ for (const b of fn.blocks) {
241
+ if (b === fn.blocks[0]) {
242
+ continue;
243
+ }
244
+ const incoming = edgesTo.get(b) ?? [];
245
+ for (let i = b.params.length - 1; i >= 0; i--) {
246
+ const param = b.params[i];
247
+ if (live.has(param)) {
248
+ continue;
249
+ }
250
+ onRemoved?.(param);
251
+ b.params.splice(i, 1);
252
+ for (const s of incoming) {
253
+ s.args.splice(i, 1);
254
+ }
255
+ removed++;
256
+ }
257
+ }
258
+ return removed;
259
+ }
@@ -0,0 +1,42 @@
1
+ // THE STRUCT-NAME ALLOCATOR, in one place because there are three minters of synthesized struct
2
+ // names (raise/structs.ts `Struct<N>`, raise/struct-arrays.ts `Elem<N>`, l3/offmember.ts `Off<N>`)
3
+ // and each had rolled its own scan, at two different strengths.
4
+ //
5
+ // `l3/hoist.ts`'s header states the rule for LOCALS — "every pass that mints a local takes
6
+ // `nameAllocator` (or `takenNames`, to number its own)". This is that rule for structs.
7
+ //
8
+ // THE CONTRACT IS MONOTONE, never first-free: the next index is past EVERY taken one. Skipping a
9
+ // CONTIGUOUS PREFIX is a weaker guarantee than being free — a set holding `Off0` and `Off2` stops
10
+ // a first-free walk at 1, and the name minted after it is `Off2` again, one layout declared twice
11
+ // under one name, which is invisible in the tree (`structs` is a list, not a map) and surfaces
12
+ // either as a compile error or, through a name-deduping consumer, as one access reading another
13
+ // layout's member. That is the class of silent loss PR #127 named for `localNames`.
14
+ //
15
+ // AND AT THE Elem AND Off CALL SITES THE SEED IS 0. Measured, not assumed: instrumenting both and
16
+ // lifting every corpus function gives 1498 `Elem` seeds and 1417 `Off` seeds map-less, 417 and 398
17
+ // map-ful, all zero. Each of those two prefixes has exactly one minter and each minter runs once
18
+ // per function (raise/pre-recovery.ts is a linear pass list, `/offmember` a single respell), so no
19
+ // tree either one is handed can already carry its own prefix.
20
+ //
21
+ // THE `Struct` PREFIX IS THE COUNTER-EXAMPLE, and it is why the contract is not simply "return 0":
22
+ // TWO passes mint it, raise/memberarrays.ts ahead of raise/structs.ts in the pre-recovery list, so
23
+ // the second is routinely handed a graph already carrying the first's `Struct<N>` types and seeds
24
+ // past them (its own note at the seeding site says so).
25
+ //
26
+ // THAT IS NOT THE SAME AS A GUARD WITH NO INHABITANT, and the difference is why this is kept
27
+ // where such a guard is not. A refusal with no inhabitant still asserts a hazard, and starts
28
+ // deleting candidates the day its predicate widens; an allocator with no taken name is the
29
+ // IDENTITY — it returns the same 0 a hand-rolled counter returns. What it buys is that the 0 is a
30
+ // computed fact rather than a caller invariant nothing checks. The price is one scan of the taken
31
+ // names per call, which at the `Elem` site is a type-graph walk.
32
+ export function nextStructIndex(taken: Iterable<string>, prefix: string): number {
33
+ const re = new RegExp(`^${prefix}(\\d+)$`);
34
+ let next = 0;
35
+ for (const name of taken) {
36
+ const m = re.exec(name);
37
+ if (m) {
38
+ next = Math.max(next, Number(m[1]) + 1);
39
+ }
40
+ }
41
+ return next;
42
+ }