Skip to main content

logicaffeine_compile/codegen_sva/
full_asm.rs

1//! full_asm — assemble a compiled LOGOS program (register bytecode `CompiledProgram`) into the
2//! instruction words the FULL soft-core (`soft_core.v`) executes: the whole 113-op ISA — user functions
3//! (hardware arg-copy `Call`), division/modulo (the sequential shared divider), and the structured heap
4//! (lists, tuples, ranges, sets, maps, structs) — so the ENTIRE benchmark suite compiles from `.lg` source
5//! to words that run on silicon byte-identically to the VM.
6//!
7//! Unlike `scalar_asm` (the function-free combinational subset), this targets the full datapath and lays
8//! out Main + every function body in one imem image. The op BYTE is always `binisa::opcode(op)` — the
9//! append-only ISA ABI the hardware decoder keys on — and every operand field is packed exactly as
10//! `codegen_sva::rtl::run_soft_core` decodes it (the ≡VM software model is the field-layout spec).
11//!
12//! Locked ≡ VM by `rtl.rs::tests::encode_full_matches_vm`.
13
14use crate::vm::CompiledProgram;
15
16#[cfg(test)]
17#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/full_asm/survey.rs"]
18mod survey;
19
20use crate::vm::binisa::opcode;
21use crate::vm::{Constant, Op, Reg};
22
23/// A register index must fit the full core's 256-entry file (8-bit operand fields).
24fn reg(r: Reg) -> Result<u32, String> {
25    if r > 255 {
26        return Err(format!(
27            "register r{r} exceeds the full core's 256-register file"
28        ));
29    }
30    Ok(r as u32)
31}
32
33/// `[opcode:8][dst:8][a:8][b:8]` — the general full-core word (8-bit operand fields).
34fn w(oc: u16, dst: u32, a: u32, b: u32) -> u32 {
35    ((oc as u32) << 24) | (dst << 16) | (a << 8) | b
36}
37
38// ── CHIP-SYNTHETIC FLOAT OPCODES ──────────────────────────────────────────────────────────────────
39// The soft-core is dynamically typed at the VM level (`Add`/`Mul`/… are polymorphic over Int and f64),
40// but silicon needs a fixed datapath per op. These opcodes drive the 64-bit float bank + `fpu.v`. They
41// sit ABOVE the append-only ISA table (like `LoadConstWide`=200) so they are pure chip-internal decode
42// with NO `Op` variant and NO ISA-map entry — the ISA totality/append-only locks stay untouched. The
43// encoder emits them by STATICALLY lowering a float-typed op (see [`FloatTyping`]); `run_soft_core`
44// models each with real Rust `f64` (bit-identical to the RTL), and `soft_core.v` dispatches them to
45// `fpu.v`. Numbers must match `codegen_sva::rtl::run_soft_core_typed` and `soft_core.v` exactly.
46const FCONST: u16 = 201; // 3-word: [201][dst][0][0] ‖ bits[31:0] ‖ bits[63:32]
47const FADD: u16 = 202;
48const FSUB: u16 = 203;
49const FMUL: u16 = 204;
50const FDIV: u16 = 205;
51#[allow(dead_code)]
52const FSQRT: u16 = 206; // reached via the `sqrt(x)` builtin lowering (spectral_norm / nbody)
53const FLT: u16 = 207; // float `<` -> int 0/1 in regs
54const FEQ: u16 = 208; // float `==` -> int 0/1 in regs
55const FLE: u16 = 229; // float `<=` -> int 0/1 in regs (le = lt|eq; `>=` is FLe with swapped operands)
56const FNE: u16 = 230; // float `!=` -> int 0/1 in regs (ne = !eq)
57                      // The `is approximately` (isclose) compare has NO dedicated float opcode: `float_shape` DECOMPOSES it into
58                      // the primitive float ops the RTL already sequences (FEq/FSub/FAbs/FMax/FConst/FMul/FLe) combined by an int
59                      // `BitOr` — so no bespoke multi-cycle isclose FSM is needed and the SAME image runs on the model and RTL.
60const BITOR: u16 = 21; // `eq | le` — the ISA `BitOr` ALU op (combines the two 0/1 isclose results)
61
62// The FLOAT-plane CallBuiltin (opcode 38) coprocessor ordinals — a chip-internal numbering DISTINCT from
63// the integer-plane ordinals in `builtin_chip_ordinal` (5/7/8/9): these read `fregs` (an operand proven
64// float by the type oracle). 36/37/38 are `abs`/`min`/`max` on floats (fregs → fregs). 39/40/41 are
65// `floor`/`ceil`/`round` on a float — fregs → REGS (an INT result: the VM's `f.floor() as i64` etc). The
66// model's `38 =>` arm branches on the ordinal to pick the plane; `soft_core.v`'s fpu case mirrors it.
67const CB_FABS: u32 = 36;
68const CB_FMIN: u32 = 37;
69const CB_FMAX: u32 = 38;
70const CB_FLOOR: u32 = 39;
71const CB_FCEIL: u32 = 40;
72const CB_FROUND: u32 = 41;
73const INT_TO_F: u16 = 209; // fregs[dst] = regs[src] as f64  (Int operand used in a float context)
74#[allow(dead_code)]
75const F_TO_INT: u16 = 210; // regs[dst] = fregs[src] as i32
76const FMOVE: u16 = 211; // fregs[dst] = fregs[src]
77const SHOWF: u16 = 212; // Show a float
78                        // ── THE FLOAT-HEAP PLANE (chip-synthetic opcodes 213..=215) ──────────────────────────────────────────
79                        // A `Seq of Float` handle is an INT pointer into the integer heap (which keeps the `[length, …]` header +
80                        // structure); its f64 element payloads ride a PARALLEL float plane (`fheap`/`fdmem`) at the SAME indices.
81                        // The encoder emits these when the float-seq type oracle proves a heap op operates on a `Seq of Float`:
82                        // a float `Push`/`Add to`, a float `Set item`, and an `item i of (Seq of Float)` read.
83const FLIST_PUSH: u16 = 213; // FListPush { list=dst, value=ra } — int ListPush structure + set fheap[slot]=fregs[ra]
84const FINDEX: u16 = 214; // FIndex { dst, coll=ra, index=rb } — fregs[dst] = fheap[regs[ra]+regs[rb]]
85const FSET_INDEX: u16 = 215; // FSetIndex { coll=dst, index=ra, value=rb } — fheap[regs[dst]+regs[ra]] = fregs[rb]
86const FITER_NEXT: u16 = 231; // FIterNext { dst, exit:16 } — `Repeat for v in (Seq of Float)`: fregs[dst] = fheap[ptr+cursor]
87                             // ── THE TEXT (string) PLANE (chip-synthetic opcodes 216/217) ─────────────────────────────────────────
88                             // A `Text` is a char-code list `[len, c0, c1, …]` on the integer heap — so its OPERATIONS (Concat = a Text
89                             // `Add`, Length, `item i of`, FormatValue) already ride the list/heap datapath. The two things a char-list
90                             // identity does NOT supply are a Text CONSTANT (there is no 32-bit-integer encoding of "hello") and a Text
91                             // `Show` (the string must be reconstituted from the char list). These two chip-synthetic opcodes fill the
92                             // gap; the char data of every distinct Text constant is appended AFTER the code as a STRING-CONSTANT POOL in
93                             // imem (compact — one shared block per string), and `TextConst` copies a block into a fresh heap char-list.
94/// Pool keys of the Text a failure may need that no program constant spells: the two Bool renderings
95/// (`Fail with false`) and the refusal of a non-Text contract reason, one per kind the chip carries.
96const POOL_TRUE: u32 = u32::MAX - 1;
97const POOL_FALSE: u32 = u32::MAX - 2;
98const POOL_REFUSE_INT: u32 = u32::MAX - 3;
99const POOL_REFUSE_BOOL: u32 = u32::MAX - 4;
100const FAILURE_POOL: &[(u32, &str)] = &[
101    (POOL_TRUE, "true"),
102    (POOL_FALSE, "false"),
103    (
104        POOL_REFUSE_INT,
105        "The reason after `otherwise` must be Text, but it was Int.",
106    ),
107    (
108        POOL_REFUSE_BOOL,
109        "The reason after `otherwise` must be Text, but it was Bool.",
110    ),
111];
112
113/// How a failure reaches the chip's failure contract: stream the reason over the Text port
114/// (`ShowText`), then trap-halt with `reasoned` set (`FailWith`, rb = 1).
115enum FailPlan {
116    /// The reason is already a Text in `src`.
117    Text { src: Reg },
118    /// `Fail with <Int>` — render it (`FormatValue`) first.
119    Int { src: Reg },
120    /// `Fail with <Bool>` — select the pooled `true`/`false` by arithmetic on the 0/1 value, using the
121    /// never-returning call's `dst` as the second temporary.
122    Bool { src: Reg, dst: Reg },
123    /// A pooled Text (a `FailWith` message, a refusal) — its pool key.
124    Pooled { key: u32 },
125}
126
127impl FailPlan {
128    fn width(&self) -> usize {
129        match self {
130            FailPlan::Text { .. } => 2,
131            FailPlan::Int { .. } => 3,
132            FailPlan::Bool { .. } => 9,
133            FailPlan::Pooled { .. } => 4,
134        }
135    }
136}
137
138/// The `format(x)` builtin on the chip: `x`'s text exactly as `Show` renders it.
139enum FormatPlan {
140    /// Already Text — the same handle.
141    Text { dst: Reg, src: Reg },
142    /// An Int — its decimal (`FormatValue`).
143    Int { dst: Reg, src: Reg },
144    /// A Bool — the pooled `true`/`false`, selected by arithmetic on the 0/1 value.
145    Bool { dst: Reg, src: Reg },
146}
147
148impl FormatPlan {
149    fn width(&self) -> usize {
150        match self {
151            FormatPlan::Text { .. } | FormatPlan::Int { .. } => 1,
152            FormatPlan::Bool { .. } => 7,
153        }
154    }
155}
156
157fn format_plan(i: usize, op: &Op, ft: &FloatTyping) -> Result<Option<FormatPlan>, String> {
158    use crate::semantics::builtins::BuiltinId;
159    let Op::CallBuiltin {
160        dst,
161        builtin: BuiltinId::Format,
162        args_start,
163        arg_count,
164    } = op
165    else {
166        return Ok(None);
167    };
168    if *arg_count != 1 {
169        return Err(format!("instruction {i}: format() takes one value"));
170    }
171    let (dst, src) = (*dst, *args_start);
172    Ok(Some(if ft.is_text(i, src) {
173        FormatPlan::Text { dst, src }
174    } else if ft.is_float(i, src) {
175        return Err(format!(
176            "instruction {i}: format() of a Real has no chip rendering (the chip's float formatter is \
177             fixed-precision, not `Show`'s)"
178        ));
179    } else if ft.is_bool(i, src) {
180        FormatPlan::Bool { dst, src }
181    } else {
182        FormatPlan::Int { dst, src }
183    }))
184}
185
186/// The failure lowering of op `i`, if it is one: a `FailWith` with a Text message, or the `Fail` /
187/// `FailReason` builtins. A reason this chip cannot render as `Show` would (a Float) is refused.
188fn failure_plan(
189    i: usize,
190    op: &Op,
191    constants: &[Constant],
192    ft: &FloatTyping,
193) -> Result<Option<FailPlan>, String> {
194    use crate::semantics::builtins::BuiltinId;
195    Ok(match op {
196        Op::FailWith { msg } if matches!(constants.get(*msg as usize), Some(Constant::Text(_))) => {
197            Some(FailPlan::Pooled { key: *msg })
198        }
199        Op::CallBuiltin {
200            dst,
201            builtin: b @ (BuiltinId::Fail | BuiltinId::FailReason),
202            args_start,
203            ..
204        } => {
205            let a = *args_start;
206            Some(if ft.is_text(i, a) {
207                FailPlan::Text { src: a }
208            } else if ft.is_float(i, a) {
209                return Err(format!(
210                    "instruction {i}: a failure reason that is a Real has no chip rendering (the chip's float \
211                     formatter is fixed-precision, not `Show`'s)"
212                ));
213            } else if *b == BuiltinId::Fail {
214                if ft.is_bool(i, a) {
215                    FailPlan::Bool { src: a, dst: *dst }
216                } else {
217                    FailPlan::Int { src: a }
218                }
219            } else if ft.is_bool(i, a) {
220                FailPlan::Pooled {
221                    key: POOL_REFUSE_BOOL,
222                }
223            } else {
224                FailPlan::Pooled {
225                    key: POOL_REFUSE_INT,
226                }
227            })
228        }
229        _ => None,
230    })
231}
232
233/// The string-pool key of the program name a chip's `args()` answers with ([`crate::semantics::PROGRAM_NAME`],
234/// the host-less argv); no real constant index reaches it.
235const ARGV0_POOL_KEY: u32 = u32::MAX;
236const TEXTCONST: u16 = 216; // TextConst { dst } — 2-word: word0=[216][dst][len:16], word1=pool_word_offset (imem index of c0). Copies len chars imem[off..off+len] into a fresh heap [len, chars…]; regs[dst]=ptr.
237const SHOWTEXT: u16 = 217; // ShowText { src=ra } — decode the char list at regs[ra] to a UTF-8 string and Show it
238const TEXTCHARAT: u16 = 218; // TextCharAt { dst, coll=ra, index=rb } — a single-char Text: fresh heap [1, heap[regs[ra]+regs[rb]]] (the VM's `item i of text` returns a 1-char string)
239const TEXTEQ: u16 = 219; // TextEq { dst, a=ra, b=rb } — char-list CONTENT equality (regs[dst] = 1/0), matching the VM's Text `equals`
240const TEXTNE: u16 = 220; // TextNe { dst, a=ra, b=rb } — char-list content INEQUALITY (the negation of TextEq)
241                         // ── THE WORD32 (ℤ/2³²) PLANE (chip-synthetic opcode 221) ─────────────────────────────────────────────
242                         // A `Word32` is the 32-bit register's own bit pattern, so its VALUE rides the ordinary integer datapath —
243                         // `word32(n)`/`intOfWord32(w)` are the identity (`Move`), `word_and`/`word_or`/`word_not` are the existing
244                         // `BitAnd`/`BitOr`/`Not` ALU ops (bit-identical to the ring), and a Word32 `+` is `Add` (wrapping i32 ==
245                         // wrapping u32). The ONLY things the integer datapath does NOT supply are a ROTATE and a LOGICAL right shift
246                         // (the ALU's `Shr` is ARITHMETIC) — routed through the `CallBuiltin` coprocessor (opcode 38) — and an
247                         // UNSIGNED `Show`: the integer `Show`=89 prints `i32` SIGNED, but a Word32 (SHA-256 hash word > 2³¹) must
248                         // print unsigned. `ShowWord32` is that unsigned Show; the model renders `ShowVal::Word32`, and `soft_core.v`
249                         // routes it to a separate output port so the host formats it unsigned (int/float/string Shows unchanged).
250const SHOWWORD32: u16 = 221; // ShowWord32 { src=ra } — Show the 32-bit result at regs[ra] as UNSIGNED (0..2³²−1)
251                             // ── THE 64-BIT CONSTANT (chip-synthetic opcode 222) ──────────────────────────────────────────────────
252                             // The inline 16-bit (opcode 5) and wide 32-bit (opcode 200) LoadConst forms cannot hold a constant that
253                             // exceeds i32 (the benchmark PRNGs' `% 2147483648`/`% 4294967296` moduli, 2³¹ and 2³², or a full-range i64
254                             // literal). Such a constant is emitted as a 3-word LoadConstWide64 at EVERY datapath width: word0 =
255                             // [222][dst][0][0], word1 = bits[31:0], word2 = bits[63:32] — the full i64, mirroring `FConst`'s
256                             // two-immediate-word layout. `run_soft_core_typed_w`'s `222 =>` arm retires the FULL i64 irrespective of
257                             // width, and the value plane auto-promotes to BigInt on overflow exactly like the VM's unbounded `Int`, so
258                             // the full core reproduces every constant — and every arithmetic result — EXACTLY at width 32 as at 64. The
259                             // width is a fast-path/area knob (how wide the native adder is), not a correctness switch: BigInt handles
260                             // exactness, so the full-core image is width-independent. (The separate 32-bit tape-out core in
261                             // `scalar_asm.rs` genuinely wraps at 2³² — that is the small-area chip, distinct from this exact full core.)
262const LOADCONSTWIDE64: u16 = 222;
263// ── THE INT-KEY MAP PLANE (chip-synthetic opcodes 223/224) ───────────────────────────────────────────
264// A `Map of Int to …` stores its keys as DIRECT integers in the key cells (not char-list pointers). The
265// shared map ops SetIndex/Index (57/59) compare keys with `key_eq` — a char-list CONTENT compare that reads
266// the key as a heap pointer — which is correct for a `Map of Text` key but WRONG for an integer key (it would
267// dereference the int as an address). These two chip-synthetic ops carry the int-key path: `MapIndexInt`
268// (223) looks up by integer `==`, `MapSetIndexInt` (224) sets/appends by integer `==`. The encoder emits them
269// for an `Index`/`SetIndex` whose collection is a MAP and whose key is statically NON-Text; a `Map of Text`
270// key (a Text-typed key register) keeps the string-key 57/59 form, and a plain LIST index stays positional
271// 57/59. So the string-key map path (locked by `the_soft_core_runs_logos_programs_identically`) is untouched.
272const MAPINDEXINT: u16 = 223; // MapIndexInt { dst, map=ra, key=rb } — map lookup by integer key (==)
273const MAPSETINDEXINT: u16 = 224; // MapSetIndexInt { map=dst, key=ra, value=rb } — map set/append by integer key (==)
274                                 // ── THE FLOAT FORMAT PLANE (chip-synthetic opcode 225) ───────────────────────────────────────────────
275                                 // A `Show "{f:.N}"` on a FLOAT renders `fregs[src]` to N decimal places as a Text char-list. The integer
276                                 // `FormatValue` (op 64) reads `regs[src]` and builds the decimal string of an i64; a float needs the FPU's
277                                 // value and a fixed-precision decimal formatting the integer path can't express. `FormatValueFloat` reads
278                                 // the f64 in `fregs[src]`, formats it EXACTLY as the VM (`apply_format_spec` on a `.N` spec = Rust's
279                                 // `format!("{:.N$}", f)`), and lays the UTF-8 char-codes out as a fresh heap `[len, chars…]` — the same Text
280                                 // char-list shape op 64 / `TextConst` / `Concat` produce, so a downstream `ShowText` (217) decodes it
281                                 // byte-identically. The precision N (0..255; the benchmarks use 9 / 15) rides the word's b-byte.
282const FORMATVALUE_FLOAT: u16 = 225; // FormatValueFloat { dst, src=ra, prec=rb } — fixed-precision decimal Text of fregs[ra]
283                                    // ── THE WORD32 (ℤ/2³²) ARITHMETIC PLANE (chip-synthetic opcodes 226/227/228) ─────────────────────────
284                                    // A `Word32 +`/`-`/`*` adds/subtracts/multiplies IN THE RING ℤ/2³² (wrapping mod 2³²), exactly as the VM's
285                                    // `Word32::add`/`sub`/`mul` (`u32::wrapping_add`/…). This is DISTINCT from the value-integer ALU (opcodes
286                                    // 8/10/11), which is now UNBOUNDED (auto-promoting to BigInt on overflow) so that a plain `Int +` matches
287                                    // the VM's exact `Int` — a Word32 sum must NOT promote, it must wrap to 32 bits (SHA-256's `s1 + s0 + …`
288                                    // accumulator would otherwise drift past 2³² round over round and corrupt the hash). The model computes
289                                    // `((a as u32) wrapping_op (b as u32)) as i32` and stays `Small`; the result Shows UNSIGNED via ShowWord32.
290const WORD32ADD: u16 = 226; // Word32Add { dst, a=ra, b=rb } — regs[dst] = (regs[ra] +ᵤ regs[rb]) mod 2³²
291const WORD32SUB: u16 = 227; // Word32Sub { dst, a=ra, b=rb } — regs[dst] = (regs[ra] -ᵤ regs[rb]) mod 2³²
292const WORD32MUL: u16 = 228; // Word32Mul { dst, a=ra, b=rb } — regs[dst] = (regs[ra] ·ᵤ regs[rb]) mod 2³²
293                            // ── THE TEXT / SEQ BUILTIN PLANE (chip-synthetic opcodes 233–237) ──────────────────────────────────
294                            // The deterministic string / fill builtins the CHIP compiler emits from source, each producing an on-heap
295                            // value from register operands — byte-identical to the matching `semantics::builtins::call_builtin` arm:
296                            //   • `Chr(code)` (233) — a ONE-character Text: a fresh heap char-list `[1, codepoint]` (the same shape
297                            //     `TextConst`/op 64/`Concat` produce, so a downstream `ShowText` decodes it identically). An invalid
298                            //     codepoint (a surrogate / > 0x10FFFF) TRAPS (the VM's `char::from_u32` → `Err`), pre-trap output stands.
299                            //   • `TextContains(hay, needle)` (234) — a SUBSTRING char-scan over two Text char-lists → an int 0/1 in
300                            //     regs[dst] (the empty needle is contained in everything), matching the VM's `h.contains(n)`.
301                            //   • `RepeatSeq(elem, count)` (235) — a fresh `count`-slot Seq, every slot the SAME scalar `regs[elem]`
302                            //     (`count ≤ 0` empty), matching the VM's `(0..n).map(|_| element.deep_clone())` for a SCALAR element.
303                            //     The encoder emits it only when the element is provably a scalar (not float/text/seq/map); a
304                            //     reference element would need per-slot deep copies the flat fill can't express, so it rejects inside.
305                            //   • `ParseInt(text)` (236) — a bounded DECIMAL char-scan over the Text char-list at `regs[text]` → the
306                            //     i64 value in `regs[dst]`: trim Unicode whitespace, an optional single leading `+`/`-`, then ≥1 ASCII
307                            //     digit accumulating `acc = acc*10 − digit` in the NEGATIVE magnitude (so `-9223372036854775808` = i64::MIN
308                            //     parses), negating for a positive sign. A non-numeric / empty / OVERFLOWING input TRAPS (halts, pre-trap
309                            //     output stands) — byte-identical to the VM's `str::trim().parse::<i64>()` returning `Err` (the VM does NOT
310                            //     promote an overflow to BigInt, it errors, and that `?` aborts the run just like the chip's `break`).
311                            //   • `TextBytes(text)` (237) — the UTF-8 encoding of the Text char-list at `regs[text]` as a fresh `Seq of
312                            //     Int` `[n, b0, b1, …]` (each byte 0..=255), matching the VM's `bytes_to_seq(s.as_bytes())`. A codepoint
313                            //     encodes to 1–4 bytes by its range (< 0x80, < 0x800, < 0x10000, else four); a char-list holds only
314                            //     scalar values (every producer — TextConst, Chr, FormatValue, Concat — validates), so no surrogate
315                            //     reaches the encoder and the op never traps. The byte count is known only after the scan, so the
316                            //     header is written last, the same order the RTL's single write port uses.
317const CHR: u16 = 233; // Chr { dst, code=ra } — regs[dst] = ptr to a fresh 1-char Text `[1, code]`; trap on an invalid codepoint
318const TEXTCONTAINS: u16 = 234; // TextContains { dst, hay=ra, needle=rb } — regs[dst] = 1/0 (needle is a substring of hay)
319const REPEATSEQ: u16 = 235; // RepeatSeq { dst, elem=ra, count=rb } — regs[dst] = ptr to a fresh `count`-slot Seq filled with regs[elem]
320const PARSEINT: u16 = 236; // ParseInt { dst, text=ra } — regs[dst] = the i64 value of the decimal Text at regs[ra]; trap on a non-numeric / empty / overflowing input (matches the VM's `str::trim().parse::<i64>()` error)
321const TEXTBYTES: u16 = 237; // TextBytes { dst, text=ra } — regs[dst] = ptr to a fresh Seq of the UTF-8 bytes of the Text at regs[ra]
322
323// The CallBuiltin (opcode 38) coprocessor ORDINAL for each chip-lowered builtin, carried in the word's
324// b-byte. A chip-internal numbering (NOT the `BuiltinId` discriminant) shared with `run_soft_core_typed`'s
325// `38 =>` arm and `soft_core.v`. This function names the INTEGER-plane ordinals (they read/write `regs`):
326// 5/7/8 are Abs/Min/Max, 9 is `count_ones`; 30/31/32 are the Word32 rotate / logical-shift the integer ALU
327// can't express. A builtin with a direct ALU lowering (word32/intOfWord32 → Move, word_and → BitAnd, …) is
328// NOT here — it never reaches opcode 38. The FLOAT-plane numeric builtins (`floor`/`ceil`/`round` on a
329// float, and `abs`/`min`/`max` on floats) ride `float_shape` with their own ordinals (36..=41), reading
330// `fregs`; they are NOT integer-plane ops, so they are not returned here.
331fn builtin_chip_ordinal(id: crate::semantics::builtins::BuiltinId) -> Option<u8> {
332    use crate::semantics::builtins::BuiltinId::*;
333    Some(match id {
334        Abs => 5,
335        Min => 7,
336        Max => 8,
337        CountOnes => 9,
338        Rotl => 30,
339        Rotr => 31,
340        Word32Shr => 32,
341        _ => return None,
342    })
343}
344
345/// The chip-synthetic opcode a DETERMINISTIC string / fill builtin lowers to (`Chr`=233 / `contains` on
346/// TEXT=234 / `repeatSeq`=235 / `parseInt`=236 / `text_bytes`=237), with the static TYPE GATE that keeps the lowering sound;
347/// `Ok(None)` for a builtin handled elsewhere (the op-38 numeric coprocessor, the Word32 vocabulary via
348/// `word_shape`) or a genuine host/reference-value boundary (`format`/uuid/…) that reaches the loud reject. An
349/// `Err` is the HONEST reject for a supported builtin whose OPERAND SHAPE has no chip form — a `contains`
350/// on a non-Text operand (the list `contains` rides `Op::Contains`, not this builtin) or a `repeatSeq` of
351/// a float / reference element (the flat fill can't deep-copy a heap element per slot). Both `imem_width`
352/// and `encode_op` consult this so their widths and rejects agree exactly. Mirrors `builtin_chip_ordinal`.
353fn text_builtin_opcode(
354    i: usize,
355    builtin: crate::semantics::builtins::BuiltinId,
356    args_start: Reg,
357    ft: &FloatTyping,
358) -> Result<Option<u16>, String> {
359    use crate::semantics::builtins::BuiltinId;
360    Ok(match builtin {
361        // `chr(code)` — a 1-char Text from an INTEGER codepoint. The argument is an ordinary integer
362        // register; a float/text/seq codepoint is a type error the VM also rejects (`chr` requires an Int),
363        // so gate it to a scalar-int operand (not float / not text / not float-seq).
364        BuiltinId::Chr => {
365            let code = args_start;
366            if ft.is_float(i, code) || ft.is_text(i, code) || ft.is_float_seq(i, code) {
367                return Err(format!(
368                    "instruction {i}: `chr` needs an integer codepoint (r{code} is not an integer) — no \
369                     full-core chip lowering for a non-integer `chr` argument"
370                ));
371            }
372            Some(CHR)
373        }
374        // `contains(hay, needle)` on TEXT — both operands must be Text char-lists (the LIST `contains` rides
375        // `Op::Contains`, opcode 63, never this builtin). A non-Text operand has no substring semantics, so
376        // reject it loudly rather than mis-scanning a numeric heap block as codepoints.
377        BuiltinId::Contains => {
378            let hay = args_start;
379            let needle = args_start.saturating_add(1);
380            if !(ft.is_text(i, hay) && ft.is_text(i, needle)) {
381                return Err(format!(
382                    "instruction {i}: `contains` on the full core lowers only for TEXT operands (r{hay} / \
383                     r{needle} are not both Text) — a list `contains` uses `Op::Contains` (op 63), not this builtin"
384                ));
385            }
386            Some(TEXTCONTAINS)
387        }
388        // `repeatSeq(elem, count)` — an `n`-slot fill of a SCALAR element. A float element would need the
389        // float heap plane per slot, and a Text/seq/map element a per-slot deep copy the flat fill can't do,
390        // so gate to a provably-scalar element (not float / not text / not float-seq / not map). The count is
391        // an ordinary integer register (a runtime value the model's sequential fill unit reads).
392        BuiltinId::RepeatSeq => {
393            let elem = args_start;
394            if ft.is_float(i, elem)
395                || ft.is_text(i, elem)
396                || ft.is_float_seq(i, elem)
397                || ft.is_map(i, elem)
398            {
399                return Err(format!(
400                    "instruction {i}: `repeatSeq` on the full core lowers only for a SCALAR element (r{elem} \
401                     is a float / reference value) — a float or heap-reference element has no flat-fill chip form"
402                ));
403            }
404            Some(REPEATSEQ)
405        }
406        // `parseInt(text)` — a bounded decimal char-scan over the Text char-list at `regs[args_start]` → an
407        // i64 in `regs[dst]`. The operand MUST be a Text char-list (the char-scan reads it as `[len, cp…]`);
408        // the VM itself errors on a non-Text argument (`parseInt requires a Text argument`), and a non-Text
409        // register on the chip has no char-list to scan, so gate it to a provably-Text operand rather than
410        // mis-reading a numeric/pointer register as a string. The scan itself TRAPS on a non-numeric / empty /
411        // overflowing input inside the model (op 236), byte-identical to the VM's `str::parse::<i64>()` `Err`.
412        BuiltinId::ParseInt => {
413            let text = args_start;
414            if !ft.is_text(i, text) {
415                return Err(format!(
416                    "instruction {i}: `parseInt` on the full core lowers only for a TEXT operand (r{text} is \
417                     not a Text char-list) — the decimal char-scan reads its argument as a string; a non-Text \
418                     `parseInt` argument (which the VM also rejects) has no chip lowering"
419                ));
420            }
421            Some(PARSEINT)
422        }
423        // `text_bytes(text)` — the UTF-8 bytes of the Text char-list at `regs[args_start]` as a fresh Int Seq.
424        // The scan reads its operand as `[len, cp…]`; the VM rejects a non-Text argument (`text_bytes()
425        // requires text`), and a non-Text register has no char-list to encode, so gate it to a provably-Text
426        // operand. The result is a plain integer-element Seq, so it sets no plane.
427        BuiltinId::TextBytes => {
428            let text = args_start;
429            if !ft.is_text(i, text) {
430                return Err(format!(
431                    "instruction {i}: `text_bytes` on the full core lowers only for a TEXT operand (r{text} is \
432                     not a Text char-list) — the UTF-8 encoder reads its argument as a string; a non-Text \
433                     `text_bytes` argument (which the VM also rejects) has no chip lowering"
434                ));
435            }
436            Some(TEXTBYTES)
437        }
438        _ => None,
439    })
440}
441
442/// True when `v` fits the sign-extended 16-bit inline `LoadConst` immediate (1 word); otherwise the
443/// encoder emits the 2-word wide `LoadConst` (opcode 200) whose second word is the full 32-bit value.
444fn narrow(v: i32) -> bool {
445    (-32768..=32767).contains(&v)
446}
447
448/// The FULL integer value of a constant (no truncation): an `Int` as its `i64`, a `Bool` as 0/1. This is
449/// what a 64-bit datapath must hold — the PRNG moduli `2147483648` (2³¹) / `4294967296` (2³²) exceed i32, so
450/// truncating them to `i32` is the 32-bit divergence source. The 32-bit encoder path still applies `as i32`
451/// at the emission site (the honest 32-bit limitation); this returns the value in full so a 64-bit datapath
452/// can hold it. `Err` on a Float (needs the FPU) or a Text/other constant with no integer representation.
453fn const_i64(c: &Constant) -> Result<i64, String> {
454    match c {
455        Constant::Int(v) => Ok(*v),
456        Constant::Bool(b) => Ok(i64::from(*b)),
457        // `Nothing` is the compiler's default-fill placeholder for an as-yet-unbumped counter field (a
458        // `Tally`/`Counter` struct field starts `Nothing`, then `CrdtBump` accumulates onto it). The VM's
459        // `crdt_counter_bump(Nothing, k)` returns `Int(k)` — i.e. Nothing behaves as the additive identity 0
460        // — so the chip loads it as 0 and the first bump computes `0 + k == k`, byte-identical to the VM.
461        Constant::Nothing => Ok(0),
462        // A `Duration` is the nanosecond count carried by a `Sleep`/`After ticks` timeout arm. The chip's
463        // deterministic scheduler ignores the actual wall-clock quantity (a `Sleep` is a no-op, a `SelectWait`
464        // timeout arm only fires when no recv is ready — see the model's ops 88/103/104), so the value rides
465        // the integer datapath as a plain nanosecond count with no effect on output; loading it losslessly
466        // keeps the constant pool honest (the over-i32 nanos count uses the 3-word op-222 form like any i64).
467        Constant::Duration(nanos) => Ok(*nanos),
468        Constant::Float(_) => {
469            Err("float constant needs the FPU (P3) — this is a float program".into())
470        }
471        other => Err(format!(
472            "constant {other:?} is not representable on the integer core"
473        )),
474    }
475}
476
477/// True when `v` fits a signed 32-bit field — the wide `LoadConst` (opcode 200) can hold it losslessly, so
478/// both the 32-bit and 64-bit datapaths encode it the SAME 1-word/2-word way (width-32 output stays byte-
479/// identical). A value OUTSIDE this range needs the 3-word 64-bit constant (opcode 222) on a 64-bit core;
480/// on a 32-bit core it is the honest limitation (the historical i32 truncation stands).
481fn fits_i32(v: i64) -> bool {
482    v >= i64::from(i32::MIN) && v <= i64::from(i32::MAX)
483}
484
485/// The fixed decimal precision of a float `FormatValue` — the `N` of a `.N` format spec. The VM's
486/// `apply_format_spec` renders a `Float` with a `.N` spec as `format!("{:.N$}", f)`; the soft-core's
487/// `FormatValueFloat` (op 225) does the SAME, carrying `N` in the word's b-byte, so the chip's Text is
488/// byte-identical to the VM's `Show` line. `spec == u32::MAX` (a bare `{f}`, no spec) or any spec that is
489/// not a plain `.N` (currency `$`, alignment `>N`) has no float chip lowering yet — `None`, so a float
490/// `FormatValue` outside the `.N` family falls through to the loud rejection rather than mis-rendering.
491fn float_format_precision(spec: u32, debug_prefix: u32, constants: &[Constant]) -> Option<u8> {
492    if spec == u32::MAX || debug_prefix != u32::MAX {
493        return None; // a bare `{f}` (full-precision display) / a `{f=}` debug prefix isn't the `.N` path
494    }
495    let s = match constants.get(spec as usize) {
496        Some(Constant::Text(s)) => s.as_str(),
497        _ => return None,
498    };
499    let n: usize = s.strip_prefix('.')?.parse().ok()?;
500    u8::try_from(n).ok() // the b-byte holds 0..=255; the benchmarks use 9 / 15
501}
502
503// ── THE TYPE ORACLE: static Int-vs-Float register typing (P3) ─────────────────────────────────────
504// The VM is dynamically typed, so nothing in the bytecode says whether a given `Add`/`Mul`/`Div`/`Show`
505// operates on integers or f64 — the runtime decides from the value tags. Silicon can't: each op needs a
506// fixed datapath. This forward FIXPOINT dataflow recovers, per code index, which registers PROVABLY hold
507// an f64 (they live in the chip's parallel `fregs` bank). The seeds are the ground truth the language
508// already carries: a `Constant::Float` literal, a `Float`/`Seq of Float` function parameter or return, a
509// float global, and the `sqrt` builtin. Float then PROPAGATES: an arithmetic op is float iff either
510// operand is float; a `Move` copies floatness; an `item i of (Seq of Float)` reads a float. The loop
511// back-edges are handled by the worklist (a register is float on entry iff it is float on ALL reaching
512// paths — the join is AND, so a register used as both int and float on different paths stays int and the
513// op falls to the integer datapath, which is the conservative, always-sound direction).
514
515/// True when a `BoundaryType` is (or unwraps to) a scalar `Float`. A `Seq of Float` element read yields a
516/// float scalar, so its ELEMENT kind matters at `Index`; the seq HANDLE itself is an integer pointer.
517fn boundary_is_float_scalar(t: &crate::vm::BoundaryType) -> bool {
518    matches!(t, crate::vm::BoundaryType::Float)
519}
520
521/// True when a `BoundaryType` is a `Seq of Float` — a heap handle whose element PAYLOADS ride the float
522/// plane. The handle register itself is an integer pointer (into the int heap), but `item i of` it reads a
523/// float and `Push`/`Set item` on it writes the float plane, so it needs its own lattice bit.
524fn boundary_is_float_seq(t: &crate::vm::BoundaryType) -> bool {
525    matches!(t, crate::vm::BoundaryType::Seq(inner) if boundary_is_float_scalar(inner))
526}
527
528/// True when a `BoundaryType` is `Text`. A Text handle is an int pointer to a char-code list; the encoder
529/// carries a `text` lattice bit so a Text `Add`/`AddAssign`/`Show`/param/return lowers to the string ops.
530fn boundary_is_text(t: &crate::vm::BoundaryType) -> bool {
531    matches!(t, crate::vm::BoundaryType::Text)
532}
533
534/// True when a `BoundaryType` is a `Map` — an int-pointer handle to a key-scan heap block. The `map` plane
535/// carries this so an `Index`/`SetIndex` on a map lowers to the KEY-SCAN chip ops (int- or string-key by the
536/// key register's type) instead of the positional list ops.
537fn boundary_is_map(t: &crate::vm::BoundaryType) -> bool {
538    matches!(t, crate::vm::BoundaryType::Map(_, _))
539}
540
541/// True when a `BoundaryType` is a scalar `Word32` — a native `i32` register whose VALUE is its own bit
542/// pattern, but whose `Show` renders UNSIGNED (0..2³²−1). The `word` plane carries this so a Word32
543/// param/return/`Show` lowers to `ShowWord32`.
544fn boundary_is_word32_scalar(t: &crate::vm::BoundaryType) -> bool {
545    matches!(t, crate::vm::BoundaryType::Word32)
546}
547
548/// True when a `BoundaryType` is a `Seq of Word32` — an int-pointer handle whose ELEMENTS are Word32 (the
549/// SHA-256 message schedule / hash state array). The handle rides the int heap; an `item i of` it reads a
550/// Word32 scalar, so it seeds the `wseq` plane (mirroring `boundary_is_float_seq`).
551fn boundary_is_word32_seq(t: &crate::vm::BoundaryType) -> bool {
552    matches!(t, crate::vm::BoundaryType::Seq(inner) if boundary_is_word32_scalar(inner))
553}
554
555/// One register's static float typing at a program point: a `scalar` f64 (rides `fregs`) OR a `seq` handle
556/// to a `Seq of Float` (an int pointer whose element payloads ride the float heap plane) OR a `text` handle
557/// (an int pointer to a char-code list) OR a `word` scalar (a `Word32`, ℤ/2³²) OR a `wseq` handle to a
558/// `Seq of Word32`. The bits are disjoint in practice (a register is one of them), but carried independently
559/// so the per-plane join is correct. `Bottom` is all-false — an integer / pointer / unreached register.
560///
561/// A `Word32` is the 32-bit register's own bit pattern, so its VALUE is indistinguishable from an `i32` on
562/// the chip; the plane exists ONLY to render a Word32 `Show` UNSIGNED (SHA-256's hash words exceed 2³¹) — a
563/// Word32-typed `Show` lowers to `ShowWord32`. The `word`/`wseq` split mirrors the float `scalar`/`seq`
564/// split: the SEQ handle is an int pointer, but an `item i of` it reads a Word32 scalar.
565#[derive(Clone, Copy, PartialEq, Eq)]
566struct FloatState {
567    scalar: [bool; 256], // register holds an f64 scalar
568    seq: [bool; 256],    // register holds a `Seq of Float` handle (its elements are floats)
569    text: [bool; 256],   // register holds a `Text` handle (a char-code list) — the string plane
570    word: [bool; 256],   // register holds a `Word32` scalar (ℤ/2³²) — the unsigned-Show plane
571    wseq: [bool; 256],   // register holds a `Seq of Word32` handle (its elements are Word32)
572    map: [bool; 256], // register holds a `Map` handle — the key-scan plane (int- vs string-key routing)
573}
574
575impl FloatState {
576    fn bottom() -> Self {
577        FloatState {
578            scalar: [false; 256],
579            seq: [false; 256],
580            text: [false; 256],
581            word: [false; 256],
582            wseq: [false; 256],
583            map: [false; 256],
584        }
585    }
586}
587
588/// Which registers provably hold a Bool at each code index — a MUST analysis (AND at joins, so a
589/// register is Bool only when it is Bool on every path reaching the op). Bools come from a Bool
590/// constant, a comparison, `not`, a Bool parameter, or a Bool-declared call return / global / struct
591/// field; a `Move` carries the fact; any other write to a register ends it.
592fn bool_typing(program: &CompiledProgram, structs: &StructTyping) -> Vec<[bool; 256]> {
593    use crate::vm::BoundaryType;
594    let n = program.code.len();
595    let mut entry: Vec<Option<[bool; 256]>> = vec![None; n];
596    let mut work: std::collections::VecDeque<usize> = Default::default();
597    let join = |entry: &mut Vec<Option<[bool; 256]>>,
598                work: &mut std::collections::VecDeque<usize>,
599                pc: usize,
600                st: [bool; 256]| {
601        if pc >= n {
602            return;
603        }
604        let next = match entry[pc] {
605            None => st,
606            Some(old) => std::array::from_fn(|r| old[r] && st[r]),
607        };
608        if entry[pc] != Some(next) {
609            entry[pc] = Some(next);
610            work.push_back(pc);
611        }
612    };
613    let seed = |params: &[Option<BoundaryType>]| -> [bool; 256] {
614        std::array::from_fn(|r| matches!(params.get(r), Some(Some(BoundaryType::Bool))))
615    };
616    join(&mut entry, &mut work, 0, [false; 256]);
617    for f in &program.functions {
618        join(&mut entry, &mut work, f.entry_pc, seed(&f.param_types));
619    }
620    while let Some(i) = work.pop_front() {
621        let mut out = entry[i].expect("a queued op has an entry state");
622        let op = &program.code[i];
623        match op {
624            Op::LoadConst { dst, idx } => {
625                out[*dst as usize & 255] = matches!(
626                    program.constants.get(*idx as usize),
627                    Some(Constant::Bool(_))
628                );
629            }
630            Op::Lt { dst, .. }
631            | Op::Gt { dst, .. }
632            | Op::LtEq { dst, .. }
633            | Op::GtEq { dst, .. }
634            | Op::Eq { dst, .. }
635            | Op::NotEq { dst, .. }
636            | Op::ApproxEq { dst, .. }
637            | Op::Not { dst, .. } => out[*dst as usize & 255] = true,
638            Op::Move { dst, src } | Op::DeepClone { dst, src } => {
639                out[*dst as usize & 255] = out[*src as usize & 255]
640            }
641            Op::RevSwap { a, b } => out.swap(*a as usize & 255, *b as usize & 255),
642            Op::Call { dst, func, .. } => {
643                out[*dst as usize & 255] = matches!(
644                    program
645                        .functions
646                        .get(*func as usize)
647                        .and_then(|f| f.return_type.as_ref()),
648                    Some(BoundaryType::Bool)
649                );
650            }
651            Op::GlobalGet { dst, idx } => {
652                out[*dst as usize & 255] = matches!(
653                    program.global_types.get(*idx as usize),
654                    Some(Some(BoundaryType::Bool))
655                );
656            }
657            Op::GetField { dst, obj, field } => {
658                out[*dst as usize & 255] = matches!(
659                    declared_field_type(program, structs, i, *obj, *field),
660                    Some(BoundaryType::Bool)
661                );
662            }
663            other if rebinds_same_kind(other) => {}
664            other => {
665                for d in written_regs(other) {
666                    out[d as usize & 255] = false;
667                }
668            }
669        }
670        for s in successors(i, op, n, |ci| ci) {
671            join(&mut entry, &mut work, s, out);
672        }
673    }
674    entry
675        .into_iter()
676        .map(|e| e.unwrap_or([false; 256]))
677        .collect()
678}
679
680/// The whole-program float register typing: for each code index, which registers provably hold an f64
681/// scalar (`fregs`) and which hold a `Seq of Float` handle (the float heap plane). Computed once; both
682/// encoder passes consult it so their imem widths and emitted words agree. Mirrors the structure of
683/// [`isolation_targets`] (same seed/successor logic).
684struct FloatTyping {
685    entry: Vec<FloatState>,
686    /// Per code index, which registers provably hold a Bool ([`bool_typing`]). The chip carries a Bool
687    /// as 0/1; rendering it as `true`/`false` needs this static fact.
688    bools: Vec<[bool; 256]>,
689}
690
691impl FloatTyping {
692    fn analyze(program: &CompiledProgram, structs: &StructTyping) -> Self {
693        let n = program.code.len();
694        const R: usize = 256;
695        let mut entry: Vec<FloatState> = vec![FloatState::bottom(); n];
696        let mut seen: Vec<bool> = vec![false; n]; // reached at least once (Bottom = false everywhere is ambiguous)
697        let mut work: std::collections::VecDeque<usize> = Default::default();
698
699        // Seed a region entry (Main or a function body) with its parameters' float-ness. A float scalar
700        // param rides `fregs`; a `Seq of Float` param is an integer pointer whose ELEMENTS are float — it
701        // seeds the `seq` plane (its handle isn't a scalar float, but its `item i of` reads a float).
702        let seed = |entry: &mut Vec<FloatState>,
703                    seen: &mut Vec<bool>,
704                    work: &mut std::collections::VecDeque<usize>,
705                    pc: usize,
706                    param_types: &[Option<crate::vm::BoundaryType>]| {
707            if pc >= entry.len() {
708                return;
709            }
710            for (r, pt) in param_types.iter().enumerate().take(R) {
711                if let Some(t) = pt {
712                    entry[pc].scalar[r] = boundary_is_float_scalar(t);
713                    entry[pc].seq[r] = boundary_is_float_seq(t);
714                    entry[pc].text[r] = boundary_is_text(t);
715                    entry[pc].word[r] = boundary_is_word32_scalar(t);
716                    entry[pc].wseq[r] = boundary_is_word32_seq(t);
717                    entry[pc].map[r] = boundary_is_map(t);
718                }
719            }
720            seen[pc] = true;
721            work.push_back(pc);
722        };
723        // (Region entries are (re)seeded at the top of every fixpoint sweep below.)
724
725        // The transfer of op `i` given the entry state `st`: the (register, scalar-float, float-seq, text,
726        // word32, word32-seq, map) writes this op performs. Reads consult `st` on the matching plane. A write of
727        // all-false CLEARS every plane at `r` (the op reassigned that register to an int / pointer), which is
728        // essential for the AND-join to converge — a register rebound to an int on one path stops being float
729        // / word32. The heap-handle planes (seq / text / wseq / map) join with OR: a handle typing is established
730        // once (a constant, a producing op, or a boundary) and, as a heap-object property, isn't lost on a
731        // re-derive.
732        let transfer = |i: usize,
733                        st: &FloatState|
734         -> Vec<(usize, bool, bool, bool, bool, bool, bool)> {
735            use Op::*;
736            let sf = |r: u16| st.scalar[r as usize & 255]; // scalar-float?
737            let qf = |r: u16| st.seq[r as usize & 255]; // float-seq?
738            let tx = |r: u16| st.text[r as usize & 255]; // text?
739            let wd = |r: u16| st.word[r as usize & 255]; // word32 scalar?
740            let wq = |r: u16| st.wseq[r as usize & 255]; // word32-seq?
741            let mp = |r: u16| st.map[r as usize & 255]; // map handle?
742            match &program.code[i] {
743                LoadConst { dst, idx } => {
744                    let c = program.constants.get(*idx as usize);
745                    let is_f = matches!(c, Some(Constant::Float(_)));
746                    let is_t = matches!(c, Some(Constant::Text(_)));
747                    vec![(*dst as usize & 255, is_f, false, is_t, false, false, false)]
748                }
749                // Arithmetic: float iff either operand is float; TEXT iff either operand is Text (a Text `Add`
750                // is string concat — the non-Text operand is FormatValue'd, so `+` on a mixed Text/int is a
751                // Text result); WORD32 iff either operand is Word32 (a Word32 `+`/`-`/`*` stays Word32 —
752                // SHA-256's `s1 + s0 + …` accumulator, so the final Show renders unsigned). A Word32 operand
753                // is never simultaneously float / text, so the planes stay disjoint.
754                Add { dst, lhs, rhs } => {
755                    let f = sf(*lhs) || sf(*rhs);
756                    let t = tx(*lhs) || tx(*rhs);
757                    let w = (wd(*lhs) || wd(*rhs)) && !f && !t;
758                    vec![(*dst as usize & 255, f, false, t, w, false, false)]
759                }
760                Sub { dst, lhs, rhs } | Mul { dst, lhs, rhs } => {
761                    let f = sf(*lhs) || sf(*rhs);
762                    let w = (wd(*lhs) || wd(*rhs)) && !f;
763                    vec![(*dst as usize & 255, f, false, false, w, false, false)]
764                }
765                Div { dst, lhs, rhs } | ExactDiv { dst, lhs, rhs } | FloorDiv { dst, lhs, rhs } => {
766                    vec![(
767                        *dst as usize & 255,
768                        sf(*lhs) || sf(*rhs),
769                        false,
770                        false,
771                        false,
772                        false,
773                        false,
774                    )]
775                }
776                // Bitwise `xor`/`and`/`or`/`not` — the Word32 crypto operators (`rotr(x,7) xor …`) that lower
777                // straight to the ALU. The result stays Word32 iff an operand is Word32, so the diffusion
778                // chain's final value Shows unsigned.
779                BitXor { dst, lhs, rhs }
780                | BitAnd { dst, lhs, rhs }
781                | BitOr { dst, lhs, rhs }
782                | Shl { dst, lhs, rhs }
783                | Shr { dst, lhs, rhs } => {
784                    vec![(
785                        *dst as usize & 255,
786                        false,
787                        false,
788                        false,
789                        wd(*lhs) || wd(*rhs),
790                        false,
791                        false,
792                    )]
793                }
794                Not { dst, src } => vec![(
795                    *dst as usize & 255,
796                    false,
797                    false,
798                    false,
799                    wd(*src),
800                    false,
801                    false,
802                )],
803                AddAssign { dst, src } => {
804                    let f = sf(*dst) || sf(*src);
805                    let t = tx(*dst) || tx(*src);
806                    let w = (wd(*dst) || wd(*src)) && !f && !t;
807                    vec![(*dst as usize & 255, f, false, t, w, false, false)]
808                }
809                // Move copies ALL planes — a `Let ys be xs` on a float / Word32 / text / map handle keeps its typing;
810                // a `DeepClone` is the same value in a fresh heap block.
811                Move { dst, src } | DeepClone { dst, src } => {
812                    vec![(
813                        *dst as usize & 255,
814                        sf(*src),
815                        qf(*src),
816                        tx(*src),
817                        wd(*src),
818                        wq(*src),
819                        mp(*src),
820                    )]
821                }
822                // `RevSwap` exchanges two registers — and with them, every plane.
823                RevSwap { a, b } => {
824                    let plane = |r: u16| (sf(r), qf(r), tx(r), wd(r), wq(r), mp(r));
825                    let ((a0, a1, a2, a3, a4, a5), (b0, b1, b2, b3, b4, b5)) =
826                        (plane(*a), plane(*b));
827                    vec![
828                        (*a as usize & 255, b0, b1, b2, b3, b4, b5),
829                        (*b as usize & 255, a0, a1, a2, a3, a4, a5),
830                    ]
831                }
832                // A slice is the same kind of sequence as the one it cuts: a Text slice is Text, a `Seq of Float`
833                // slice a float seq.
834                SliceOp {
835                    dst, collection, ..
836                } => {
837                    vec![(
838                        *dst as usize & 255,
839                        false,
840                        qf(*collection),
841                        tx(*collection),
842                        false,
843                        wq(*collection),
844                        false,
845                    )]
846                }
847                // `Concat` joins two renderings into a Text; `SeqConcat` keeps the element typing of either side.
848                Concat { dst, .. } => {
849                    vec![(*dst as usize & 255, false, false, true, false, false, false)]
850                }
851                SeqConcat { dst, lhs, rhs } => {
852                    vec![(
853                        *dst as usize & 255,
854                        false,
855                        qf(*lhs) || qf(*rhs),
856                        false,
857                        false,
858                        wq(*lhs) || wq(*rhs),
859                        false,
860                    )]
861                }
862                // `p's f`: the field's DECLARED type — a Text field is a Text handle, a Real field a float. A field
863                // whose object type or declaration the oracles cannot name is an integer (all planes clear).
864                GetField { dst, obj, field } => {
865                    match declared_field_type(program, structs, i, *obj, *field) {
866                        Some(t) => vec![(
867                            *dst as usize & 255,
868                            boundary_is_float_scalar(t),
869                            boundary_is_float_seq(t),
870                            boundary_is_text(t),
871                            boundary_is_word32_scalar(t),
872                            boundary_is_word32_seq(t),
873                            boundary_is_map(t),
874                        )],
875                        None => vec![(
876                            *dst as usize & 255,
877                            false,
878                            false,
879                            false,
880                            false,
881                            false,
882                            false,
883                        )],
884                    }
885                }
886                // Comparisons write an INT bool (never float / text / word / map), regardless of operand types.
887                Lt { dst, .. }
888                | Gt { dst, .. }
889                | LtEq { dst, .. }
890                | GtEq { dst, .. }
891                | Eq { dst, .. }
892                | NotEq { dst, .. }
893                | ApproxEq { dst, .. } => {
894                    vec![(
895                        *dst as usize & 255,
896                        false,
897                        false,
898                        false,
899                        false,
900                        false,
901                        false,
902                    )]
903                }
904                // `item i of xs`: a float SCALAR result iff xs is a float seq, a WORD32 scalar iff xs is a
905                // Word32 seq (`item i of (Seq of Word32)`). `item i of text` is a single-CHARACTER Text. A map
906                // read (`item k of m`) yields the VALUE — not a map handle — so the map plane is never set here.
907                Index {
908                    dst, collection, ..
909                }
910                | IndexUnchecked {
911                    dst, collection, ..
912                } => {
913                    vec![(
914                        *dst as usize & 255,
915                        qf(*collection),
916                        false,
917                        tx(*collection),
918                        wq(*collection),
919                        false,
920                        false,
921                    )]
922                }
923                // `Repeat for v in xs`: the loop variable `v` is a float SCALAR iff the governing `IterPrepare`
924                // (the compiler emits it at `i-1`, before the loop-head `IterNext`) names a `Seq of Float`
925                // iterable — its element rides `fheap`, so the body's `s + v` lowers to the FPU. The iterable's
926                // float-seq bit (established at the `IterPrepare`, a read) flows unchanged into this op's entry
927                // state `st`, so `qf(iterable)` here IS its float-seq-ness. Else `v` is an integer element.
928                IterNext { dst, .. } => {
929                    let f = i > 0
930                        && matches!(&program.code[i - 1], Op::IterPrepare { .. })
931                        && match &program.code[i - 1] {
932                            Op::IterPrepare { iterable } => qf(*iterable),
933                            _ => false,
934                        };
935                    vec![(*dst as usize & 255, f, false, false, false, false, false)]
936                }
937                // A float / Word32 `Push`/`Add to` MARKS its list register as a float-seq / word-seq (the
938                // value's plane decides; the list handle stays an int pointer, its seq bit records the element
939                // typing). SetAdd (Set membership) rides the same detector.
940                ListPush { list, value } => {
941                    if sf(*value) {
942                        vec![(
943                            *list as usize & 255,
944                            false,
945                            true,
946                            false,
947                            false,
948                            false,
949                            false,
950                        )]
951                    } else if wd(*value) {
952                        vec![(
953                            *list as usize & 255,
954                            false,
955                            false,
956                            false,
957                            false,
958                            true,
959                            false,
960                        )]
961                    } else {
962                        vec![]
963                    }
964                }
965                SetAdd { set, value } => {
966                    if sf(*value) {
967                        vec![(*set as usize & 255, false, true, false, false, false, false)]
968                    } else if wd(*value) {
969                        vec![(*set as usize & 255, false, false, false, false, true, false)]
970                    } else {
971                        vec![]
972                    }
973                }
974                // A float / Word32 `Set item i of xs to v` likewise CONFIRMS xs's element typing. It does NOT
975                // touch the collection's map plane (a map handle stays a map after a `Set item k of m`).
976                SetIndex {
977                    collection, value, ..
978                }
979                | SetIndexUnchecked {
980                    collection, value, ..
981                } => {
982                    if sf(*value) {
983                        vec![(
984                            *collection as usize & 255,
985                            false,
986                            true,
987                            false,
988                            false,
989                            false,
990                            false,
991                        )]
992                    } else if wd(*value) {
993                        vec![(
994                            *collection as usize & 255,
995                            false,
996                            false,
997                            false,
998                            false,
999                            true,
1000                            false,
1001                        )]
1002                    } else {
1003                        vec![]
1004                    }
1005                }
1006                // A `[a, b, …]` list literal is a float / Word32 seq iff its first element is that scalar kind.
1007                NewList { dst, start, count } if *count > 0 => {
1008                    vec![(
1009                        *dst as usize & 255,
1010                        false,
1011                        sf(*start),
1012                        false,
1013                        false,
1014                        wd(*start),
1015                        false,
1016                    )]
1017                }
1018                // `a new Map of …` — the map handle plane's producer (seeds the key-scan routing).
1019                NewEmptyMap { dst } | NewEmptyMapWithCapacity { dst, .. } => {
1020                    vec![(*dst as usize & 255, false, false, false, false, false, true)]
1021                }
1022                // Call: the result is a float scalar / float seq / Text / Word32 / Word32 seq per the callee's
1023                // declared return type (the `ssig0(x: Word32) -> Word32` shape).
1024                Call { dst, func, .. } => {
1025                    let rt = program
1026                        .functions
1027                        .get(*func as usize)
1028                        .and_then(|f| f.return_type.as_ref());
1029                    let is_scalar = rt.map(boundary_is_float_scalar).unwrap_or(false);
1030                    let is_seq = rt.map(boundary_is_float_seq).unwrap_or(false);
1031                    let is_text = rt.map(boundary_is_text).unwrap_or(false);
1032                    let is_word = rt.map(boundary_is_word32_scalar).unwrap_or(false);
1033                    let is_wseq = rt.map(boundary_is_word32_seq).unwrap_or(false);
1034                    let is_map = rt.map(boundary_is_map).unwrap_or(false);
1035                    vec![(
1036                        *dst as usize & 255,
1037                        is_scalar,
1038                        is_seq,
1039                        is_text,
1040                        is_word,
1041                        is_wseq,
1042                        is_map,
1043                    )]
1044                }
1045                CallBuiltin {
1046                    dst,
1047                    builtin,
1048                    args_start,
1049                    ..
1050                } => {
1051                    use crate::semantics::builtins::BuiltinId::*;
1052                    let is_f = match builtin {
1053                        Sqrt => true, // sqrt of a number is always a float (the FPU result)
1054                        // `floor`/`ceil`/`round` of a FLOAT return an INTEGER (`f.floor() as i64`), so their
1055                        // dst leaves the float plane — a `Show floor(x)` must render an int, not a float.
1056                        Floor | Ceil | Round => false,
1057                        Abs => sf(*args_start), // |x| preserves the operand's kind (float in → float out)
1058                        // `min`/`max` promote to float if EITHER operand is float (`(x as f64).min(y)`), so the
1059                        // result is float when either arg is — matching `float_shape`'s either-operand routing.
1060                        Min | Max => sf(*args_start) || sf(args_start.saturating_add(1)),
1061                        _ => false,
1062                    };
1063                    // The Word32 vocabulary: `word32(n)`/`intOfWord32(w)` and the rotate/shift/bitwise word
1064                    // ops all yield a Word32 (the register's own 32-bit pattern), so their result Shows
1065                    // UNSIGNED. `intOfWord32` is an unsigned Int in the VM, but on a 32-bit register its value
1066                    // is the Word32 bit pattern — indistinguishable except at Show, so it rides the word plane.
1067                    let is_w = matches!(
1068                        builtin,
1069                        Word32 | IntOfWord32 | Rotl | Rotr | Word32Shr | Wand | Wor | Wnot
1070                    );
1071                    // `chr(code)` yields a ONE-character TEXT (a char-code list), so its dst is a Text handle —
1072                    // a downstream `Show`/`item i of`/`contains` on it must route to the string plane. `contains`
1073                    // yields an int 0/1, and `repeatSeq` and `text_bytes` a plain integer-element Seq (an int
1074                    // pointer, positionally indexed), so none sets a plane — the all-false default is correct.
1075                    // `format(x)` renders its argument to Text (a char-code list), like `FormatValue`.
1076                    let is_t = matches!(builtin, Chr | Format);
1077                    vec![(*dst as usize & 255, is_f, false, is_t, is_w, false, false)]
1078                }
1079                GlobalGet { dst, idx } => {
1080                    let gt = program
1081                        .global_types
1082                        .get(*idx as usize)
1083                        .and_then(|t| t.as_ref());
1084                    let is_scalar = gt.map(boundary_is_float_scalar).unwrap_or(false);
1085                    let is_seq = gt.map(boundary_is_float_seq).unwrap_or(false);
1086                    let is_text = gt.map(boundary_is_text).unwrap_or(false);
1087                    let is_word = gt.map(boundary_is_word32_scalar).unwrap_or(false);
1088                    let is_wseq = gt.map(boundary_is_word32_seq).unwrap_or(false);
1089                    let is_map = gt.map(boundary_is_map).unwrap_or(false);
1090                    vec![(
1091                        *dst as usize & 255,
1092                        is_scalar,
1093                        is_seq,
1094                        is_text,
1095                        is_word,
1096                        is_wseq,
1097                        is_map,
1098                    )]
1099                }
1100                // `FormatValue` renders a value to a Text char-list (integer op 64 or float op 225), so its dst
1101                // is a TEXT handle — the downstream `Concat` (`"" ++ formatted`) must see a Text-Text concat, not
1102                // re-FormatValue a register the chip already stringified. Marks only the text plane.
1103                FormatValue { dst, .. } => {
1104                    vec![(*dst as usize & 255, false, false, true, false, false, false)]
1105                }
1106                op if rebinds_same_kind(op) => vec![],
1107                // Every other register an op writes holds an integer / pointer: all planes clear.
1108                op => written_regs(op)
1109                    .into_iter()
1110                    .map(|d| (d as usize & 255, false, false, false, false, false, false))
1111                    .collect(),
1112            }
1113        };
1114
1115        // The two planes have OPPOSITE lattice directions — SCALAR joins with AND (a register is a float
1116        // scalar only if float on EVERY reaching path; the conservative direction that lets a genuine
1117        // int/float register reuse fall to the integer datapath), SEQ joins with OR (a `Seq of Float` handle's
1118        // element typing is a property of the heap object, established once a float is pushed and never lost).
1119        // A single worklist sweep can't reconcile them: when a seq is first typed by a push INSIDE a loop, the
1120        // seq bit rises late, but a scalar bit DERIVED from it (an `item i of seq` read) may already have been
1121        // AND-latched at `false` on an earlier visit (false AND true = false, never rises again) — a loop-built
1122        // float seq then reads back all-zero. The fix is to iterate the whole sweep to a fixpoint: the seq
1123        // plane converges first (OR is monotone), and each subsequent sweep re-derives the scalar bits against
1124        // the now-stable seq, so nothing stays latched. Convergence is monotone, so a handful of sweeps suffice;
1125        // the bound is a hard backstop.
1126        for _sweep in 0..16 {
1127            let before = entry.clone();
1128            for x in seen.iter_mut() {
1129                *x = false;
1130            }
1131            work.clear();
1132            seed(&mut entry, &mut seen, &mut work, 0, &[]); // Main: no params
1133            for f in &program.functions {
1134                seed(&mut entry, &mut seen, &mut work, f.entry_pc, &f.param_types);
1135            }
1136            while let Some(i) = work.pop_front() {
1137                let mut out = entry[i];
1138                for (r, sc, sq, tx, wd, wq, mp) in transfer(i, &entry[i]) {
1139                    out.scalar[r] = sc;
1140                    out.seq[r] = sq;
1141                    out.text[r] = tx;
1142                    out.word[r] = wd;
1143                    out.wseq[r] = wq;
1144                    out.map[r] = mp;
1145                }
1146                let succs = successors(i, &program.code[i], n, |ci| ci);
1147                for s in succs {
1148                    if s >= n {
1149                        continue;
1150                    }
1151                    let mut changed = false;
1152                    if !seen[s] {
1153                        seen[s] = true;
1154                        // SCALAR planes (float `scalar`, `word`): (re)initialize from this predecessor — the
1155                        // must-analysis start, met with any later predecessors below. On a re-sweep this
1156                        // DISCARDS the previous sweep's scalar so a bit that was latched low can be re-derived
1157                        // correctly against the now-stable handle planes. HANDLE planes (seq / text / wseq):
1158                        // OR-preserve — NEVER drop a `Seq of Float`/`Text`/`Seq of Word32` typing carried over
1159                        // from a prior sweep (that carried-over, converged handle plane is what un-latches the
1160                        // scalars — e.g. a loop-built `Seq of Word32` whose element read must stay word-typed).
1161                        entry[s].scalar = out.scalar;
1162                        entry[s].word = out.word;
1163                        for r in 0..R {
1164                            entry[s].seq[r] |= out.seq[r];
1165                            entry[s].text[r] |= out.text[r];
1166                            entry[s].wseq[r] |= out.wseq[r];
1167                            entry[s].map[r] |= out.map[r];
1168                        }
1169                        changed = true;
1170                    } else {
1171                        for r in 0..R {
1172                            let m = entry[s].scalar[r] && out.scalar[r]; // SCALAR = AND
1173                            if m != entry[s].scalar[r] {
1174                                entry[s].scalar[r] = m;
1175                                changed = true;
1176                            }
1177                            let mw = entry[s].word[r] && out.word[r]; // WORD32 SCALAR = AND
1178                            if mw != entry[s].word[r] {
1179                                entry[s].word[r] = mw;
1180                                changed = true;
1181                            }
1182                            let mq = entry[s].seq[r] || out.seq[r]; // SEQ = OR
1183                            if mq != entry[s].seq[r] {
1184                                entry[s].seq[r] = mq;
1185                                changed = true;
1186                            }
1187                            let mt = entry[s].text[r] || out.text[r]; // TEXT = OR
1188                            if mt != entry[s].text[r] {
1189                                entry[s].text[r] = mt;
1190                                changed = true;
1191                            }
1192                            let mwq = entry[s].wseq[r] || out.wseq[r]; // WORD32 SEQ = OR
1193                            if mwq != entry[s].wseq[r] {
1194                                entry[s].wseq[r] = mwq;
1195                                changed = true;
1196                            }
1197                            let mm = entry[s].map[r] || out.map[r]; // MAP = OR
1198                            if mm != entry[s].map[r] {
1199                                entry[s].map[r] = mm;
1200                                changed = true;
1201                            }
1202                        }
1203                    }
1204                    if changed {
1205                        work.push_back(s);
1206                    }
1207                }
1208            }
1209            if entry == before {
1210                break;
1211            }
1212        }
1213        FloatTyping {
1214            entry,
1215            bools: bool_typing(program, structs),
1216        }
1217    }
1218
1219    /// Is register `r` a float SCALAR on entry to code index `i`?
1220    fn is_float(&self, i: usize, r: u16) -> bool {
1221        self.entry
1222            .get(i)
1223            .map(|st| st.scalar[r as usize & 255])
1224            .unwrap_or(false)
1225    }
1226
1227    /// Is register `r` a `Seq of Float` handle on entry to code index `i`?
1228    fn is_float_seq(&self, i: usize, r: u16) -> bool {
1229        self.entry
1230            .get(i)
1231            .map(|st| st.seq[r as usize & 255])
1232            .unwrap_or(false)
1233    }
1234
1235    /// Is register `r` a `Text` (char-code list) handle on entry to code index `i`?
1236    fn is_bool(&self, i: usize, r: u16) -> bool {
1237        self.bools
1238            .get(i)
1239            .map(|b| b[r as usize & 255])
1240            .unwrap_or(false)
1241    }
1242
1243    fn is_text(&self, i: usize, r: u16) -> bool {
1244        self.entry
1245            .get(i)
1246            .map(|st| st.text[r as usize & 255])
1247            .unwrap_or(false)
1248    }
1249
1250    /// Is register `r` a `Word32` SCALAR on entry to code index `i`? (Its `Show` renders UNSIGNED.) The
1251    /// `Seq of Word32` handle plane (`wseq`) has no separate accessor: an `item i of (Seq of Word32)` rides
1252    /// the ordinary integer heap datapath (the value is the 32-bit pattern), so only the RESULT's word
1253    /// typing matters — the transfer's `Index` arm derives it from the collection's `wseq` bit directly.
1254    fn is_word32(&self, i: usize, r: u16) -> bool {
1255        self.entry
1256            .get(i)
1257            .map(|st| st.word[r as usize & 255])
1258            .unwrap_or(false)
1259    }
1260
1261    /// Is register `r` a `Map` handle on entry to code index `i`? An `Index`/`SetIndex` on a map lowers to the
1262    /// KEY-SCAN chip ops (int- or string-key by the key register's own type) rather than the positional list
1263    /// ops; a register that is not provably a map keeps the positional (list) lowering.
1264    fn is_map(&self, i: usize, r: u16) -> bool {
1265        self.entry
1266            .get(i)
1267            .map(|st| st.map[r as usize & 255])
1268            .unwrap_or(false)
1269    }
1270}
1271
1272/// True when the `IterNext` at code index `i` iterates a `Seq of Float`: the loop's governing `IterPrepare`
1273/// (the compiler emits it at `i - 1`, immediately before the loop-head `IterNext`) names a float-seq iterable.
1274/// A float iteration reads each element's f64 PAYLOAD off `fheap` (op 231 `FIterNext`) into `fregs[dst]`, so
1275/// the loop variable is a float scalar. A `None`/non-float-seq iterable is the ordinary integer `IterNext`.
1276fn is_float_iternext(i: usize, program: &CompiledProgram, ft: &FloatTyping) -> bool {
1277    if i == 0 {
1278        return false;
1279    }
1280    match &program.code[i - 1] {
1281        Op::IterPrepare { iterable } => ft.is_float_seq(i - 1, *iterable),
1282        _ => false,
1283    }
1284}
1285
1286/// The single dst register an op writes (for the integer-default float transfer), or `None` for a control
1287/// op / a multi- or no-write op we don't need to type (its dst, if any, is left non-float).
1288/// The registers `op` writes — the ISA's exhaustive write set. Every register typing below re-derives a
1289/// written register from the writing op ALONE: a fact the register held before the write never survives it.
1290fn written_regs(op: &Op) -> Vec<Reg> {
1291    let mut out = Vec::new();
1292    op.for_each_written_reg(|r| out.push(r));
1293    out
1294}
1295
1296/// A write that rebinds its register to a value of the SAME kind, so every typing carries the register's
1297/// facts through it: `EnsureOwned` (a copy-on-write clone), `NetSync` (the merged replica written back), and
1298/// the reversible ring ops (`xor`/`+`/`-`/negation keep a Word a Word and an Int an Int).
1299fn rebinds_same_kind(op: &Op) -> bool {
1300    matches!(
1301        op,
1302        Op::EnsureOwned { .. }
1303            | Op::NetSync { .. }
1304            | Op::RevAdd { .. }
1305            | Op::RevSub { .. }
1306            | Op::RevXor { .. }
1307            | Op::RevNeg { .. }
1308    )
1309}
1310
1311/// The declared type of field `field` (a field-name const) of the struct in register `obj` at code index
1312/// `i`: the struct oracle names the object's type, the program's registry declares the field.
1313fn declared_field_type<'p>(
1314    program: &'p CompiledProgram,
1315    st: &StructTyping,
1316    i: usize,
1317    obj: Reg,
1318    field: u32,
1319) -> Option<&'p crate::vm::BoundaryType> {
1320    let type_name = st.type_names.get(st.struct_of(i, obj)? as usize)?;
1321    let Some(Constant::Text(fname)) = program.constants.get(field as usize) else {
1322        return None;
1323    };
1324    let def = program.struct_types.iter().find(|d| &d.name == type_name)?;
1325    def.fields.iter().find(|(n, _)| n == fname).map(|(_, t)| t)
1326}
1327
1328// ── THE STRUCT-TYPE ORACLE + USAGE-DERIVED FIELD LAYOUT ────────────────────────────────────────────
1329// A LOGOS struct (and a bare-int `Counter`/`Tally`, which the compiler lowers to a plain struct) is a heap
1330// record `[nfields, f0, …]` addressed POSITIONALLY on the chip (model ops 74/75/76/80/81/84). But the
1331// bytecode addresses a field by NAME (`StructInsert`/`GetField`/`CrdtBump`/`CrdtResolve` carry the
1332// field-name `ConstIdx`, never a slot index) — the dynamically-typed VM/tree-walker resolve names in a
1333// `HashMap`. To lower a field op the encoder must map (struct type, field name) → a slot position that is
1334// (a) CONSISTENT across every op on that struct type (so `StructInsert` writes the slot `GetField` reads),
1335// and (b) a compact `[0, nfields)` layout (so `NewStruct { num_fields }` allocates enough slots). The slot
1336// number is a pure chip-internal detail — the program's OUTPUT depends only on WHICH value lands in the
1337// field, not on its heap offset — so any consistent bijection works.
1338//
1339// The struct-type NAME of each object register comes from the ground truth the bytecode carries: a
1340// `NewStruct { type_name }` const, a `Struct(name)` parameter / global / return type. `StructTyping` is
1341// the forward dataflow that recovers it (propagating through `Move`). The layout is then DERIVED from
1342// actual usage — for each struct type, its field names in first-reference order, assigned positions 0.. —
1343// which is complete by construction (it never depends on the lossy/partial `struct_types` registry) and
1344// self-consistent (the same field name in the same type always resolves to the same slot).
1345struct StructTyping {
1346    entry: Vec<[Option<u16>; 256]>, // per code index, per register: Some(type-name index) or None
1347    /// Program-global struct-type names (index → name); `NewStruct` consts + `Struct(name)` boundary types.
1348    type_names: Vec<String>,
1349    /// Per struct type (by `type_names` index): field name → slot position, in first-reference order.
1350    field_slots: Vec<std::collections::HashMap<String, usize>>,
1351    /// Per struct type: field count (== `field_slots[t].len()`) — the `NewStruct { num_fields }` operand.
1352    field_counts: Vec<usize>,
1353}
1354
1355impl StructTyping {
1356    fn analyze(program: &CompiledProgram) -> Self {
1357        let n = program.code.len();
1358        const R: usize = 256;
1359        // Program-global struct-type name table: every name a `NewStruct` const or a `Struct(_)` boundary
1360        // type mentions. The index is the register-plane's stable type id.
1361        let mut type_names: Vec<String> = Vec::new();
1362        let mut name_index: std::collections::HashMap<String, u16> =
1363            std::collections::HashMap::new();
1364        let intern = |name: &str,
1365                      names: &mut Vec<String>,
1366                      idx: &mut std::collections::HashMap<String, u16>|
1367         -> Option<u16> {
1368            if let Some(&i) = idx.get(name) {
1369                return Some(i);
1370            }
1371            let i = u16::try_from(names.len()).ok()?;
1372            names.push(name.to_string());
1373            idx.insert(name.to_string(), i);
1374            Some(i)
1375        };
1376        for op in &program.code {
1377            if let Op::NewStruct { type_name, .. } = op {
1378                if let Some(Constant::Text(s)) = program.constants.get(*type_name as usize) {
1379                    intern(s, &mut type_names, &mut name_index);
1380                }
1381            }
1382        }
1383        let note_bt = |t: &crate::vm::BoundaryType,
1384                       names: &mut Vec<String>,
1385                       idx: &mut std::collections::HashMap<String, u16>| {
1386            if let crate::vm::BoundaryType::Struct(name) = t {
1387                intern(name, names, idx);
1388            }
1389        };
1390        for f in &program.functions {
1391            for pt in f.param_types.iter().flatten() {
1392                note_bt(pt, &mut type_names, &mut name_index);
1393            }
1394            if let Some(rt) = &f.return_type {
1395                note_bt(rt, &mut type_names, &mut name_index);
1396            }
1397        }
1398        for gt in program.global_types.iter().flatten() {
1399            note_bt(gt, &mut type_names, &mut name_index);
1400        }
1401        // A `BoundaryType::Struct(name)` → its type-name index (a struct-typed param/global/return).
1402        let bt_index = |t: &crate::vm::BoundaryType| -> Option<u16> {
1403            match t {
1404                crate::vm::BoundaryType::Struct(name) => name_index.get(name.as_str()).copied(),
1405                _ => None,
1406            }
1407        };
1408
1409        let mut entry: Vec<[Option<u16>; 256]> = vec![[None; R]; n];
1410        let mut seen: Vec<bool> = vec![false; n];
1411        let mut work: std::collections::VecDeque<usize> = Default::default();
1412        let seed = |entry: &mut Vec<[Option<u16>; R]>,
1413                    seen: &mut Vec<bool>,
1414                    work: &mut std::collections::VecDeque<usize>,
1415                    pc: usize,
1416                    param_types: &[Option<crate::vm::BoundaryType>]| {
1417            if pc >= entry.len() {
1418                return;
1419            }
1420            for (r, pt) in param_types.iter().enumerate().take(R) {
1421                if let Some(t) = pt {
1422                    entry[pc][r] = bt_index(t);
1423                }
1424            }
1425            seen[pc] = true;
1426            work.push_back(pc);
1427        };
1428
1429        // The transfer: which register this op re-types (Some(type index) or None = cleared to a non-struct).
1430        let transfer = |i: usize, st: &[Option<u16>; R]| -> Vec<(usize, Option<u16>)> {
1431            use Op::*;
1432            match &program.code[i] {
1433                // `a new T …` (a struct, or a bare-int Counter/Tally lowered to a struct): the dst holds T.
1434                NewStruct { dst, type_name } => {
1435                    let ty = match program.constants.get(*type_name as usize) {
1436                        Some(Constant::Text(s)) => name_index.get(s.as_str()).copied(),
1437                        _ => None,
1438                    };
1439                    vec![(*dst as usize & 255, ty)]
1440                }
1441                // `Let q be p` keeps p's struct typing.
1442                Move { dst, src } => vec![(*dst as usize & 255, st[*src as usize & 255])],
1443                // A struct-typed call return / global read seeds the dst from the declared type.
1444                Call { dst, func, .. } => {
1445                    let ty = program
1446                        .functions
1447                        .get(*func as usize)
1448                        .and_then(|f| f.return_type.as_ref())
1449                        .and_then(bt_index);
1450                    vec![(*dst as usize & 255, ty)]
1451                }
1452                GlobalGet { dst, idx } => {
1453                    let ty = program
1454                        .global_types
1455                        .get(*idx as usize)
1456                        .and_then(|t| t.as_ref())
1457                        .and_then(bt_index);
1458                    vec![(*dst as usize & 255, ty)]
1459                }
1460                DeepClone { dst, src } => vec![(*dst as usize & 255, st[*src as usize & 255])],
1461                RevSwap { a, b } => vec![
1462                    (*a as usize & 255, st[*b as usize & 255]),
1463                    (*b as usize & 255, st[*a as usize & 255]),
1464                ],
1465                // `p's inner` where `inner` is declared a struct: the dst holds that struct.
1466                GetField { dst, obj, field } => {
1467                    let ty = st[*obj as usize & 255]
1468                        .and_then(|t| type_names.get(t as usize))
1469                        .and_then(|tn| program.struct_types.iter().find(|d| &d.name == tn))
1470                        .zip(match program.constants.get(*field as usize) {
1471                            Some(Constant::Text(f)) => Some(f),
1472                            _ => None,
1473                        })
1474                        .and_then(|(d, f)| d.fields.iter().find(|(n, _)| n == f))
1475                        .and_then(|(_, t)| bt_index(t));
1476                    vec![(*dst as usize & 255, ty)]
1477                }
1478                // A struct is value-typed: `StructInsert`/`CrdtBump`/`CrdtMerge`/`CrdtResolve` mutate the SAME
1479                // object in place (no register write), and a same-kind rebind keeps its struct.
1480                op if rebinds_same_kind(op) => vec![],
1481                // Every other register an op writes holds a non-struct value.
1482                op => written_regs(op)
1483                    .into_iter()
1484                    .map(|d| (d as usize & 255, None))
1485                    .collect(),
1486            }
1487        };
1488
1489        for _sweep in 0..16 {
1490            let before = entry.clone();
1491            for x in seen.iter_mut() {
1492                *x = false;
1493            }
1494            work.clear();
1495            seed(&mut entry, &mut seen, &mut work, 0, &[]);
1496            for f in &program.functions {
1497                seed(&mut entry, &mut seen, &mut work, f.entry_pc, &f.param_types);
1498            }
1499            while let Some(i) = work.pop_front() {
1500                let mut out = entry[i];
1501                for (r, ty) in transfer(i, &entry[i]) {
1502                    out[r] = ty;
1503                }
1504                let succs = successors(i, &program.code[i], n, |ci| ci);
1505                for s in succs {
1506                    if s >= n {
1507                        continue;
1508                    }
1509                    let mut changed = false;
1510                    if !seen[s] {
1511                        seen[s] = true;
1512                        entry[s] = out;
1513                        changed = true;
1514                    } else {
1515                        for r in 0..R {
1516                            // MUST-agree join: keep the type only if both sides agree, else clear to None.
1517                            let m = if entry[s][r] == out[r] {
1518                                entry[s][r]
1519                            } else {
1520                                None
1521                            };
1522                            if m != entry[s][r] {
1523                                entry[s][r] = m;
1524                                changed = true;
1525                            }
1526                        }
1527                    }
1528                    if changed {
1529                        work.push_back(s);
1530                    }
1531                }
1532            }
1533            if entry == before {
1534                break;
1535            }
1536        }
1537
1538        // Build the usage-derived field layout: for each field op, resolve its object's struct type (from
1539        // the now-converged register plane) and record its field name in first-reference order. The result
1540        // is a compact `[0, nfields)` slot table per struct type, complete + self-consistent by construction.
1541        let mut field_slots: Vec<std::collections::HashMap<String, usize>> =
1542            vec![std::collections::HashMap::new(); type_names.len()];
1543        for (i, op) in program.code.iter().enumerate() {
1544            let (obj, field) = match op {
1545                Op::StructInsert { obj, field, .. } => (*obj, *field),
1546                Op::GetField { obj, field, .. } => (*obj, *field),
1547                Op::CrdtBump { obj, field, .. } => (*obj, *field),
1548                Op::CrdtResolve { obj, field, .. } => (*obj, *field),
1549                _ => continue,
1550            };
1551            let Some(ty) = entry.get(i).and_then(|st| st[obj as usize & 255]) else {
1552                continue;
1553            };
1554            let Some(Constant::Text(fname)) = program.constants.get(field as usize) else {
1555                continue;
1556            };
1557            let slots = &mut field_slots[ty as usize];
1558            if !slots.contains_key(fname.as_str()) {
1559                let pos = slots.len();
1560                slots.insert(fname.clone(), pos);
1561            }
1562        }
1563        let field_counts = field_slots.iter().map(|s| s.len()).collect();
1564        StructTyping {
1565            entry,
1566            type_names,
1567            field_slots,
1568            field_counts,
1569        }
1570    }
1571
1572    /// The type-name index of the struct in register `r` on entry to code index `i`, if provably known.
1573    fn struct_of(&self, i: usize, r: u16) -> Option<u16> {
1574        self.entry.get(i).and_then(|st| st[r as usize & 255])
1575    }
1576
1577    /// The type-name index of a struct type by NAME (a `NewStruct`'s `type_name` const), if it is a struct
1578    /// type this program uses. `NewStruct` reads its type from the const directly (the ground truth) rather
1579    /// than the register plane, which only types the dst on the op's EXIT (successor entry), not at the op.
1580    fn index_of_name(&self, name: &str) -> Option<u16> {
1581        self.type_names
1582            .iter()
1583            .position(|n| n == name)
1584            .and_then(|i| u16::try_from(i).ok())
1585    }
1586
1587    /// The field count (`NewStruct` operand) of struct type `ty` — the usage-derived slot count.
1588    fn field_count(&self, ty: u16) -> usize {
1589        self.field_counts.get(ty as usize).copied().unwrap_or(0)
1590    }
1591}
1592
1593/// Resolve the (slot position, field count) for a field op on register `obj` at code index `i`: the object's
1594/// struct type (from the oracle) + the field NAME (from the `field` const) via the usage-derived layout.
1595/// `Err` names the missing piece so an unlowerable struct op fails LOUDLY rather than mis-addressing a heap slot.
1596fn resolve_field(
1597    i: usize,
1598    constants: &[Constant],
1599    st: &StructTyping,
1600    obj: Reg,
1601    field: u32,
1602) -> Result<(usize, usize), String> {
1603    let ty = st.struct_of(i, obj).ok_or_else(|| {
1604        format!(
1605            "instruction {i}: struct field op on r{obj} whose struct type is not statically known — \
1606             no full-core chip lowering (the positional slot cannot be resolved)"
1607        )
1608    })?;
1609    let field_name = match constants.get(field as usize) {
1610        Some(Constant::Text(s)) => s.as_str(),
1611        _ => {
1612            return Err(format!(
1613                "instruction {i}: struct field name const #{field} is not Text"
1614            ))
1615        }
1616    };
1617    let slot = st
1618        .field_slots
1619        .get(ty as usize)
1620        .and_then(|m| m.get(field_name))
1621        .copied()
1622        .ok_or_else(|| {
1623            format!(
1624                "instruction {i}: field `{field_name}` not in the usage-derived layout of struct type `{}`",
1625                st.type_names.get(ty as usize).map(String::as_str).unwrap_or("?")
1626            )
1627        })?;
1628    Ok((slot, st.field_count(ty)))
1629}
1630
1631/// An 8-bit variant TAG for an inductive constructor: the constructor-name `ConstIdx` itself. Constants are
1632/// content-deduped (`Compiler::add_const`), so the SAME constructor name shares ONE index — `NewInductive`'s
1633/// `ctor` and every `TestArm`'s `variant` referencing that name get the SAME tag, exactly the VM's name
1634/// equality. Must fit the model's 8-bit tag field; a program with >255 distinct constants that puts a
1635/// constructor name past 255 is rejected loudly (the tag would truncate).
1636fn inductive_tag(i: usize, ctor: u32) -> Result<u32, String> {
1637    if ctor > 255 {
1638        return Err(format!(
1639            "instruction {i}: inductive constructor const index {ctor} exceeds the 8-bit tag field"
1640        ));
1641    }
1642    Ok(ctor)
1643}
1644
1645/// The FLOAT lowering of a single op, produced from the type oracle: a list of `IntToF` conversions
1646/// (`(dst_fbank, src_intbank)` — each one imem word) that must precede the main op so its integer
1647/// operands enter the float bank, then the main float word (self-contained, no branch target). `None`
1648/// means the op is NOT float-typed and the integer path (`imem_width`/`encode_op`) handles it unchanged.
1649/// The conversions write `fregs[reg] = regs[reg] as f64` IN PLACE (same register number, disjoint bank),
1650/// so a subsequent integer use of that register is unaffected.
1651struct FloatPlan {
1652    convs: Vec<(u32, u32)>, // (reg to fill in the float bank) — src==dst==the reg, converted in place
1653    body: Vec<u32>, // the float instruction word(s): one for a primitive op, a whole sequence for a
1654                    // multi-op lowering (`is approximately` decomposes into FEq/FSub/FAbs/FMax/FConst/FMul/FLe/BitOr).
1655}
1656
1657/// Decide the float lowering of code index `i`, or `None` for the integer path. Emits `IntToF` for each
1658/// integer operand feeding a float op (the Int→Float boundary the type oracle located — e.g. `2.0 * k`
1659/// converts `k`). Comparisons with a float operand lower to `FLt`/`FEq` (int 0/1 result, so branches work).
1660/// `scratch` is the program-wide-unused base register (`max index any op touches + 1`); a multi-op float
1661/// lowering (`is approximately`) claims a small contiguous block starting there for its intermediates.
1662fn float_shape(
1663    i: usize,
1664    op: &Op,
1665    ft: &FloatTyping,
1666    scratch: Reg,
1667) -> Result<Option<FloatPlan>, String> {
1668    let isf = |r: Reg| ft.is_float(i, r);
1669    // The `CallBuiltin` opcode (38) carries the FLOAT-plane numeric coprocessor calls (abs/min/max/floor/
1670    // ceil/round on floats), the ordinal in the b-byte. Same opcode the integer-plane path uses; the
1671    // model's `38 =>` arm branches on the ordinal to read `fregs` vs `regs`.
1672    let cb_oc = crate::vm::binisa::opcode_of_name("CallBuiltin")
1673        .expect("CallBuiltin is a registered opcode");
1674    // A conversion for an integer operand feeding a float op: fill fregs[r] from regs[r] in place.
1675    let conv = |r: Reg, plan: &mut Vec<(u32, u32)>| -> Result<(), String> {
1676        if !isf(r) {
1677            plan.push((reg(r)?, reg(r)?));
1678        }
1679        Ok(())
1680    };
1681    let isq = |r: Reg| ft.is_float_seq(i, r);
1682    Ok(match op {
1683        Op::LoadConst { .. } => None, // handled in encode_op (needs the constant table); width special-cased
1684        // ── FLOAT-HEAP PLANE: a `Seq of Float` structure lives in the int heap; its f64 payloads ride the
1685        // parallel float plane. A float `Push`/`Add to` lowers to `FListPush` (int structure + fheap[slot] =
1686        // fregs[value]); a float `Set item` lowers to `FSetIndex`. The value is a proven scalar float (that
1687        // is the detector), so no `IntToF` conversion is needed. The list handle + index stay int registers.
1688        Op::ListPush { list, value } | Op::SetAdd { set: list, value } if isf(*value) => {
1689            Some(FloatPlan {
1690                convs: Vec::new(),
1691                body: vec![w(FLIST_PUSH, reg(*list)?, reg(*value)?, 0)],
1692            })
1693        }
1694        Op::SetIndex {
1695            collection,
1696            index,
1697            value,
1698        }
1699        | Op::SetIndexUnchecked {
1700            collection,
1701            index,
1702            value,
1703        } if isf(*value) => Some(FloatPlan {
1704            convs: Vec::new(),
1705            body: vec![w(FSET_INDEX, reg(*collection)?, reg(*index)?, reg(*value)?)],
1706        }),
1707        // `item i of (Seq of Float)` — read the f64 payload off the float plane into `fregs[dst]`. The
1708        // collection handle + index are int registers (the pointer + 1-based position); the type oracle
1709        // proved the collection is a float seq so the RESULT is a scalar float.
1710        Op::Index {
1711            dst,
1712            collection,
1713            index,
1714        }
1715        | Op::IndexUnchecked {
1716            dst,
1717            collection,
1718            index,
1719        } if isq(*collection) => Some(FloatPlan {
1720            convs: Vec::new(),
1721            body: vec![w(FINDEX, reg(*dst)?, reg(*collection)?, reg(*index)?)],
1722        }),
1723        Op::Add { dst, lhs, rhs }
1724        | Op::Sub { dst, lhs, rhs }
1725        | Op::Mul { dst, lhs, rhs }
1726        | Op::Div { dst, lhs, rhs }
1727            if isf(*lhs) || isf(*rhs) =>
1728        {
1729            let oc = match op {
1730                Op::Add { .. } => FADD,
1731                Op::Sub { .. } => FSUB,
1732                Op::Mul { .. } => FMUL,
1733                _ => FDIV, // `/` on floats is IEEE-754 division
1734            };
1735            let mut convs = Vec::new();
1736            conv(*lhs, &mut convs)?;
1737            conv(*rhs, &mut convs)?;
1738            Some(FloatPlan {
1739                convs,
1740                body: vec![w(oc, reg(*dst)?, reg(*lhs)?, reg(*rhs)?)],
1741            })
1742        }
1743        // Exact/floor division and modulo have no float datapath (they are integer-division semantics);
1744        // a float operand reaching one is a real type error, rejected rather than silently mis-lowered.
1745        Op::ExactDiv { lhs, rhs, .. }
1746        | Op::FloorDiv { lhs, rhs, .. }
1747        | Op::Mod { lhs, rhs, .. }
1748            if isf(*lhs) || isf(*rhs) =>
1749        {
1750            return Err(format!(
1751                "instruction {i}: integer division/modulo op `{}` on a float operand has no chip lowering",
1752                op.name()
1753            ));
1754        }
1755        // `Set x to x + y` where x is a float accumulator: FAdd dst, dst, src (dst already float).
1756        Op::AddAssign { dst, src } if isf(*dst) || isf(*src) => {
1757            let mut convs = Vec::new();
1758            conv(*dst, &mut convs)?;
1759            conv(*src, &mut convs)?;
1760            Some(FloatPlan {
1761                convs,
1762                body: vec![w(FADD, reg(*dst)?, reg(*dst)?, reg(*src)?)],
1763            })
1764        }
1765        Op::Move { dst, src } if isf(*src) => Some(FloatPlan {
1766            convs: Vec::new(),
1767            body: vec![w(FMOVE, reg(*dst)?, reg(*src)?, 0)],
1768        }),
1769        // Comparisons where at least one side is float → FLt/FEq (int 0/1 result in regs).
1770        Op::Lt { dst, lhs, rhs } | Op::Gt { dst, lhs, rhs } if isf(*lhs) || isf(*rhs) => {
1771            let mut convs = Vec::new();
1772            conv(*lhs, &mut convs)?;
1773            conv(*rhs, &mut convs)?;
1774            // Gt a b == Lt b a.
1775            let (a, b) = if matches!(op, Op::Gt { .. }) {
1776                (*rhs, *lhs)
1777            } else {
1778                (*lhs, *rhs)
1779            };
1780            Some(FloatPlan {
1781                convs,
1782                body: vec![w(FLT, reg(*dst)?, reg(a)?, reg(b)?)],
1783            })
1784        }
1785        // `a <= b` → FLe(a,b); `a >= b` → `b <= a` → FLe(b,a) (operand swap, no float NOT needed).
1786        Op::LtEq { dst, lhs, rhs } | Op::GtEq { dst, lhs, rhs } if isf(*lhs) || isf(*rhs) => {
1787            let mut convs = Vec::new();
1788            conv(*lhs, &mut convs)?;
1789            conv(*rhs, &mut convs)?;
1790            let (a, b) = if matches!(op, Op::GtEq { .. }) {
1791                (*rhs, *lhs)
1792            } else {
1793                (*lhs, *rhs)
1794            };
1795            Some(FloatPlan {
1796                convs,
1797                body: vec![w(FLE, reg(*dst)?, reg(a)?, reg(b)?)],
1798            })
1799        }
1800        // `a == b` → FEq; `a != b` → FNe (native `!=`, NaN-true like the VM's f64 `!=`).
1801        Op::Eq { dst, lhs, rhs } | Op::NotEq { dst, lhs, rhs } if isf(*lhs) || isf(*rhs) => {
1802            let mut convs = Vec::new();
1803            conv(*lhs, &mut convs)?;
1804            conv(*rhs, &mut convs)?;
1805            let oc = if matches!(op, Op::NotEq { .. }) {
1806                FNE
1807            } else {
1808                FEQ
1809            };
1810            Some(FloatPlan {
1811                convs,
1812                body: vec![w(oc, reg(*dst)?, reg(*lhs)?, reg(*rhs)?)],
1813            })
1814        }
1815        // `a is approximately b` — the tolerant isclose compare, DECOMPOSED into the primitive float ops the
1816        // RTL already sequences per instruction (no bespoke multi-cycle isclose FSM). Bit-for-bit reproduces
1817        // the ONE shared definition `logicaffeine_data::ops::logos_approx_eq`, the SAME IEEE-754 RNE ops in the
1818        // SAME order, so the SAME image runs on the model AND `soft_core.v`:
1819        //   eq = FEq(a,b) ; diff = FSub(a,b) ; adiff = FAbs(diff) ; m = FMax(FAbs(a), FAbs(b)) ;
1820        //   scaled = 1e-9·m ; thresh = FMax(scaled, 1e-12) ; le = FLe(adiff, thresh) ; result = eq | le.
1821        // `logos_approx_eq(a,b) = (a==b) || |a−b| ≤ max(1e-9·max(|a|,|b|), 1e-12)`; the `eq | le` fast path
1822        // reproduces the `a==b` short-circuit (and stays 0-on-NaN, since `==`/`<=` are both false on NaN).
1823        // The float coprocessor `FMax` (op 38, ordinal 38) reads its two operands from CONSECUTIVE float
1824        // registers (`fregs[ra]`, `fregs[ra+1]`), so the two `|a|`/`|b|` and `scaled`/`1e-12` pairs are placed
1825        // adjacently. Intermediates live in a contiguous block above `scratch` (program-wide-unused), disjoint
1826        // per bank: the int results `eq`/`le` reuse indices in the int file that the float temps occupy in the
1827        // float file (the banks never alias). The final `BitOr` writes the int 0/1 into `dst`.
1828        Op::ApproxEq { dst, lhs, rhs } if isf(*lhs) || isf(*rhs) => {
1829            let mut convs = Vec::new();
1830            conv(*lhs, &mut convs)?;
1831            conv(*rhs, &mut convs)?;
1832            let (a, b, d) = (reg(*lhs)?, reg(*rhs)?, reg(*dst)?);
1833            // Contiguous scratch block s..=s+8 (all above every used register). The two FMax pairs must be
1834            // adjacent: (fa=|a|, fa+1=|b|) and (fsc=scaled, fsc+1=1e-12).
1835            let s = reg(scratch)?;
1836            let need_hi = s + 8;
1837            if need_hi > 255 {
1838                return Err(format!(
1839                    "instruction {i}: `is approximately` needs a 9-register scratch block for its isclose \
1840                     decomposition, but the program's live registers leave none free (base r{s})"
1841                ));
1842            }
1843            let fa = s; // |a|  (FMax pair lo)
1844            let fb = s + 1; // |b|  (FMax pair hi = fa+1)
1845            let fm = s + 2; // m = max(|a|,|b|)
1846            let fc9 = s + 3; // 1e-9
1847            let fsc = s + 4; // scaled = 1e-9·m       (FMax pair lo)
1848            let fc12 = s + 5; // 1e-12                 (FMax pair hi = fsc+1)
1849            let fth = s + 6; // thresh = max(scaled, 1e-12)
1850            let fdiff = s + 7; // a − b
1851            let fadiff = s + 8; // |a − b|
1852            let ieq = s; // int bank: eq = (a==b)   (disjoint bank — reuses the |a| index)
1853            let ile = s + 1; // int bank: le = diff ≤ thresh (reuses the |b| index)
1854            let fconst = |dst: u32, bits: u64| -> [u32; 3] {
1855                [
1856                    w(FCONST, dst, 0, 0),
1857                    (bits & 0xffff_ffff) as u32,
1858                    (bits >> 32) as u32,
1859                ]
1860            };
1861            let mut body: Vec<u32> = Vec::with_capacity(18);
1862            body.push(w(FEQ, ieq, a, b)); // eq = FEq(a,b) → regs[ieq]
1863            body.push(w(FSUB, fdiff, a, b)); // diff = a − b
1864            body.push(w(cb_oc, fadiff, fdiff, CB_FABS)); // adiff = |diff|
1865            body.push(w(cb_oc, fa, a, CB_FABS)); // |a|
1866            body.push(w(cb_oc, fb, b, CB_FABS)); // |b|  (= fa+1)
1867            body.push(w(cb_oc, fm, fa, CB_FMAX)); // m = FMax(fregs[fa], fregs[fa+1])
1868            body.extend(fconst(fc9, (1e-9f64).to_bits())); // 1e-9
1869            body.push(w(FMUL, fsc, fc9, fm)); // scaled = 1e-9 · m
1870            body.extend(fconst(fc12, (1e-12f64).to_bits())); // 1e-12  (= fsc+1)
1871            body.push(w(cb_oc, fth, fsc, CB_FMAX)); // thresh = FMax(fregs[fsc], fregs[fsc+1])
1872            body.push(w(FLE, ile, fadiff, fth)); // le = FLe(adiff, thresh) → regs[ile]
1873            body.push(w(BITOR, d, ieq, ile)); // result = eq | le → regs[dst]
1874            Some(FloatPlan { convs, body })
1875        }
1876        Op::Show { src } if isf(*src) => Some(FloatPlan {
1877            convs: Vec::new(),
1878            body: vec![w(SHOWF, 0, reg(*src)?, 0)],
1879        }),
1880        Op::CallBuiltin {
1881            dst,
1882            builtin,
1883            args_start,
1884            ..
1885        } if matches!(builtin, crate::semantics::builtins::BuiltinId::Sqrt) && isf(*args_start) => {
1886            Some(FloatPlan {
1887                convs: Vec::new(),
1888                body: vec![w(FSQRT, reg(*dst)?, reg(*args_start)?, 0)],
1889            })
1890        }
1891        // `abs(x)` on a FLOAT — the FPU magnitude (`f.abs()`), fregs → fregs. Result stays a float scalar.
1892        Op::CallBuiltin {
1893            dst,
1894            builtin,
1895            args_start,
1896            ..
1897        } if matches!(builtin, crate::semantics::builtins::BuiltinId::Abs) && isf(*args_start) => {
1898            Some(FloatPlan {
1899                convs: Vec::new(),
1900                body: vec![w(cb_oc, reg(*dst)?, reg(*args_start)?, CB_FABS)],
1901            })
1902        }
1903        // `min(a, b)` / `max(a, b)` where EITHER operand is a float — the VM promotes the pair to f64
1904        // (`(x as f64).min(y)` etc), so any int operand is converted (IntToF) and the compare runs on the
1905        // float plane (fregs → fregs). The two args are the consecutive registers `args_start` / `args_start+1`.
1906        Op::CallBuiltin {
1907            dst,
1908            builtin,
1909            args_start,
1910            arg_count,
1911        } if matches!(
1912            builtin,
1913            crate::semantics::builtins::BuiltinId::Min | crate::semantics::builtins::BuiltinId::Max
1914        ) && *arg_count == 2
1915            && (isf(*args_start) || isf(args_start.saturating_add(1))) =>
1916        {
1917            let mut convs = Vec::new();
1918            conv(*args_start, &mut convs)?;
1919            conv(args_start.saturating_add(1), &mut convs)?;
1920            let ord = if matches!(builtin, crate::semantics::builtins::BuiltinId::Min) {
1921                CB_FMIN
1922            } else {
1923                CB_FMAX
1924            };
1925            Some(FloatPlan {
1926                convs,
1927                body: vec![w(cb_oc, reg(*dst)?, reg(*args_start)?, ord)],
1928            })
1929        }
1930        // `floor(x)` / `ceil(x)` / `round(x)` on a FLOAT — the FPU rounds to an INTEGER (`f.floor() as i64`,
1931        // round-half-away via Rust's `f64::round`), fregs → REGS. The result is an int scalar, so the dst
1932        // leaves the float plane (the type oracle marks it int); the coprocessor writes `regs[dst]`.
1933        Op::CallBuiltin {
1934            dst,
1935            builtin,
1936            args_start,
1937            ..
1938        } if matches!(
1939            builtin,
1940            crate::semantics::builtins::BuiltinId::Floor
1941                | crate::semantics::builtins::BuiltinId::Ceil
1942                | crate::semantics::builtins::BuiltinId::Round
1943        ) && isf(*args_start) =>
1944        {
1945            use crate::semantics::builtins::BuiltinId;
1946            let ord = match builtin {
1947                BuiltinId::Floor => CB_FLOOR,
1948                BuiltinId::Ceil => CB_FCEIL,
1949                _ => CB_FROUND,
1950            };
1951            Some(FloatPlan {
1952                convs: Vec::new(),
1953                body: vec![w(cb_oc, reg(*dst)?, reg(*args_start)?, ord)],
1954            })
1955        }
1956        _ => None,
1957    })
1958}
1959
1960// ── THE WORD32 (ℤ/2³²) LOWERING ─────────────────────────────────────────────────────────────────────
1961// A `Word32` is the register's own 32-bit pattern, so almost the whole vocabulary is FREE on the integer
1962// datapath. This shape covers the parts that need a distinct lowering:
1963//  • the CONSTRUCTOR/COERCION builtins `word32(n)` / `intOfWord32(w)` → `Move` (the value is the bit
1964//    pattern, already in the register; the coercion is a no-op copy);
1965//  • the BITWISE word builtins `word_and`/`word_or`/`word_not` → the `BitAnd`/`BitOr`/`Not` ALU ops
1966//    (bit-identical to the ring), since the source's `xor` OPERATOR already lowers to `BitXor`;
1967//  • the ROTATE and LOGICAL-SHIFT builtins `rotl`/`rotr`/`word32Shr` → the `CallBuiltin` coprocessor
1968//    (opcode 38, by ordinal) — the integer ALU has no rotate and only an ARITHMETIC `Shr`. The shift/
1969//    rotate count rides `regs[args_start+1]` (a plain int), matching the model's coprocessor arm;
1970//  • a Word32 `+`/`-`/`*` → the WORD32 ARITHMETIC ops (226/227/228), which wrap in the ring ℤ/2³² — the
1971//    value-integer ALU (8/10/11) is now UNBOUNDED (BigInt on overflow, to match a plain `Int`), so a
1972//    Word32 sum routed there would drift past 2³² instead of wrapping (SHA-256's `s1 + s0 + …` chain).
1973//    The oracle's word32-result marking (`wd(lhs) || wd(rhs)`) is on the op's EXIT; here we read the same
1974//    signal from the ENTRY state (an operand is already Word32), which is exactly when the result wraps.
1975//  • a Word32-typed `Show` → `ShowWord32` (opcode 221), which renders the value UNSIGNED.
1976// All are single-word (no operand conversions), so the width is always 1. `None` means the op is not a
1977// Word32 op and the integer path handles it unchanged (a Word32 `==`/`Move`/`Push`/bitwise already rides
1978// the integer datapath byte-identically — only its element/Show TYPING differs, carried by the type oracle).
1979fn word_shape(i: usize, op: &Op, ft: &FloatTyping) -> Result<Option<u32>, String> {
1980    use crate::semantics::builtins::BuiltinId;
1981    let move_oc = crate::vm::binisa::opcode_of_name("Move").expect("Move is a registered opcode");
1982    let and_oc =
1983        crate::vm::binisa::opcode_of_name("BitAnd").expect("BitAnd is a registered opcode");
1984    let or_oc = crate::vm::binisa::opcode_of_name("BitOr").expect("BitOr is a registered opcode");
1985    let not_oc = crate::vm::binisa::opcode_of_name("Not").expect("Not is a registered opcode");
1986    let cb_oc = crate::vm::binisa::opcode_of_name("CallBuiltin")
1987        .expect("CallBuiltin is a registered opcode");
1988    // A Word32 `+`/`-`/`*` (either operand already Word32) wraps in ℤ/2³²: the wrapping opcodes 226/227/228.
1989    // NOT a Text/float `Add` (those planes take precedence in the transfer; a Text `+` has a Text operand,
1990    // a float `+` a float operand — neither is Word32-typed here, so the guard `is_word32(operand)` is exact).
1991    let word32_arith = |dst: &Reg, a: &Reg, b: &Reg, oc: u16| -> Result<Option<u32>, String> {
1992        if ft.is_word32(i, *a) || ft.is_word32(i, *b) {
1993            Ok(Some(w(oc, reg(*dst)?, reg(*a)?, reg(*b)?)))
1994        } else {
1995            Ok(None)
1996        }
1997    };
1998    Ok(match op {
1999        Op::Add { dst, lhs, rhs } => return word32_arith(dst, lhs, rhs, WORD32ADD),
2000        Op::Sub { dst, lhs, rhs } => return word32_arith(dst, lhs, rhs, WORD32SUB),
2001        Op::Mul { dst, lhs, rhs } => return word32_arith(dst, lhs, rhs, WORD32MUL),
2002        // `Set w to w + x` on a Word32 accumulator — the in-place add wraps too (lhs = dst).
2003        Op::AddAssign { dst, src } => return word32_arith(dst, dst, src, WORD32ADD),
2004        Op::CallBuiltin {
2005            dst,
2006            builtin,
2007            args_start,
2008            ..
2009        } => match builtin {
2010            // The value IS the register's bit pattern — construction / int-coercion is an identity copy.
2011            BuiltinId::Word32 | BuiltinId::IntOfWord32 => {
2012                Some(w(move_oc, reg(*dst)?, reg(*args_start)?, 0))
2013            }
2014            // Bitwise word ops → the existing ALU datapath. The two operands ride args_start, args_start+1.
2015            BuiltinId::Wand => Some(w(
2016                and_oc,
2017                reg(*dst)?,
2018                reg(*args_start)?,
2019                reg(args_start.saturating_add(1))?,
2020            )),
2021            BuiltinId::Wor => Some(w(
2022                or_oc,
2023                reg(*dst)?,
2024                reg(*args_start)?,
2025                reg(args_start.saturating_add(1))?,
2026            )),
2027            BuiltinId::Wnot => Some(w(not_oc, reg(*dst)?, reg(*args_start)?, 0)),
2028            // Rotate / logical shift → the CallBuiltin coprocessor (opcode 38) by ordinal. dst = result,
2029            // ra = args_start (the word), and the coprocessor reads the count from regs[args_start+1].
2030            BuiltinId::Rotl | BuiltinId::Rotr | BuiltinId::Word32Shr => {
2031                let ord = builtin_chip_ordinal(*builtin).ok_or_else(|| {
2032                    format!("instruction {i}: word builtin {builtin:?} has no chip ordinal")
2033                })?;
2034                // The count register (args_start+1) must fit the 8-bit operand field too.
2035                reg(args_start.saturating_add(1))?;
2036                Some(w(cb_oc, reg(*dst)?, reg(*args_start)?, ord as u32))
2037            }
2038            _ => None,
2039        },
2040        // A Word32-typed `Show` renders UNSIGNED (0..2³²−1) — the integer `Show` would print `i32` signed.
2041        Op::Show { src } if ft.is_word32(i, *src) => Some(w(SHOWWORD32, 0, reg(*src)?, 0)),
2042        _ => None,
2043    })
2044}
2045
2046// ── THE TEXT (string) LOWERING ────────────────────────────────────────────────────────────────────
2047// A `Text` handle is an int pointer to a char-code list. Three op shapes carry Text on the chip:
2048//  • a Text `LoadConst` → `TextConst` (2 words: op+len then the pool word-offset), copying the string's
2049//    char block from the imem string-constant pool into a fresh heap char-list;
2050//  • a Text `Add`/`AddAssign` → the `Concat` datapath (op 32) — the VM's `Text + x` rule is string concat,
2051//    with a non-Text operand rendered via `to_display_string()`. An Int operand is FormatValue'd first (its
2052//    decimal char-list); a Text operand feeds the Concat directly. (`AddAssign` = `Set r to r + x`.)
2053//  • a Text `Show` → `ShowText`, which decodes the char list to a UTF-8 string.
2054// This mirrors the VM's kernel add: `(Text,Text)` concat, `(Text,other)`/`(other,Text)` stringify-then-concat.
2055enum TextLower {
2056    /// A Text `LoadConst`: copy the pooled char block into a fresh heap char-list. `idx` = constant index.
2057    Const { dst: Reg, idx: u32 },
2058    /// A Text concat (`Add`/`AddAssign`). `lhs`/`rhs` are the concat operands; `fmt` names the ONE operand
2059    /// (if any) that is not itself Text and must be FormatValue'd to its decimal char-list first (Int only).
2060    Concat {
2061        dst: Reg,
2062        lhs: Reg,
2063        rhs: Reg,
2064        fmt: Option<Reg>,
2065    },
2066    /// A Text `Show` — decode the char list at `src` and emit the string.
2067    Show { src: Reg },
2068    /// `item i of text` — a single-character Text (a fresh `[1, codepoint]` char-list), matching the VM.
2069    CharAt {
2070        dst: Reg,
2071        collection: Reg,
2072        index: Reg,
2073    },
2074    /// A Text `equals`/`is not` (`Eq`/`NotEq` with a Text operand) — char-list CONTENT compare. `eq` selects
2075    /// equality (`TextEq`) vs inequality (`TextNe`); the result is an int 0/1.
2076    Compare {
2077        dst: Reg,
2078        lhs: Reg,
2079        rhs: Reg,
2080        eq: bool,
2081    },
2082    /// A Text `contains` (`Op::Contains` on a Text collection, or the `contains(hay, needle)` builtin) —
2083    /// a SUBSTRING char-scan (`TextContains`, op 234) → int 0/1. Distinct from the LIST/SET/MAP `Op::Contains`
2084    /// (op 63, element membership): a Text `contains` tests a contiguous run of codepoints, not a single cell.
2085    Contains { dst: Reg, hay: Reg, needle: Reg },
2086}
2087
2088impl TextLower {
2089    /// The imem word count of this lowering. `Const` = 2 (op word + pool offset word); `Concat` = 1
2090    /// (a bare Concat) or 2 (a FormatValue then the Concat); every other Text op = 1.
2091    fn width(&self) -> usize {
2092        match self {
2093            TextLower::Const { .. } => 2,
2094            TextLower::Concat { fmt, .. } => 1 + usize::from(fmt.is_some()),
2095            TextLower::Show { .. }
2096            | TextLower::CharAt { .. }
2097            | TextLower::Compare { .. }
2098            | TextLower::Contains { .. } => 1,
2099        }
2100    }
2101}
2102
2103/// Classify code index `i` as a Text op, or `None` for the non-Text path. Uses the Text type plane to decide
2104/// a Text `Add`/`AddAssign`/`Show`; a Text `LoadConst` is decided from the constant kind (ground truth). A
2105/// Text concat whose non-Text operand is neither a plain Int (FormatValue-able) nor a Text is rejected — the
2106/// chip has no `to_display_string` for a Float/Bool inside a string, so it fails loudly rather than mis-lower.
2107fn text_shape(
2108    i: usize,
2109    op: &Op,
2110    constants: &[Constant],
2111    ft: &FloatTyping,
2112) -> Result<Option<TextLower>, String> {
2113    let ist = |r: Reg| ft.is_text(i, r);
2114    // The non-Text operand of a Text concat must be a plain Int at runtime (FormatValue renders the decimal
2115    // string, matching the VM's `Int::to_display_string`). A float there would need a float→string path the
2116    // chip doesn't have; reject it. We can only prove int-ness by exclusion (not float / not text / not seq),
2117    // which the benchmarks satisfy (they concat only ints and strings).
2118    let non_text_ok = |r: Reg| -> Result<(), String> {
2119        if ft.is_float(i, r) || ft.is_float_seq(i, r) {
2120            return Err(format!(
2121                "instruction {i}: a Text concat with a non-integer operand (r{r} is float) has no chip lowering — \
2122                 only `Text + Int` interpolation and `Text + Text` concat are supported"
2123            ));
2124        }
2125        Ok(())
2126    };
2127    Ok(match op {
2128        Op::LoadConst { dst, idx } => match constants.get(*idx as usize) {
2129            Some(Constant::Text(_)) => Some(TextLower::Const {
2130                dst: *dst,
2131                idx: *idx,
2132            }),
2133            _ => None,
2134        },
2135        Op::Add { dst, lhs, rhs } if ist(*lhs) || ist(*rhs) => {
2136            let fmt = if ist(*lhs) && ist(*rhs) {
2137                None
2138            } else if ist(*lhs) {
2139                non_text_ok(*rhs)?;
2140                Some(*rhs)
2141            } else {
2142                non_text_ok(*lhs)?;
2143                Some(*lhs)
2144            };
2145            Some(TextLower::Concat {
2146                dst: *dst,
2147                lhs: *lhs,
2148                rhs: *rhs,
2149                fmt,
2150            })
2151        }
2152        // An explicit `Concat` — the interpolation `"{x:.N}"` accumulator (`"" ++ formatted ++ …`) emits this,
2153        // not `Add`. Both operands are Text on the chip (the accumulator started as a Text `LoadConst`; each
2154        // piece is a Text literal or a `FormatValue`'d char-list, already marked Text by the type oracle), so it
2155        // is a bare Text-Text `Concat`. A non-Text operand (a raw int piece) is FormatValue'd first, mirroring
2156        // the `Add` path — so a mixed interpolation lowers the same way regardless of which concat op the
2157        // compiler chose.
2158        Op::Concat { dst, lhs, rhs } if ist(*lhs) || ist(*rhs) => {
2159            let fmt = if ist(*lhs) && ist(*rhs) {
2160                None
2161            } else if ist(*lhs) {
2162                non_text_ok(*rhs)?;
2163                Some(*rhs)
2164            } else {
2165                non_text_ok(*lhs)?;
2166                Some(*lhs)
2167            };
2168            Some(TextLower::Concat {
2169                dst: *dst,
2170                lhs: *lhs,
2171                rhs: *rhs,
2172                fmt,
2173            })
2174        }
2175        // `Set r to r + x` on a Text accumulator: Concat into r (lhs = dst). The VM's `add_assign` for a Text
2176        // dst is exactly `r + x`, so this is `Concat r = r ++ x` (FormatValue-ing a non-Text x).
2177        Op::AddAssign { dst, src } if ist(*dst) || ist(*src) => {
2178            let fmt = if ist(*dst) && ist(*src) {
2179                None
2180            } else if ist(*dst) {
2181                non_text_ok(*src)?;
2182                Some(*src)
2183            } else {
2184                // A Text src added into a non-Text dst — `stringify(dst) ++ src`; the dst is the fmt operand.
2185                non_text_ok(*dst)?;
2186                Some(*dst)
2187            };
2188            Some(TextLower::Concat {
2189                dst: *dst,
2190                lhs: *dst,
2191                rhs: *src,
2192                fmt,
2193            })
2194        }
2195        Op::Show { src } if ist(*src) => Some(TextLower::Show { src: *src }),
2196        // `item i of text` — a single-char Text (the VM returns a 1-char string). The collection handle +
2197        // 1-based index are int registers; the result is a fresh char-list.
2198        Op::Index {
2199            dst,
2200            collection,
2201            index,
2202        }
2203        | Op::IndexUnchecked {
2204            dst,
2205            collection,
2206            index,
2207        } if ist(*collection) => Some(TextLower::CharAt {
2208            dst: *dst,
2209            collection: *collection,
2210            index: *index,
2211        }),
2212        // A Text `equals`/`is not` — char-list content compare (both operands are Text handles). The VM
2213        // compares Text by content; the chip does the same via `TextEq`/`TextNe`.
2214        Op::Eq { dst, lhs, rhs } if ist(*lhs) || ist(*rhs) => Some(TextLower::Compare {
2215            dst: *dst,
2216            lhs: *lhs,
2217            rhs: *rhs,
2218            eq: true,
2219        }),
2220        Op::NotEq { dst, lhs, rhs } if ist(*lhs) || ist(*rhs) => Some(TextLower::Compare {
2221            dst: *dst,
2222            lhs: *lhs,
2223            rhs: *rhs,
2224            eq: false,
2225        }),
2226        // A Text `contains` (`s contains "sub"`) — the compiler lowers the `contains` OPERATOR to `Op::Contains`
2227        // (op 63) for EVERY collection kind, but Text membership is SUBSTRING containment, not the element scan
2228        // op 63 performs (which would compare a codepoint cell against the needle's Text pointer and always
2229        // miss). When the COLLECTION is a Text char-list, route it to the substring scanner `TextContains`
2230        // (op 234) — byte-identical to the VM's `Value::contains` for two Texts (`h.contains(needle)`). The
2231        // NEEDLE must be provably Text too: the VM errors on a non-Text needle against a Text (`Cannot check if
2232        // Text contains …`), and the char-scan reads it as a char-list, so a non-Text needle is rejected loudly
2233        // rather than mis-scanned (never silently falling to the op-63 element scan, which is wrong for a Text).
2234        Op::Contains {
2235            dst,
2236            collection,
2237            value,
2238        } if ist(*collection) => {
2239            if !ist(*value) {
2240                return Err(format!(
2241                    "instruction {i}: `contains` on a Text needs a Text needle (r{value} is not Text) — the \
2242                     substring scanner reads the needle as a char-list; a non-Text needle has no chip lowering"
2243                ));
2244            }
2245            Some(TextLower::Contains {
2246                dst: *dst,
2247                hay: *collection,
2248                needle: *value,
2249            })
2250        }
2251        _ => None,
2252    })
2253}
2254
2255/// The imem word count an op lowers to: 2 for `Call` (word2 = callee entry) and a wide `LoadConst`
2256/// (word2 = 32-bit immediate), 1 for everything else. Target-independent (depends only on the op kind,
2257/// its float typing, and constant magnitude), so pass 1 can build the pc→imem map before any target is
2258/// resolved. A float op costs `1 + <IntToF conversions>` (see [`float_shape`]); a float `LoadConst` is a
2259/// 3-word `FConst`. A Text op costs [`TextLower::width`]. Returns `Err` for an op with no chip lowering, so
2260/// an unsupported program fails loudly.
2261fn imem_width(
2262    i: usize,
2263    op: &Op,
2264    constants: &[Constant],
2265    ft: &FloatTyping,
2266    slice_fold: &dyn Fn(usize) -> Option<(u8, u8)>,
2267    scratch: Reg,
2268) -> Result<usize, String> {
2269    // A float-typed op: the lowering body (one word for a primitive op, a full sequence for `is
2270    // approximately`) plus one IntToF per integer operand feeding it.
2271    if let Some(plan) = float_shape(i, op, ft, scratch)? {
2272        return Ok(plan.convs.len() + plan.body.len());
2273    }
2274    // A Word32 op (construct/coerce/bitwise/rotate/shift/Show): always a single chip word.
2275    if word_shape(i, op, ft)?.is_some() {
2276        return Ok(1);
2277    }
2278    if let Some(plan) = failure_plan(i, op, constants, ft)? {
2279        return Ok(plan.width());
2280    }
2281    if let Some(plan) = format_plan(i, op, ft)? {
2282        return Ok(plan.width());
2283    }
2284    // A Text-typed op: a TextConst (2), a Concat (1) / FormatValue+Concat (2), or a ShowText (1).
2285    if let Some(t) = text_shape(i, op, constants, ft)? {
2286        return Ok(t.width());
2287    }
2288    Ok(match op {
2289        Op::Call { .. } => 2,
2290        // `args()` materializes `["logos"]`: NewEmptyList + TextConst (2) + ListPush.
2291        Op::Args { .. } => 4,
2292        // A `NewList` of a `Seq of Float` literal builds through the float plane: `NewEmptyList` + one
2293        // `FListPush` per element (`1 + count` words), so `item i of` reads the f64 payload. A non-float
2294        // `NewList` is the single-word structure op. (See the matching `encode_op` arm.)
2295        Op::NewList { start, count, .. } if (0..*count).any(|k| ft.is_float(i, start.saturating_add(k))) => {
2296            1 + *count as usize
2297        }
2298        Op::NewList { .. } => 1,
2299        Op::LoadConst { idx, .. } => {
2300            let c = constants
2301                .get(*idx as usize)
2302                .ok_or_else(|| format!("LoadConst references missing constant #{idx}"))?;
2303            // A float literal is ALWAYS a 3-word `FConst` (op + the two immediate words = the f64 bits) —
2304            // the constant kind is ground truth, so it needs no dataflow lookup.
2305            if let Constant::Float(_) = c {
2306                let _ = i; // (i unused here; kept in the signature for the float ops)
2307                return Ok(3);
2308            }
2309            let v = const_i64(c)?;
2310            // A constant wider than i32 (the PRNG moduli 2³¹ / 2³², or a full-range i64 literal) is held in a
2311            // 3-word LoadConstWide64 (op 222) at EVERY width. The full core's value datapath auto-promotes to
2312            // BigInt on overflow exactly like the VM, and op 222 retires the full i64 regardless of datapath
2313            // width, so the chip reproduces the constant EXACTLY at width 32 as well as 64 — the width is a
2314            // fast-path/area knob, not a correctness switch (BigInt handles exactness). An i32-fitting constant
2315            // uses the narrow (1) / 2-word (2) inline immediate.
2316            if !fits_i32(v) {
2317                3
2318            } else if narrow(v as i32) {
2319                1
2320            } else {
2321                2
2322            }
2323        }
2324        // Ops with a clean single-word lowering (validated by `encode_op` below).
2325        Op::Add { .. } | Op::Sub { .. } | Op::Mul { .. } | Op::Div { .. } | Op::Mod { .. }
2326        | Op::ExactDiv { .. } | Op::FloorDiv { .. } | Op::AddAssign { .. }
2327        // `Swap a and b` (RevSwap), the tolerant `is approximately` (ApproxEq — int `==` here, float via
2328        // `float_shape`), fresh set union/intersection (UnionOp/IntersectOp), swap-remove (RemoveFrom),
2329        // and struct-field push (ListPushField) all lower to one word.
2330        | Op::RevSwap { .. } | Op::ApproxEq { .. } | Op::UnionOp { .. } | Op::IntersectOp { .. }
2331        | Op::RemoveFrom { .. } | Op::ListPushField { .. }
2332        | Op::Lt { .. } | Op::Gt { .. } | Op::LtEq { .. } | Op::GtEq { .. } | Op::Eq { .. } | Op::NotEq { .. }
2333        | Op::BitXor { .. } | Op::BitAnd { .. } | Op::BitOr { .. } | Op::Shl { .. } | Op::Shr { .. } | Op::Not { .. }
2334        | Op::Move { .. } | Op::Jump { .. } | Op::JumpIfFalse { .. } | Op::JumpIfTrue { .. }
2335        | Op::Return { .. } | Op::ReturnNothing | Op::Show { .. } | Op::Halt | Op::FailWith { .. }
2336        | Op::NewTuple { .. } | Op::NewEmptyList { .. } | Op::NewEmptyListI32 { .. }
2337        | Op::NewEmptyListWithCapacity { .. } | Op::NewEmptyListFixed { .. }
2338        | Op::NewEmptyMapWithCapacity { .. }
2339        | Op::NewEmptyMap { .. } | Op::NewEmptySet { .. } | Op::NewRange { .. }
2340        | Op::ListPush { .. } | Op::SetAdd { .. } | Op::Length { .. } | Op::Contains { .. } | Op::ListPop { .. }
2341        | Op::DeepClone { .. } | Op::SeqConcat { .. }
2342        | Op::Index { .. } | Op::IndexUnchecked { .. } | Op::SetIndex { .. } | Op::SetIndexUnchecked { .. }
2343        | Op::IterPrepare { .. } | Op::IterNext { .. } | Op::IterPop
2344        | Op::GlobalGet { .. } | Op::GlobalSet { .. }
2345        // Structs (74/75/76), inductives + match (77/78/79), CRDTs (80/81/84/82/83/113), non-capturing
2346        // closures (40/39), and deterministic concurrency (channels 92/93/94, Spawn 98) all lower to a
2347        // single self-contained chip word. `encode_op` validates each operand fits its field. (A field op
2348        // needs the struct oracle to resolve the slot; a closure/spawn resolves an imem entry — none add a word.)
2349        | Op::NewStruct { .. } | Op::StructInsert { .. } | Op::GetField { .. }
2350        | Op::NewInductive { .. } | Op::TestArm { .. } | Op::BindArm { .. }
2351        | Op::CrdtBump { .. } | Op::CrdtMerge { .. } | Op::CrdtJoin { .. }
2352        | Op::NewCrdt { .. } | Op::CrdtAppend { .. } | Op::CrdtResolve { .. }
2353        | Op::MakeClosure { .. } | Op::CallValue { .. }
2354        | Op::ChanNew { .. } | Op::ChanSend { .. } | Op::ChanRecv { .. } | Op::Spawn { .. }
2355        // The residual deterministic ops the model + RTL already decode, each a single self-contained chip
2356        // word (validated by `encode_op`): tuple destructure (71), the integer power unit (18), the non-
2357        // blocking channels (95/96/97), the handle-bound sync launch + task join/abort (99/100/101), the
2358        // `select` arms + wait (102/103/104), the deterministic scheduler `Sleep` no-op (88), and the offline
2359        // single-node networking no-ops / loopback inbox (105..=111 — Connect/Listen/Send/Stream/Await/
2360        // MakePeer/Sync). Each rides the ordinary integer register file and heap; none adds an imem word.
2361        | Op::DestructureTuple { .. } | Op::Pow { .. } | Op::Sleep { .. }
2362        | Op::ChanTrySend { .. } | Op::ChanTryRecv { .. } | Op::ChanClose { .. }
2363        | Op::SpawnHandle { .. } | Op::TaskAwait { .. } | Op::TaskAbort { .. }
2364        | Op::SelectArmRecv { .. } | Op::SelectArmTimeout { .. } | Op::SelectWait { .. }
2365        | Op::NetConnect { .. } | Op::NetListen { .. } | Op::NetSend { .. } | Op::NetStream { .. }
2366        | Op::NetAwait { .. } | Op::NetMakePeer { .. } | Op::NetSync { .. }
2367        | Op::EnsureOwned { .. } | Op::RegionBoundsGuard { .. } => 1,
2368        // A standalone `FormatValue` (the `Show "{x:.N}"` render): a single chip word — op 64 (integer
2369        // decimal) or op 225 (float fixed-precision decimal), decided by the operand's static type in
2370        // `encode_op`. A float operand needs a `.N` spec (the only float format the chip renders).
2371        Op::FormatValue { src, spec, debug_prefix, .. } => {
2372            if ft.is_float(i, *src) && float_format_precision(*spec, *debug_prefix, constants).is_none() {
2373                return Err(format!(
2374                    "instruction {i}: float `FormatValue` needs a `.N` precision spec — a bare `{{f}}` / \
2375                     currency / alignment on a float has no full-core chip lowering yet"
2376                ));
2377            }
2378            1
2379        }
2380        // The integer-plane numeric builtin coprocessor (abs/min/max/count_ones) is a single op-38 word. A
2381        // builtin with no integer-plane ordinal falls through to the reject (its float-plane / Word32 forms
2382        // were already caught by `float_shape`/`word_shape` above, which return a width before this match).
2383        Op::CallBuiltin { builtin, .. } if builtin_chip_ordinal(*builtin).is_some() => 1,
2384        // The deterministic string / fill / parse / encode builtins (`chr`=233 / TEXT `contains`=234 /
2385        // `repeatSeq`=235 / `parseInt`=236 / `text_bytes`=237) are a single chip word; the type-gate reject
2386        // inside `text_builtin_opcode` matches `encode_op` exactly.
2387        Op::CallBuiltin { builtin, args_start, .. } if text_builtin_opcode(i, *builtin, *args_start, ft)?.is_some() => 1,
2388        // `items i through j of xs` (SliceOp) is a single op-65 word IFF its bounds fold to small constant
2389        // nibbles; a non-foldable (runtime / large) bound has no chip form — reject to match `encode_op`.
2390        Op::SliceOp { .. } => {
2391            if slice_fold(i).is_some() {
2392                1
2393            } else {
2394                return Err(format!(
2395                    "instruction {i}: `SliceOp` bounds are not provably small integer constants (1..=15) — the \
2396                     chip's slice unit takes immediate nibble bounds, so a runtime or large slice bound has no \
2397                     full-core chip lowering yet"
2398                ));
2399            }
2400        }
2401        other => {
2402            return Err(format!(
2403                "`{}` has no full-core chip lowering yet (outside the integer benchmark subset)",
2404                other.name()
2405            ))
2406        }
2407    })
2408}
2409
2410/// Emit one op's imem word(s). `entry_of` maps a callee's `FuncIdx` to its imem entry index, and
2411/// `imem_of` maps a code index (a Jump/branch/iter target) to its imem word index (both via the pass-1
2412/// pc→imem table). Word count MUST equal [`imem_width`] for the same op. A float-typed op (per the type
2413/// oracle `ft` at code index `i`) lowers to its `IntToF` conversions then the float word.
2414fn encode_op(
2415    i: usize,
2416    op: &Op,
2417    constants: &[Constant],
2418    ft: &FloatTyping,
2419    st: &StructTyping,
2420    entry_of: &dyn Fn(u16) -> Result<u32, String>,
2421    imem_of: &dyn Fn(usize) -> Result<u32, String>,
2422    pool_of: &dyn Fn(u32) -> Result<(u32, u32), String>,
2423    captures_nonempty: &dyn Fn(u16) -> bool,
2424    is_float_iter: &dyn Fn(usize) -> bool,
2425    slice_fold: &dyn Fn(usize) -> Option<(u8, u8)>,
2426    scratch: Reg,
2427) -> Result<Vec<u32>, String> {
2428    // ── FLOAT DATAPATH: the type oracle proved this op operates on f64. Emit each integer operand's
2429    // Int→Float conversion (fregs[r] = regs[r]) then the float instruction. Keeps the integer path below
2430    // byte-identical (a non-float op returns `None` here and falls through).
2431    if let Some(plan) = float_shape(i, op, ft, scratch)? {
2432        let mut words = Vec::with_capacity(plan.convs.len() + plan.body.len());
2433        for (d, s) in plan.convs {
2434            words.push(w(INT_TO_F, d, s, 0)); // IntToF fregs[d] <- regs[s]  (d==s: convert in place)
2435        }
2436        words.extend(plan.body);
2437        return Ok(words);
2438    }
2439    // ── WORD32 (ℤ/2³²) DATAPATH: `word32`/`intOfWord32` → Move (identity), `word_and`/`word_or`/`word_not`
2440    // → the BitAnd/BitOr/Not ALU ops, `rotl`/`rotr`/`word32Shr` → the CallBuiltin coprocessor (opcode 38),
2441    // and a Word32 `Show` → `ShowWord32` (unsigned). A single self-contained word; the integer path below is
2442    // untouched when nothing is a Word32 op (a Word32 `+`/`Move`/`Push` already rides the integer datapath).
2443    if let Some(word) = word_shape(i, op, ft)? {
2444        return Ok(vec![word]);
2445    }
2446    // ── TEXT (string) DATAPATH: a Text constant → `TextConst` (pool copy); a Text `Add`/`AddAssign` → the
2447    // `format(x)` → its Show text (see [`FormatPlan`]).
2448    if let Some(plan) = format_plan(i, op, ft)? {
2449        let oc = |name: &str| crate::vm::binisa::opcode_of_name(name).expect("a registered opcode");
2450        return Ok(match plan {
2451            FormatPlan::Text { dst, src } => vec![w(oc("Move"), reg(dst)?, reg(src)?, 0)],
2452            FormatPlan::Int { dst, src } => vec![w(oc("FormatValue"), reg(dst)?, reg(src)?, 0)],
2453            FormatPlan::Bool { dst, src } => {
2454                let (t_off, t_len) = pool_of(POOL_TRUE)?;
2455                let (f_off, f_len) = pool_of(POOL_FALSE)?;
2456                vec![
2457                    ((TEXTCONST as u32) << 24) | (reg(dst)? << 16) | (t_len & 0xffff),
2458                    t_off,
2459                    ((TEXTCONST as u32) << 24) | (reg(scratch)? << 16) | (f_len & 0xffff),
2460                    f_off,
2461                    w(oc("Sub"), reg(dst)?, reg(dst)?, reg(scratch)?), // true − false
2462                    w(oc("Mul"), reg(dst)?, reg(dst)?, reg(src)?),     // × the 0/1 value
2463                    w(oc("Add"), reg(dst)?, reg(dst)?, reg(scratch)?), // + false
2464                ]
2465            }
2466        });
2467    }
2468    // A FAILURE → stream its reason over the Text port, then trap-halt `reasoned` (see [`FailPlan`]).
2469    if let Some(plan) = failure_plan(i, op, constants, ft)? {
2470        let oc = |name: &str| crate::vm::binisa::opcode_of_name(name).expect("a registered opcode");
2471        let trap = (u32::from(oc("FailWith")) << 24) | 1; // rb = 1: reasoned
2472        let show = |r: Reg| -> Result<u32, String> { Ok(w(SHOWTEXT, 0, reg(r)?, 0)) };
2473        let text_const = |dst: Reg, key: u32| -> Result<[u32; 2], String> {
2474            let (off, len) = pool_of(key)?;
2475            Ok([
2476                ((TEXTCONST as u32) << 24) | (reg(dst)? << 16) | (len & 0xffff),
2477                off,
2478            ])
2479        };
2480        return Ok(match plan {
2481            FailPlan::Text { src } => vec![show(src)?, trap],
2482            FailPlan::Int { src } => vec![
2483                w(oc("FormatValue"), reg(scratch)?, reg(src)?, 0),
2484                show(scratch)?,
2485                trap,
2486            ],
2487            FailPlan::Bool { src, dst } => {
2488                let t = text_const(scratch, POOL_TRUE)?;
2489                let f = text_const(dst, POOL_FALSE)?;
2490                vec![
2491                    t[0],
2492                    t[1],
2493                    f[0],
2494                    f[1],
2495                    w(oc("Sub"), reg(scratch)?, reg(scratch)?, reg(dst)?), // true − false
2496                    w(oc("Mul"), reg(scratch)?, reg(scratch)?, reg(src)?), // × the 0/1 value
2497                    w(oc("Add"), reg(scratch)?, reg(scratch)?, reg(dst)?), // + false → the chosen text
2498                    show(scratch)?,
2499                    trap,
2500                ]
2501            }
2502            FailPlan::Pooled { key } => {
2503                let t = text_const(scratch, key)?;
2504                vec![t[0], t[1], show(scratch)?, trap]
2505            }
2506        });
2507    }
2508    // `Concat` datapath (FormatValue-ing a non-Text operand); a Text `Show` → `ShowText`. The Text plane
2509    // proved the shape; the integer path below is untouched when nothing is Text.
2510    if let Some(t) = text_shape(i, op, constants, ft)? {
2511        let concat_oc =
2512            crate::vm::binisa::opcode_of_name("Concat").expect("Concat is a registered opcode");
2513        let fmt_oc = crate::vm::binisa::opcode_of_name("FormatValue")
2514            .expect("FormatValue is a registered opcode");
2515        return Ok(match t {
2516            TextLower::Const { dst, idx } => {
2517                // TextConst: word0 = [216][dst][len:16], word1 = the pool word-offset (imem index of c0).
2518                let (off, len) = pool_of(idx)?;
2519                if len > 0xffff {
2520                    return Err(format!(
2521                        "instruction {i}: Text constant length {len} exceeds the 16-bit field"
2522                    ));
2523                }
2524                vec![
2525                    ((TEXTCONST as u32) << 24) | (reg(dst)? << 16) | (len & 0xffff),
2526                    off,
2527                ]
2528            }
2529            TextLower::Concat { dst, lhs, rhs, fmt } => match fmt {
2530                // Text ++ Text — a bare Concat of the two char-lists.
2531                None => vec![w(concat_oc, reg(dst)?, reg(lhs)?, reg(rhs)?)],
2532                // A non-Text operand → FormatValue it into `scratch` (its decimal char-list), then Concat with
2533                // the scratch substituted for that operand. The scratch is a program-wide-unused register, dead
2534                // the instant the Concat consumes it, so this needs no liveness dance.
2535                Some(nt) => {
2536                    let (a, b) = if nt == lhs {
2537                        (scratch, rhs)
2538                    } else {
2539                        (lhs, scratch)
2540                    };
2541                    vec![
2542                        w(fmt_oc, reg(scratch)?, reg(nt)?, 0),
2543                        w(concat_oc, reg(dst)?, reg(a)?, reg(b)?),
2544                    ]
2545                }
2546            },
2547            TextLower::Show { src } => vec![w(SHOWTEXT, 0, reg(src)?, 0)],
2548            TextLower::CharAt {
2549                dst,
2550                collection,
2551                index,
2552            } => {
2553                vec![w(TEXTCHARAT, reg(dst)?, reg(collection)?, reg(index)?)]
2554            }
2555            TextLower::Compare { dst, lhs, rhs, eq } => {
2556                vec![w(
2557                    if eq { TEXTEQ } else { TEXTNE },
2558                    reg(dst)?,
2559                    reg(lhs)?,
2560                    reg(rhs)?,
2561                )]
2562            }
2563            // A Text `contains` → the substring char-scanner (op 234): [234][dst][hay=ra][needle=rb].
2564            TextLower::Contains { dst, hay, needle } => {
2565                vec![w(TEXTCONTAINS, reg(dst)?, reg(hay)?, reg(needle)?)]
2566            }
2567        });
2568    }
2569    // A float LITERAL: 3-word FConst carrying the exact f64 bits (the constant kind is ground truth).
2570    if let Op::LoadConst { dst, idx } = op {
2571        if let Some(Constant::Float(f)) = constants.get(*idx as usize) {
2572            let bits = f.to_bits();
2573            return Ok(vec![
2574                w(FCONST, reg(*dst)?, 0, 0),
2575                (bits & 0xffff_ffff) as u32,
2576                (bits >> 32) as u32,
2577            ]);
2578        }
2579    }
2580    let oc = opcode(op);
2581    let three = |dst: Reg, a: Reg, b: Reg| -> Result<Vec<u32>, String> {
2582        Ok(vec![w(oc, reg(dst)?, reg(a)?, reg(b)?)])
2583    };
2584    Ok(match op {
2585        // ── arithmetic / comparison / bitwise — 3-register [op][dst][lhs][rhs] ──
2586        Op::Add { dst, lhs, rhs }
2587        | Op::Sub { dst, lhs, rhs }
2588        | Op::Mul { dst, lhs, rhs }
2589        | Op::Div { dst, lhs, rhs }
2590        | Op::Mod { dst, lhs, rhs }
2591        | Op::ExactDiv { dst, lhs, rhs }
2592        | Op::FloorDiv { dst, lhs, rhs }
2593        | Op::Lt { dst, lhs, rhs }
2594        | Op::Gt { dst, lhs, rhs }
2595        | Op::LtEq { dst, lhs, rhs }
2596        | Op::GtEq { dst, lhs, rhs }
2597        | Op::Eq { dst, lhs, rhs }
2598        | Op::NotEq { dst, lhs, rhs }
2599        | Op::BitXor { dst, lhs, rhs }
2600        | Op::BitAnd { dst, lhs, rhs }
2601        | Op::BitOr { dst, lhs, rhs }
2602        | Op::Shl { dst, lhs, rhs }
2603        | Op::Shr { dst, lhs, rhs } => three(*dst, *lhs, *rhs)?,
2604        Op::Not { dst, src } => three(*dst, *src, 0)?,
2605        // `is approximately` (ApproxEq, opcode 30) — the tolerant numeric compare. On INTEGER operands
2606        // (the value plane) `≈` collapses to exact `==` (any two distinct integers differ by ≥ 1, far
2607        // above the 1e-9 relative floor), which is what the ALU's op-30 arm computes (`a == b`), so a
2608        // pure-int ApproxEq rides the ordinary 3-register ALU word. A FLOAT ApproxEq is DECOMPOSED into the
2609        // primitive float ops by `float_shape` above (the isclose sequence), so it never reaches here.
2610        Op::ApproxEq { dst, lhs, rhs } => three(*dst, *lhs, *rhs)?,
2611        // `Swap a and b` (RevSwap, opcode 4) — the reversible register exchange. The model's op-4 arm
2612        // (`regs.swap(dst, ra)`) swaps the two locals in place; the encoder emits [4][a][b][0].
2613        Op::RevSwap { a, b } => vec![w(oc, reg(*a)?, reg(*b)?, 0)],
2614        // `Set x to x + y` — in-place add lowers onto the Add datapath (lhs = dst).
2615        Op::AddAssign { dst, src } => {
2616            let add = crate::vm::binisa::opcode_of_name("Add").expect("Add opcode");
2617            vec![w(add, reg(*dst)?, reg(*dst)?, reg(*src)?)]
2618        }
2619        Op::Move { dst, src } => three(*dst, *src, 0)?,
2620        // ── constants ──
2621        Op::LoadConst { dst, idx } => {
2622            let c = constants
2623                .get(*idx as usize)
2624                .ok_or_else(|| format!("LoadConst references missing constant #{idx}"))?;
2625            let v = const_i64(c)?;
2626            if !fits_i32(v) {
2627                // A constant wider than i32 (PRNG moduli 2³¹ / 2³², full-range i64 literals) is held in a
2628                // 3-word LoadConstWide64 (op 222) at EVERY width — low word then high word, mirroring FConst's
2629                // two-immediate layout. The op-222 decode retires the full i64 irrespective of datapath width,
2630                // and the value plane auto-promotes to BigInt on overflow, so width 32 reproduces the constant
2631                // EXACTLY (width is a fast-path/area knob, not a correctness switch — BigInt handles exactness).
2632                let bits = v as u64;
2633                vec![
2634                    (u32::from(LOADCONSTWIDE64) << 24) | (reg(*dst)? << 16),
2635                    (bits & 0xffff_ffff) as u32,
2636                    (bits >> 32) as u32,
2637                ]
2638            } else {
2639                // An i32-fitting constant uses the inline forms EXACTLY: narrow (16-bit, opcode 5) or the
2640                // 2-word 32-bit immediate (opcode 200). No truncation happens here — the over-i32 case is
2641                // handled above by op 222 at every width, so the full core never wraps a constant.
2642                let vi = v as i32;
2643                if narrow(vi) {
2644                    vec![(oc as u32) << 24 | (reg(*dst)? << 16) | ((vi as u32) & 0xffff)]
2645                } else {
2646                    vec![(200u32 << 24) | (reg(*dst)? << 16), vi as u32]
2647                }
2648            }
2649        }
2650        // ── control flow — 16-bit imem targets SPLIT low-byte-first (scalar-compatible) / 32-bit Call entry ──
2651        // Jump: [op][t_lo:8][t_hi:8][0]; JumpIf: [op][t_lo:8][cond:8][t_hi:8].
2652        Op::Jump { target } => {
2653            let t = imem_of(*target)?;
2654            vec![((oc as u32) << 24) | ((t & 0xff) << 16) | (((t >> 8) & 0xff) << 8)]
2655        }
2656        Op::JumpIfFalse { cond, target } | Op::JumpIfTrue { cond, target } => {
2657            let t = imem_of(*target)?;
2658            vec![((oc as u32) << 24) | ((t & 0xff) << 16) | (reg(*cond)? << 8) | ((t >> 8) & 0xff)]
2659        }
2660        Op::Call {
2661            dst,
2662            func,
2663            args_start,
2664            arg_count,
2665        } => {
2666            if *arg_count > 255 {
2667                return Err(format!("Call arg_count {arg_count} exceeds 255"));
2668            }
2669            reg(args_start.saturating_add(arg_count.saturating_sub(1)))?; // the top arg register must fit too
2670            vec![
2671                ((oc as u32) << 24)
2672                    | (reg(*dst)? << 16)
2673                    | (reg(*args_start)? << 8)
2674                    | (*arg_count as u32 & 0xff),
2675                entry_of(*func)?,
2676            ]
2677        }
2678        Op::Return { src } => vec![w(oc, 0, reg(*src)?, 0)],
2679        Op::ReturnNothing => vec![(oc as u32) << 24],
2680        Op::Show { src } => vec![w(oc, 0, reg(*src)?, 0)],
2681        Op::Halt => vec![(oc as u32) << 24],
2682        // `args()` — argv, program name first, on every tier. A chip has no host argv, so its argument
2683        // vector is the program name alone, `["logos"]` — exactly the VM's with no host argv
2684        // (`semantics::argv_with_program_name`). Built from ops the chip already runs: a fresh list, the
2685        // pooled Text `logos` into the scratch register, and one push. So `length of args()` is 1 on chip
2686        // and VM alike.
2687        Op::Args { dst } => {
2688            let new_empty =
2689                crate::vm::binisa::opcode_of_name("NewEmptyList").expect("NewEmptyList opcode");
2690            let push = crate::vm::binisa::opcode_of_name("ListPush").expect("ListPush opcode");
2691            let (off, len) = pool_of(ARGV0_POOL_KEY)?;
2692            vec![
2693                w(new_empty, reg(*dst)?, 0, 0),
2694                ((TEXTCONST as u32) << 24) | (reg(scratch)? << 16) | (len & 0xffff),
2695                off,
2696                w(push, reg(*dst)?, reg(scratch)?, 0),
2697            ]
2698        }
2699        // `FailWith` — the unrecoverable trap (op 91): the model halts (error strobe raised), matching the
2700        // VM's error. It sits at the exhaustive-`Inspect` fall-through (a non-`Otherwise` match emits it after
2701        // the arms), UNREACHABLE for a well-typed scrutinee — but it must lower so the arm chain assembles.
2702        Op::FailWith { .. } => vec![(oc as u32) << 24],
2703        // ── structured heap (lists / tuples / ranges / sets / maps) ──
2704        Op::NewList { dst, start, count } | Op::NewTuple { dst, start, count } => {
2705            if *count > 255 {
2706                return Err(format!("NewList/NewTuple count {count} exceeds 255"));
2707            }
2708            // A `Seq of Float` literal (`[1.5, 2.5, …]`) built by a plain `NewList` would lay its structure on
2709            // the INT heap and leave the parallel f64 payload plane (`fheap`) all-zero — so a later `item i of
2710            // xs` (FIndex) reads 0.0, a SILENT divergence from the VM. Instead build it through the float plane:
2711            // `NewEmptyList` then one `FListPush` per element (each writes the int structure AND `fheap[slot] =
2712            // fregs[value]`), exactly the sequence a float `Push` loop emits (which is already proven ≡ VM). The
2713            // element registers are proven float scalars (`is_float`), so their f64 values live in `fregs`.
2714            if (0..*count).any(|k| ft.is_float(i, start.saturating_add(k))) {
2715                if !(0..*count).all(|k| ft.is_float(i, start.saturating_add(k))) {
2716                    return Err(format!(
2717                        "instruction {i}: `NewList` with a MIX of float and non-float elements has no chip \
2718                         lowering (the float-payload plane is uniform per seq)"
2719                    ));
2720                }
2721                reg(start.saturating_add(count.saturating_sub(1)))?;
2722                let new_empty =
2723                    crate::vm::binisa::opcode_of_name("NewEmptyList").expect("NewEmptyList opcode");
2724                let mut words = Vec::with_capacity(1 + *count as usize);
2725                words.push(w(new_empty, reg(*dst)?, 0, 0));
2726                for k in 0..*count {
2727                    words.push(w(FLIST_PUSH, reg(*dst)?, reg(start.saturating_add(k))?, 0));
2728                }
2729                return Ok(words);
2730            }
2731            reg(start.saturating_add(count.saturating_sub(1)))?;
2732            vec![w(oc, reg(*dst)?, reg(*start)?, *count as u32 & 0xff)]
2733        }
2734        Op::NewEmptyList { dst } | Op::NewEmptyListI32 { dst } | Op::NewEmptySet { dst } => {
2735            vec![w(oc, reg(*dst)?, 0, 0)]
2736        }
2737        // The physical fabric reserves a compile-time-sized collection block. Keep the semantic
2738        // capacity operand in bytecode/traces, but lower allocation to the existing physical
2739        // constructor: runtime-sized reserve has no representable field in this instruction word.
2740        Op::NewEmptyListWithCapacity { dst, .. } => {
2741            let physical =
2742                crate::vm::binisa::opcode_of_name("NewEmptyList").expect("NewEmptyList opcode");
2743            vec![w(physical, reg(*dst)?, 0, 0)]
2744        }
2745        Op::NewEmptyListFixed { dst, .. } => {
2746            let physical =
2747                crate::vm::binisa::opcode_of_name("NewEmptyList").expect("NewEmptyList opcode");
2748            vec![w(physical, reg(*dst)?, 0, 0)]
2749        }
2750        // `a new Map of …` — an empty MAP_TAG-tagged heap block (opcode 52). Its Index/SetIndex lower to the
2751        // INT-KEY key-scan ops (223/224) when the key is a non-Text integer, or stay on the string-key ops
2752        // (57/59, `key_eq`) when the key is a Text handle — the `is_map`/`is_text` routing below.
2753        Op::NewEmptyMap { dst } => vec![w(oc, reg(*dst)?, 0, 0)],
2754        Op::NewEmptyMapWithCapacity { dst, .. } => {
2755            let physical =
2756                crate::vm::binisa::opcode_of_name("NewEmptyMap").expect("NewEmptyMap opcode");
2757            vec![w(physical, reg(*dst)?, 0, 0)]
2758        }
2759        Op::NewRange { dst, start, end } => three(*dst, *start, *end)?,
2760        Op::ListPush { list, value } => three(*list, *value, 0)?,
2761        Op::SetAdd { set, value } => three(*set, *value, 0)?,
2762        Op::ListPop { list, dst } => three(*dst, *list, 0)?,
2763        // `copy of xs` — an independent deep heap copy (opcode 66); the value-semantics fresh binding.
2764        Op::DeepClone { dst, src } => three(*dst, *src, 0)?,
2765        // `a followed by b` — a fresh sequence `lhs ++ rhs` (opcode 33); Text `+` uses Concat (32) via
2766        // `text_shape` above, so this arm is the Seq-of-Int/Seq concatenation path.
2767        Op::SeqConcat { dst, lhs, rhs } => three(*dst, *lhs, *rhs)?,
2768        // `a union b` / `a intersection b` — a FRESH set (opcodes 68/69): dedup'd union / membership
2769        // intersection over the two source blocks. Three-register, single-word (the model builds the
2770        // result block by scanning, byte-identical to the VM's `set_union`/`set_intersection`).
2771        Op::UnionOp { dst, lhs, rhs } | Op::IntersectOp { dst, lhs, rhs } => {
2772            three(*dst, *lhs, *rhs)?
2773        }
2774        // `Remove v from s` (opcode 56) — swap-out by value equality, shifting the tail down. In-place
2775        // mutation of the collection register; the model finds the first `== v` slot and compacts.
2776        Op::RemoveFrom { collection, value } => three(*collection, *value, 0)?,
2777        // `items i through j of xs` (SliceOp, op 65) — a fresh 1-based-inclusive subsequence. The chip's
2778        // slice unit (RTL `soft_core.v` op 65) takes the bounds as IMMEDIATE b-byte nibbles (`start=b[7:4]`,
2779        // `end=b[3:0]`), so both must be provably small integer constants (`1..=15`), recovered by
2780        // `slice_bound_folds`. A runtime / out-of-range bound has no chip form — rejected loudly (a genuine
2781        // hardware limit, honestly declined; the op still lowers for the common constant-bound case).
2782        Op::SliceOp {
2783            dst, collection, ..
2784        } => {
2785            let (s, e) = slice_fold(i).ok_or_else(|| {
2786                format!(
2787                    "instruction {i}: `SliceOp` bounds are not provably small integer constants (1..=15) — the \
2788                     chip's slice unit takes immediate nibble bounds, so a runtime or large slice bound has no \
2789                     full-core chip lowering yet"
2790                )
2791            })?;
2792            vec![w(
2793                oc,
2794                reg(*dst)?,
2795                reg(*collection)?,
2796                (u32::from(s) << 4) | u32::from(e),
2797            )]
2798        }
2799        // `Push x to obj's field` (ListPushField, opcode 47) — append to a struct-field seq in place (COW
2800        // if the field's list is not on top of the heap, then repoint the field). word: [47][obj=dst][
2801        // src=ra][field=rb]. The field name resolves to its usage-derived positional slot via the struct
2802        // oracle, exactly as `StructInsert`/`GetField`.
2803        Op::ListPushField { obj, field, src } => {
2804            let (slot, _n) = resolve_field(i, constants, st, *obj, *field)?;
2805            if slot > 255 {
2806                return Err(format!(
2807                    "instruction {i}: `ListPushField` field slot {slot} exceeds the 8-bit field"
2808                ));
2809            }
2810            vec![w(oc, reg(*obj)?, reg(*src)?, slot as u32)]
2811        }
2812        Op::Length { dst, collection } => three(*dst, *collection, 0)?,
2813        Op::Contains {
2814            dst,
2815            collection,
2816            value,
2817        } => three(*dst, *collection, *value)?,
2818        Op::Index {
2819            dst,
2820            collection,
2821            index,
2822        }
2823        | Op::IndexUnchecked {
2824            dst,
2825            collection,
2826            index,
2827        } => {
2828            // A map read (`item k of m`) key-scans: the INT-KEY op (223) when the key is a non-Text integer,
2829            // else the string-key op (59, via `three`, which `key_eq`-scans). A plain LIST index stays positional
2830            // (59). The `is_map`/`is_text` oracle at this code index picks the family — the run loop then reads
2831            // the key cell accordingly.
2832            if ft.is_map(i, *collection) && !ft.is_text(i, *index) {
2833                vec![w(MAPINDEXINT, reg(*dst)?, reg(*collection)?, reg(*index)?)]
2834            } else {
2835                three(*dst, *collection, *index)?
2836            }
2837        }
2838        Op::SetIndex {
2839            collection,
2840            index,
2841            value,
2842        }
2843        | Op::SetIndexUnchecked {
2844            collection,
2845            index,
2846            value,
2847        } => {
2848            // A map write (`Set item k of m to v`) key-scans: the INT-KEY op (224) for a non-Text integer key,
2849            // else the string-key op (57). A plain LIST set stays positional (57).
2850            if ft.is_map(i, *collection) && !ft.is_text(i, *index) {
2851                vec![w(
2852                    MAPSETINDEXINT,
2853                    reg(*collection)?,
2854                    reg(*index)?,
2855                    reg(*value)?,
2856                )]
2857            } else {
2858                three(*collection, *index, *value)?
2859            }
2860        }
2861        // ── snapshot iteration (Repeat / for-each) ──
2862        Op::IterPrepare { iterable } => vec![w(oc, 0, reg(*iterable)?, 0)],
2863        // IterNext: [op][dst:8][exit_lo:8][exit_hi:8]. A `Repeat for v in (Seq of Float)` iterates the float
2864        // payload plane — op 231 `FIterNext` reads `fheap[ptr+cursor]` into `fregs[dst]` (so the loop variable
2865        // is a float scalar); an ordinary iteration keeps op 86 reading the int structure into `regs[dst]`.
2866        Op::IterNext { dst, exit } => {
2867            let e = imem_of(*exit)?;
2868            let iter_oc = if is_float_iter(i) { FITER_NEXT } else { oc };
2869            vec![w(iter_oc, reg(*dst)?, e & 0xff, (e >> 8) & 0xff)]
2870        }
2871        Op::IterPop => vec![(oc as u32) << 24],
2872        // ── globals (Main bindings referenced inside functions) ──
2873        Op::GlobalGet { dst, idx } => {
2874            if *idx > 255 {
2875                return Err(format!("GlobalGet idx {idx} exceeds 255"));
2876            }
2877            vec![w(oc, reg(*dst)?, *idx as u32 & 0xff, 0)]
2878        }
2879        Op::GlobalSet { idx, src } => {
2880            if *idx > 255 {
2881                return Err(format!("GlobalSet idx {idx} exceeds 255"));
2882            }
2883            vec![w(oc, *idx as u32 & 0xff, reg(*src)?, 0)]
2884        }
2885        // ── value-transparent metadata — no-ops on the chip (as in run_soft_core / the VM) ──
2886        Op::EnsureOwned { .. } | Op::RegionBoundsGuard { .. } => vec![(oc as u32) << 24],
2887        // A standalone `FormatValue` — the `Show "{x:.N}"` render. An INTEGER operand → op 64 (the decimal
2888        // char-list of regs[src]); a FLOAT operand → op 225 `FormatValueFloat` (fregs[src] to `.N` places, the
2889        // precision in the b-byte), matching the VM's `apply_format_spec` exactly. Both build a Text char-list
2890        // a downstream `ShowText`/`Concat` consumes. `oc` is `FormatValue`'s registered opcode (== 64).
2891        Op::FormatValue {
2892            dst,
2893            src,
2894            spec,
2895            debug_prefix,
2896        } => {
2897            if ft.is_float(i, *src) {
2898                let prec = float_format_precision(*spec, *debug_prefix, constants).ok_or_else(|| {
2899                    format!(
2900                        "instruction {i}: float `FormatValue` needs a `.N` precision spec — a bare `{{f}}` / \
2901                         currency / alignment on a float has no full-core chip lowering yet"
2902                    )
2903                })?;
2904                vec![w(
2905                    FORMATVALUE_FLOAT,
2906                    reg(*dst)?,
2907                    reg(*src)?,
2908                    u32::from(prec),
2909                )]
2910            } else {
2911                // Integer FormatValue (op 64) — the decimal string of regs[src]. Any spec/debug on an INT is
2912                // dropped (the chip has only the bare-decimal integer render); no benchmark exercises that.
2913                vec![w(oc, reg(*dst)?, reg(*src)?, 0)]
2914            }
2915        }
2916        // ── STRUCTS (model ops 74/75/76) — a heap record `[nfields, f0, …]`, fields addressed POSITIONALLY.
2917        // The bytecode names a field by const; the struct oracle's usage-derived layout maps (struct type,
2918        // field name) → its slot. `NewStruct` needs the struct's field COUNT (the slots to allocate).
2919        Op::NewStruct { dst, type_name } => {
2920            // The struct type is the ground-truth `type_name` const — resolve it directly (the register
2921            // plane types the dst only on the op's EXIT, not at the NewStruct itself). `field_count` is the
2922            // usage-derived slot count for that type.
2923            let name = match constants.get(*type_name as usize) {
2924                Some(Constant::Text(s)) => s.as_str(),
2925                _ => {
2926                    return Err(format!(
2927                        "instruction {i}: `NewStruct` type name const #{type_name} is not Text"
2928                    ))
2929                }
2930            };
2931            let ty = st.index_of_name(name).ok_or_else(|| {
2932                format!("instruction {i}: `NewStruct` of `{name}` — no usage-derived field layout for this struct type")
2933            })?;
2934            let nfields = st.field_count(ty);
2935            if nfields > 255 {
2936                return Err(format!(
2937                    "instruction {i}: struct has {nfields} fields (exceeds the 8-bit count)"
2938                ));
2939            }
2940            // op 74: [74][dst][ra unused][num_fields=rb]
2941            vec![w(oc, reg(*dst)?, 0, nfields as u32)]
2942        }
2943        Op::StructInsert { obj, field, value } => {
2944            let (slot, _n) = resolve_field(i, constants, st, *obj, *field)?;
2945            if slot > 255 {
2946                return Err(format!(
2947                    "instruction {i}: struct field slot {slot} exceeds the 8-bit field"
2948                ));
2949            }
2950            // op 75: [75][obj=dst][value=ra][field=rb]
2951            vec![w(oc, reg(*obj)?, reg(*value)?, slot as u32)]
2952        }
2953        Op::GetField { dst, obj, field } => {
2954            let (slot, _n) = resolve_field(i, constants, st, *obj, *field)?;
2955            if slot > 255 {
2956                return Err(format!(
2957                    "instruction {i}: struct field slot {slot} exceeds the 8-bit field"
2958                ));
2959            }
2960            // op 76: [76][dst][obj=ra][field=rb]
2961            vec![w(oc, reg(*dst)?, reg(*obj)?, slot as u32)]
2962        }
2963        // ── INDUCTIVES + MATCH (model ops 77/78/79) — a heap record `[tag, payload0, …]`. The variant TAG is
2964        // the constructor-name const index (deduped, so `NewInductive`'s ctor and every `TestArm`'s variant on
2965        // that name agree — exactly the VM's name equality); `BindArm` extracts payload position `index`.
2966        Op::NewInductive {
2967            dst,
2968            ctor,
2969            args_start,
2970            count,
2971            ..
2972        } => {
2973            let tag = inductive_tag(i, *ctor)?;
2974            if *count > 15 {
2975                return Err(format!(
2976                    "instruction {i}: `NewInductive` payload count {count} exceeds the 4-bit field"
2977                ));
2978            }
2979            let asr = reg(*args_start)?;
2980            if asr > 15 {
2981                return Err(format!(
2982                    "instruction {i}: `NewInductive` args_start r{args_start} exceeds the 4-bit b[7:4] field"
2983                ));
2984            }
2985            reg(args_start.saturating_add(count.saturating_sub(1)))?; // the top payload register must fit too
2986                                                                      // op 77: [77][dst][ctor=a-byte][b: args_start(hi nibble) | count(lo nibble)]
2987            let b = (asr << 4) | (*count as u32 & 0xf);
2988            vec![w(oc, reg(*dst)?, tag, b)]
2989        }
2990        Op::TestArm {
2991            dst,
2992            target,
2993            variant,
2994        } => {
2995            let tag = inductive_tag(i, *variant)?;
2996            // op 78: [78][dst][target=ra][variant=b-byte]
2997            vec![w(oc, reg(*dst)?, reg(*target)?, tag)]
2998        }
2999        Op::BindArm {
3000            dst, target, index, ..
3001        } => {
3002            if *index > 255 {
3003                return Err(format!(
3004                    "instruction {i}: `BindArm` index {index} exceeds the 8-bit field"
3005                ));
3006            }
3007            // op 79: [79][dst][target=ra][index=b-byte]
3008            vec![w(oc, reg(*dst)?, reg(*target)?, *index as u32 & 0xff)]
3009        }
3010        // ── CRDTs, single-replica projection (model ops 80/81/84/82/83/113). A `Counter`/`Tally` lowers to a
3011        // plain struct; `CrdtBump` accumulates a field (`+= amount`, negated for `Decrease`), `CrdtMerge`
3012        // sum-joins field-wise, `CrdtJoin` per-replica MAX-joins, `CrdtResolve` overwrites (LWW). The field
3013        // index is the usage-derived slot; it must fit the 4-bit `b[3:0]` (`CrdtBump`) / 8-bit (`CrdtResolve`).
3014        Op::CrdtBump {
3015            obj,
3016            field,
3017            amount,
3018            negate,
3019        } => {
3020            let (slot, _n) = resolve_field(i, constants, st, *obj, *field)?;
3021            if slot > 15 {
3022                return Err(format!(
3023                    "instruction {i}: CRDT field slot {slot} exceeds the 4-bit b[3:0] field of `CrdtBump`"
3024                ));
3025            }
3026            // op 80: [80][obj=dst][amount=ra][b: negate(bit4) | field(bits 3:0)]
3027            let b = ((*negate as u32) << 4) | (slot as u32 & 0xf);
3028            vec![w(oc, reg(*obj)?, reg(*amount)?, b)]
3029        }
3030        // op 81: [81][target=dst][source=ra] — field-wise sum-join.
3031        Op::CrdtMerge { target, source } => vec![w(oc, reg(*target)?, reg(*source)?, 0)],
3032        // op 113: [113][target=dst][source=ra] — per-replica MAX join.
3033        Op::CrdtJoin { target, source } => vec![w(oc, reg(*target)?, reg(*source)?, 0)],
3034        // op 82: [82][dst] — a fresh empty CRDT collection (seq/set, single-replica projection = empty list).
3035        Op::NewCrdt { dst, .. } => vec![w(oc, reg(*dst)?, 0, 0)],
3036        // op 83: [83][seq=dst][value=ra] — RGA append (ordered list append, single-replica projection).
3037        Op::CrdtAppend { seq, value } => vec![w(oc, reg(*seq)?, reg(*value)?, 0)],
3038        Op::CrdtResolve { obj, field, value } => {
3039            let (slot, _n) = resolve_field(i, constants, st, *obj, *field)?;
3040            if slot > 255 {
3041                return Err(format!(
3042                    "instruction {i}: CRDT resolve field slot {slot} exceeds the 8-bit field"
3043                ));
3044            }
3045            // op 84: [84][obj=dst][value=ra][field=rb] — register last-write = field overwrite (LWW).
3046            vec![w(oc, reg(*obj)?, reg(*value)?, slot as u32)]
3047        }
3048        // ── NON-CAPTURING CLOSURES (model ops 40/39). `MakeClosure` stores a closure value = `[entry]` on the
3049        // heap; `CallValue` does an indirect Call through it. Only the NON-capturing case lowers: a closure
3050        // that captures locals/globals needs a capture frame the single-word op 40 (`[entry]` only) cannot
3051        // carry, so a capturing closure is rejected loudly (its `captures` list is non-empty).
3052        Op::MakeClosure { dst, func, .. } => {
3053            if captures_nonempty(*func) {
3054                return Err(format!(
3055                    "instruction {i}: capturing closure has no full-core chip lowering (the single-word \
3056                     MakeClosure carries only the entry, not a capture frame)"
3057                ));
3058            }
3059            let entry = entry_of(*func)?;
3060            if entry > 255 {
3061                return Err(format!(
3062                    "instruction {i}: closure entry imem index {entry} exceeds the 8-bit a-byte field of `MakeClosure`"
3063                ));
3064            }
3065            // op 40: [40][dst][func=entry a-byte][0] — non-capturing closure = [entry].
3066            vec![w(oc, reg(*dst)?, entry, 0)]
3067        }
3068        Op::CallValue {
3069            dst,
3070            callee,
3071            args_start,
3072            arg_count,
3073            ..
3074        } => {
3075            // op 39: [39][dst=result][callee=ra][b: args_start(hi nibble) | arg_count(lo nibble)]. The model
3076            // clones the caller frame, window-copies R[args_start..+arg_count] → R[0..] (so a compiled
3077            // closure body reads its param at its base r0), then runs the static body and returns.
3078            let asr = reg(*args_start)?;
3079            if asr > 15 {
3080                return Err(format!(
3081                    "instruction {i}: `CallValue` args_start r{args_start} exceeds the 4-bit b[7:4] field"
3082                ));
3083            }
3084            if *arg_count > 15 {
3085                return Err(format!(
3086                    "instruction {i}: `CallValue` arg_count {arg_count} exceeds the 4-bit b[3:0] field"
3087                ));
3088            }
3089            reg(args_start.saturating_add(arg_count.saturating_sub(1)))?; // top arg register must fit too
3090            let b = (asr << 4) | (*arg_count as u32 & 0xf);
3091            vec![w(oc, reg(*dst)?, reg(*callee)?, b)]
3092        }
3093        // ── DETERMINISTIC CONCURRENCY (model ops 92/93/94, Spawn 98). The single-node deterministic runtime
3094        // runs Launch SYNCHRONOUSLY and channels FIFO (the offline semantics proven for the WASM AOT backend);
3095        // the model mirrors it. `ChanNew` = a fresh ring channel; `ChanSend`/`ChanRecv` enqueue/dequeue FIFO.
3096        Op::ChanNew { dst, .. } => vec![w(oc, reg(*dst)?, 0, 0)],
3097        // op 93: [93][chan=dst][val=ra] — enqueue.
3098        Op::ChanSend { chan, val } => vec![w(oc, reg(*chan)?, reg(*val)?, 0)],
3099        // op 94: [94][dst][chan=ra] — dequeue (FIFO).
3100        Op::ChanRecv { dst, chan } => vec![w(oc, reg(*dst)?, reg(*chan)?, 0)],
3101        // op 98: [98][entry=dst byte][args_start=ra][arg_count=rb] — deterministic sync launch. The model
3102        // arg-copies R[args_start..]→R[0..] into a fresh frame (like Call) then runs the task to completion,
3103        // returning to Main via the launched body's `ReturnNothing` — so the channel side-effects persist.
3104        Op::Spawn {
3105            func,
3106            args_start,
3107            arg_count,
3108        } => {
3109            if *arg_count > 255 {
3110                return Err(format!(
3111                    "instruction {i}: `Spawn` arg_count {arg_count} exceeds 255"
3112                ));
3113            }
3114            reg(args_start.saturating_add(arg_count.saturating_sub(1)))?;
3115            let entry = entry_of(*func)?;
3116            if entry > 255 {
3117                return Err(format!(
3118                    "instruction {i}: `Spawn` entry imem index {entry} exceeds the 8-bit entry field"
3119                ));
3120            }
3121            vec![w(oc, entry, reg(*args_start)?, *arg_count as u32 & 0xff)]
3122        }
3123        // ── INTEGER POWER (model + RTL op 18). `a ** b` for a non-negative integer exponent: the chip's
3124        // sequential-multiply power unit (a stall-based FSM in `soft_core.v`, mirroring the shared divider)
3125        // computes `acc = 1; repeat b times: acc *= a`. The model retires it directly (BigInt-exact via the
3126        // auto-promoting value-integer multiply) so it is byte-identical to the VM's `int_power`. op 18:
3127        // [18][dst][lhs=a][rhs=b].
3128        Op::Pow { dst, lhs, rhs } => vec![w(oc, reg(*dst)?, reg(*lhs)?, reg(*rhs)?)],
3129        // ── INTEGER-PLANE NUMERIC BUILTIN COPROCESSOR (op 38). The Word32 rotate/shift builtins and the
3130        // FLOAT-plane numeric builtins were already handled by `word_shape`/`float_shape` above; what reaches
3131        // here is a numeric builtin on the INTEGER value plane — `abs`/`min`/`max`/`count_ones` (ordinals
3132        // 5/7/8/9 from `builtin_chip_ordinal`). op 38: [38][dst][args_start=ra][ordinal=rb]; the model reads
3133        // `regs[args_start]` (and `regs[args_start+1]` for the 2-arg min/max) and computes the value-plane
3134        // result BigInt-exactly, byte-identical to `call_builtin` (Abs's i64::MIN→2^63 promotion, Min/Max's
3135        // `regval_cmp`, CountOnes's `(n as u64).count_ones()`). A builtin with no integer-plane ordinal (host
3136        // parse/format/temporal/uuid/…) falls through to the loud reject — it has no deterministic chip form.
3137        Op::CallBuiltin {
3138            dst,
3139            builtin,
3140            args_start,
3141            arg_count,
3142        } => {
3143            // The top argument register must fit the file (2-arg builtins read args_start+1).
3144            reg(args_start.saturating_add(arg_count.saturating_sub(1)))?;
3145            // FIRST the deterministic string / fill builtins (`chr`=233 / TEXT `contains`=234 / `repeatSeq`
3146            // =235 / `parseInt`=236 / `text_bytes`=237), each a fresh on-heap value or scanned scalar from
3147            // register operands — [op][dst][args_start][args_start+1] (`parseInt` and `text_bytes` read only
3148            // `args_start`, the text handle). The type gate inside `text_builtin_opcode` rejects an
3149            // unsupported operand shape loudly (a non-Text `contains`, a float/reference `repeatSeq` element,
3150            // a non-Text `parseInt` / `text_bytes`) rather than mis-lowering it.
3151            if let Some(t_oc) = text_builtin_opcode(i, *builtin, *args_start, ft)? {
3152                vec![w(
3153                    t_oc,
3154                    reg(*dst)?,
3155                    reg(*args_start)?,
3156                    reg(args_start.saturating_add(1))?,
3157                )]
3158            } else {
3159                // ELSE the integer-plane numeric coprocessor (op 38): abs/min/max/count_ones by ordinal. A
3160                // builtin with no chip form at all (host parse/format/temporal/uuid/…) hits the loud reject.
3161                let ord = builtin_chip_ordinal(*builtin).ok_or_else(|| {
3162                    format!(
3163                        "instruction {i}: builtin `{builtin:?}` has no full-core chip lowering yet — only \
3164                         abs/min/max/count_ones (integer plane), floor/ceil/round/abs/min/max (float plane), \
3165                         sqrt, chr/contains/repeatSeq/parseInt/text_bytes, and the Word32 vocabulary lower; \
3166                         `encode_op` rejects the rest loudly"
3167                    )
3168                })?;
3169                vec![w(oc, reg(*dst)?, reg(*args_start)?, u32::from(ord))]
3170            }
3171        }
3172        // ── TUPLE DESTRUCTURE (model + RTL op 71). `Repeat for (k, v) in …` spreads a heap tuple's payload
3173        // into `count` consecutive registers. op 71: [71][start=dst][src=ra][count=rb]; the model scatters
3174        // `heap[src+1+i]` → `regs[start+i]` for `i in 0..count`.
3175        Op::DestructureTuple { src, start, count } => {
3176            if *count > 255 {
3177                return Err(format!(
3178                    "instruction {i}: `DestructureTuple` count {count} exceeds the 8-bit field"
3179                ));
3180            }
3181            reg(start.saturating_add(count.saturating_sub(1)))?; // the top spread register must fit too
3182            vec![w(oc, reg(*start)?, reg(*src)?, *count as u32 & 0xff)]
3183        }
3184        // ── DETERMINISTIC SCHEDULER (model + RTL). On the single-node deterministic runtime a `Sleep` is a
3185        // pure no-op (op 88 advances the pc), and the non-blocking channel ops ride the SAME FIFO ring the
3186        // blocking `ChanSend`/`ChanRecv` (93/94) use — `ChanTrySend` (95) reports success in `dst`,
3187        // `ChanTryRecv` (96) delivers the value or `0`, `ChanClose` (97) sets a flag no receive path reads.
3188        Op::Sleep { duration } => vec![w(oc, 0, reg(*duration)?, 0)],
3189        // op 95: [95][success=dst][chan=ra][val=rb].
3190        Op::ChanTrySend { dst, chan, val } => vec![w(oc, reg(*dst)?, reg(*chan)?, reg(*val)?)],
3191        // op 96: [96][value=dst][chan=ra].
3192        Op::ChanTryRecv { dst, chan } => vec![w(oc, reg(*dst)?, reg(*chan)?, 0)],
3193        // op 97: [97][.][chan=ra] — output-transparent close.
3194        Op::ChanClose { chan } => vec![w(oc, 0, reg(*chan)?, 0)],
3195        // ── HANDLE-BOUND SYNC LAUNCH + JOIN (model + RTL). `Let h be Launch a task to f with …` runs `f`
3196        // SYNCHRONOUSLY (deterministic offline Launch) and binds a task handle. op 99: [99][handle=dst][
3197        // entry=a-byte][.] — the model saves the caller frame, sets the handle placeholder, and jumps to
3198        // the callee (sharing the caller's registers, so the task reads the channel handle the caller placed
3199        // at its base register). `TaskAwait` (100) is the instant join and `TaskAbort` (101, `Stop h`) a
3200        // no-op — the deterministic child already ran to completion at the launch point.
3201        Op::SpawnHandle { dst, func, .. } => {
3202            let entry = entry_of(*func)?;
3203            if entry > 255 {
3204                return Err(format!(
3205                    "instruction {i}: `SpawnHandle` entry imem index {entry} exceeds the 8-bit a-byte field"
3206                ));
3207            }
3208            vec![w(oc, reg(*dst)?, entry, 0)]
3209        }
3210        // op 100: [100][dst][handle=ra] — the sync child already completed, so the join passes through.
3211        Op::TaskAwait { dst, handle } => vec![w(oc, reg(*dst)?, reg(*handle)?, 0)],
3212        // op 101: [101][.][handle=ra] — abort a task that already ran to completion (a no-op).
3213        Op::TaskAbort { handle } => vec![w(oc, 0, reg(*handle)?, 0)],
3214        // ── SELECT / WAIT-ON-MULTIPLE (model + RTL). `Await the first of:` registers each arm, then waits.
3215        // op 102: [102][chan=dst][var=ra] — a recv arm; op 103: [103][.][ticks=ra] — a timeout arm (the tick
3216        // quantity is never observed: the timeout fires only when NO recv arm is ready); op 104: [104][
3217        // dst_arm] — fire the first ready recv (dequeue FIFO) else the timeout arm, writing the arm index.
3218        Op::SelectArmRecv { chan, var } => vec![w(oc, reg(*chan)?, reg(*var)?, 0)],
3219        Op::SelectArmTimeout { ticks } => vec![w(oc, 0, reg(*ticks)?, 0)],
3220        Op::SelectWait { dst_arm } => vec![w(oc, reg(*dst_arm)?, 0, 0)],
3221        // ── OFFLINE SINGLE-NODE NETWORKING (model + RTL). On the deterministic single-node chip there is no
3222        // peer or wire, so — exactly like the VM's documented offline model (tw == VM-net == AOT) — `Connect`
3223        // (105), `Listen` (106), `Stream` (108) and `Sync` (111) are no-ops, `NetMakePeer` (110) mints a
3224        // fresh loopback peer key, `NetSend` (107) publishes `(peer key, message)` to the loopback inbox, and
3225        // `NetAwait` (109) drains the inbox entry keyed by that peer — a local round-trip to itself.
3226        Op::NetConnect { url } => vec![w(oc, 0, reg(*url)?, 0)],
3227        Op::NetListen { topic } => vec![w(oc, 0, reg(*topic)?, 0)],
3228        // op 107: [107][.][to=ra][msg=rb] — publish to the loopback inbox.
3229        Op::NetSend { to, msg } => vec![w(oc, 0, reg(*to)?, reg(*msg)?)],
3230        // op 108: [108][.][to=ra][values=rb] — offline stream no-op.
3231        Op::NetStream { to, values } => vec![w(oc, 0, reg(*to)?, reg(*values)?)],
3232        // op 109: [109][dst][from=ra] — drain the loopback inbox entry keyed by the peer (a `stream` await
3233        // drains the same inbox, so the flag needs no distinct lowering on the single node).
3234        Op::NetAwait { dst, from, .. } => vec![w(oc, reg(*dst)?, reg(*from)?, 0)],
3235        // op 110: [110][dst][addr=ra] — a fresh offline peer key (the loopback identity).
3236        Op::NetMakePeer { dst, addr } => vec![w(oc, reg(*dst)?, reg(*addr)?, 0)],
3237        // op 111: [111][dst][topic=ra] — offline sync no-op (no peer to merge; the variable is unchanged).
3238        Op::NetSync { dst, topic } => vec![w(oc, reg(*dst)?, reg(*topic)?, 0)],
3239        other => {
3240            return Err(format!(
3241                "`{}` has no full-core chip lowering yet (outside the integer benchmark subset)",
3242                other.name()
3243            ))
3244        }
3245    })
3246}
3247
3248// ── Value-semantics isolation (P1.6) ────────────────────────────────────────────────────────────────
3249// LOGOS collections are value-typed; the VM preserves this with lazy Rc copy-on-write, but the chip's flat
3250// heap has no refcount, so an in-place mutator (`ListPush`/`SetIndex`/…) on a list that is SHARED with
3251// another binding would corrupt the alias (nqueens: `Let mutable next be queens; Push col to next`).
3252// A forward dataflow tracks, per register, whether it PROVABLY holds a uniquely-owned ("Fresh") collection;
3253// any mutator whose target is not provably Fresh gets an explicit `DeepClone` isolation emitted before it.
3254// This is always sound (a mutator target is always a collection, so cloning it can never corrupt a scalar),
3255// keeps list-building loops O(n) (a `new Seq` is Fresh → in-place push, no clone), and clones only true
3256// aliases. Function params seed as shared (arg-copy aliases the caller's handle).
3257
3258#[derive(Clone, Copy, PartialEq, Eq)]
3259enum Origin {
3260    Bottom,   // unreached
3261    Fresh,    // provably a uniquely-owned collection (safe to mutate in place)
3262    NotFresh, // possibly aliased / scalar / unknown
3263}
3264
3265/// What an op does to the origin lattice.
3266enum Act {
3267    MakeFresh(u16),  // allocates a fresh collection into dst
3268    Alias(u16, u16), // dst := src (both handles now shared)
3269    Mutate(u16),     // in-place mutation of a collection register (isolate if not Fresh)
3270    Write(u16),      // writes dst with a scalar / possibly-shared value → NotFresh
3271    Ctrl,            // no register write we track
3272}
3273
3274fn classify(op: &Op) -> Act {
3275    use Op::*;
3276    match op {
3277        NewList { dst, .. } | NewTuple { dst, .. } | NewEmptyList { dst } | NewEmptyListI32 { dst }
3278        | NewEmptyListWithCapacity { dst, .. } | NewEmptyListFixed { dst, .. }
3279        | NewEmptySet { dst } | NewEmptyMap { dst }
3280        | NewEmptyMapWithCapacity { dst, .. } | NewRange { dst, .. } | DeepClone { dst, .. }
3281        | SliceOp { dst, .. } | Concat { dst, .. } | SeqConcat { dst, .. } | UnionOp { dst, .. }
3282        | IntersectOp { dst, .. } | FormatValue { dst, .. } | Args { dst }
3283        // A freshly built struct / inductive / CRDT record is uniquely owned — so its OWN construction
3284        // inserts (`StructInsert`/`CrdtBump`) mutate in place with no spurious clone, and a `Let q be p`
3285        // aliases it (below), forcing an isolating clone only when an ALIAS is then mutated.
3286        | NewStruct { dst, .. } | NewInductive { dst, .. } | NewCrdt { dst, .. } => Act::MakeFresh(*dst),
3287        Move { dst, src } => Act::Alias(*dst, *src),
3288        ListPush { list, .. } => Act::Mutate(*list),
3289        SetAdd { set, .. } => Act::Mutate(*set),
3290        SetIndex { collection, .. } | SetIndexUnchecked { collection, .. } => Act::Mutate(*collection),
3291        RemoveFrom { collection, .. } => Act::Mutate(*collection),
3292        ListPop { list, .. } => Act::Mutate(*list),
3293        StructInsert { obj, .. } => Act::Mutate(*obj),
3294        // `Push x to obj's field` mutates the FIELD's LIST, not the struct record itself — and the model's
3295        // op-47 already COPY-ON-WRITEs that field list internally (repointing the field when it is shared),
3296        // so the correct value semantics hold WITHOUT isolating the struct. Isolating the struct here would
3297        // be WRONG: it would DeepClone the (compiler-Move-aliased) record and repoint the CLONE's field, so
3298        // the push would never reach the real `b` (a length-0 read). It is therefore control-transparent to
3299        // the struct-freshness lattice — the field-list COW is the whole isolation this op needs.
3300        ListPushField { .. } => Act::Ctrl,
3301        // Struct/CRDT field mutators: an in-place field write on a (possibly aliased) record.
3302        CrdtBump { obj, .. } | CrdtResolve { obj, .. } => Act::Mutate(*obj),
3303        CrdtMerge { target, .. } | CrdtJoin { target, .. } => Act::Mutate(*target),
3304        CrdtAppend { seq, .. } => Act::Mutate(*seq),
3305        // dst-writing ops whose result may be a shared nested collection (or a scalar) → NotFresh.
3306        Add { dst, .. } | Sub { dst, .. } | Mul { dst, .. } | Div { dst, .. } | Mod { dst, .. }
3307        | ExactDiv { dst, .. } | FloorDiv { dst, .. } | AddAssign { dst, .. }
3308        | Lt { dst, .. } | Gt { dst, .. } | LtEq { dst, .. } | GtEq { dst, .. } | Eq { dst, .. } | NotEq { dst, .. }
3309        | BitXor { dst, .. } | BitAnd { dst, .. } | BitOr { dst, .. } | Shl { dst, .. } | Shr { dst, .. } | Not { dst, .. }
3310        | LoadConst { dst, .. } | Length { dst, .. } | Contains { dst, .. }
3311        | Index { dst, .. } | IndexUnchecked { dst, .. } | IterNext { dst, .. } | GlobalGet { dst, .. } => Act::Write(*dst),
3312        _ => Act::Ctrl,
3313    }
3314}
3315
3316/// Code-index successors in the linear bytecode (Main then function bodies). A `Call` returns to `i+1`;
3317/// the callee body is reached from its own seeded entry, so it is a separate dataflow root.
3318fn successors(i: usize, op: &Op, n: usize, imem_target: impl Fn(usize) -> usize) -> Vec<usize> {
3319    let next = if i + 1 < n { vec![i + 1] } else { vec![] };
3320    match op {
3321        Op::Jump { target } => vec![imem_target(*target)],
3322        Op::JumpIfFalse { target, .. } | Op::JumpIfTrue { target, .. } => {
3323            let mut s = vec![imem_target(*target)];
3324            s.extend(next);
3325            s
3326        }
3327        Op::IterNext { exit, .. } => {
3328            let mut s = vec![imem_target(*exit)];
3329            s.extend(next);
3330            s
3331        }
3332        Op::Return { .. } | Op::ReturnNothing | Op::Halt | Op::FailWith { .. } => vec![],
3333        _ => next,
3334    }
3335}
3336
3337/// The set of mutator code indices whose target is NOT provably uniquely-owned, each mapped to the
3338/// register that must be `DeepClone`-isolated before the mutation. Fixpoint forward dataflow.
3339fn isolation_targets(program: &CompiledProgram) -> std::collections::HashMap<usize, u16> {
3340    let n = program.code.len();
3341    const R: usize = 256;
3342    // Targets already live in CODE-index space (Jump targets, entry_pc). Successors use them directly.
3343    let tgt = |ci: usize| ci;
3344
3345    let mut entry: Vec<[Origin; R]> = vec![[Origin::Bottom; R]; n];
3346    let mut work: std::collections::VecDeque<usize> = Default::default();
3347    let seed = |entry: &mut Vec<[Origin; R]>,
3348                work: &mut std::collections::VecDeque<usize>,
3349                pc: usize,
3350                params: u16| {
3351        if pc >= entry.len() {
3352            return;
3353        }
3354        let mut changed = false;
3355        for r in 0..(params as usize).min(R) {
3356            if entry[pc][r] != Origin::NotFresh {
3357                entry[pc][r] = Origin::NotFresh; // a param aliases the caller's handle
3358                changed = true;
3359            }
3360        }
3361        if entry[pc].iter().all(|&o| o == Origin::Bottom) || changed || params == 0 {
3362            work.push_back(pc);
3363        }
3364    };
3365    seed(&mut entry, &mut work, 0, 0); // Main
3366    for f in &program.functions {
3367        seed(&mut entry, &mut work, f.entry_pc, f.param_count);
3368    }
3369
3370    let merge = |a: Origin, b: Origin| match (a, b) {
3371        (Origin::Bottom, x) | (x, Origin::Bottom) => x,
3372        (Origin::Fresh, Origin::Fresh) => Origin::Fresh,
3373        _ => Origin::NotFresh,
3374    };
3375    while let Some(i) = work.pop_front() {
3376        let mut out = entry[i];
3377        match classify(&program.code[i]) {
3378            Act::MakeFresh(d) => out[d as usize & 255] = Origin::Fresh,
3379            Act::Alias(d, s) => {
3380                out[d as usize & 255] = Origin::NotFresh;
3381                out[s as usize & 255] = Origin::NotFresh;
3382            }
3383            Act::Mutate(t) => out[t as usize & 255] = Origin::Fresh, // unique after this op (isolated or already unique)
3384            Act::Write(d) => out[d as usize & 255] = Origin::NotFresh,
3385            Act::Ctrl => {}
3386        }
3387        for s in successors(i, &program.code[i], n, tgt) {
3388            if s >= n {
3389                continue;
3390            }
3391            let mut changed = false;
3392            for r in 0..R {
3393                let m = merge(entry[s][r], out[r]);
3394                if m != entry[s][r] {
3395                    entry[s][r] = m;
3396                    changed = true;
3397                }
3398            }
3399            if changed {
3400                work.push_back(s);
3401            }
3402        }
3403    }
3404
3405    // Final decision: a mutator whose target is not provably Fresh on entry needs isolation.
3406    let mut iso = std::collections::HashMap::new();
3407    for (i, op) in program.code.iter().enumerate() {
3408        if let Act::Mutate(t) = classify(op) {
3409            if entry[i][t as usize & 255] != Origin::Fresh {
3410                iso.insert(i, t);
3411            }
3412        }
3413    }
3414    iso
3415}
3416
3417/// The provably-constant integer value a register holds ENTERING code index `use_idx`, recovered by a
3418/// backward STRAIGHT-LINE scan: walk `use_idx-1 .. 0` for the first op that WRITES `r`. If it is a
3419/// `LoadConst { dst: r }` of an integer AND no code index strictly between that def and the use is a branch
3420/// TARGET (so no other value can flow in on a merged path), the constant is proven; a `Move`-through follows
3421/// the source register. Any other writer, a hit on a branch target, or a non-constant source → `None`
3422/// (conservatively unknown — the caller then rejects). Sound: it only trusts a value on a single-entry run.
3423fn const_reg_value(
3424    code: &[Op],
3425    constants: &[Constant],
3426    is_target: &dyn Fn(usize) -> bool,
3427    use_idx: usize,
3428    r: Reg,
3429    depth: u8,
3430) -> Option<i64> {
3431    if depth > 8 {
3432        return None; // bounded Move-chain following
3433    }
3434    let mut j = use_idx;
3435    while j > 0 {
3436        j -= 1;
3437        // A branch target between the def and the use means a different value could reach here — unknown.
3438        if is_target(j + 1) && (j + 1) < use_idx {
3439            return None;
3440        }
3441        if writes_reg(&code[j], r) {
3442            return match &code[j] {
3443                Op::LoadConst { dst, idx } if *dst == r => match constants.get(*idx as usize) {
3444                    Some(Constant::Int(v)) => Some(*v),
3445                    Some(Constant::Bool(b)) => Some(i64::from(*b)),
3446                    _ => None,
3447                },
3448                Op::Move { dst, src } if *dst == r => {
3449                    const_reg_value(code, constants, is_target, j, *src, depth + 1)
3450                }
3451                _ => None, // some other op defines `r` — not a known constant
3452            };
3453        }
3454    }
3455    None
3456}
3457
3458/// Does `op` WRITE register `r` (a conservative over-approximation — a windowed writer like
3459/// `DestructureTuple` counts if `r` is in its written range)? Used by the straight-line const scan to stop at
3460/// the nearest definition of a register.
3461fn writes_reg(op: &Op, r: Reg) -> bool {
3462    op_registers_written(op).contains(&r)
3463}
3464
3465/// The register(s) an op writes (its destinations). Conservative — used only by the SliceOp const-fold's
3466/// backward scan, so an over-broad set only makes the fold DECLINE (reject), never mis-fold.
3467fn op_registers_written(op: &Op) -> Vec<Reg> {
3468    use Op::*;
3469    match op {
3470        LoadConst { dst, .. } | Move { dst, .. } | Not { dst, .. } => vec![*dst],
3471        Add { dst, .. }
3472        | Sub { dst, .. }
3473        | Mul { dst, .. }
3474        | Div { dst, .. }
3475        | Mod { dst, .. }
3476        | ExactDiv { dst, .. }
3477        | FloorDiv { dst, .. }
3478        | Pow { dst, .. }
3479        | Lt { dst, .. }
3480        | Gt { dst, .. }
3481        | LtEq { dst, .. }
3482        | GtEq { dst, .. }
3483        | Eq { dst, .. }
3484        | NotEq { dst, .. }
3485        | ApproxEq { dst, .. }
3486        | BitXor { dst, .. }
3487        | BitAnd { dst, .. }
3488        | BitOr { dst, .. }
3489        | Shl { dst, .. }
3490        | Shr { dst, .. }
3491        | Index { dst, .. }
3492        | IndexUnchecked { dst, .. }
3493        | Length { dst, .. }
3494        | Contains { dst, .. }
3495        | GetField { dst, .. }
3496        | GlobalGet { dst, .. }
3497        | SliceOp { dst, .. }
3498        | UnionOp { dst, .. }
3499        | IntersectOp { dst, .. }
3500        | CallBuiltin { dst, .. } => vec![*dst],
3501        AddAssign { dst, .. } => vec![*dst],
3502        DestructureTuple { start, count, .. } => {
3503            (0..*count).map(|k| start.saturating_add(k)).collect()
3504        }
3505        Call { dst, .. } | CallValue { dst, .. } => vec![*dst],
3506        RevSwap { a, b } => vec![*a, *b],
3507        // Everything else either writes a collection register we don't const-track or writes nothing an
3508        // integer bound would read — return empty so the scan continues past it (never a mis-fold).
3509        _ => Vec::new(),
3510    }
3511}
3512
3513/// The set of code indices that are BRANCH TARGETS (Jump/branch destinations, IterNext exit targets, and
3514/// function entry points) — a code index a value can reach on a merged path. The SliceOp const-fold uses
3515/// this to refuse a fold across a control-flow merge (soundness). Targets are in CODE-index space.
3516fn branch_targets(program: &CompiledProgram) -> std::collections::HashSet<usize> {
3517    let mut t = std::collections::HashSet::new();
3518    for op in &program.code {
3519        match op {
3520            Op::Jump { target }
3521            | Op::JumpIfFalse { target, .. }
3522            | Op::JumpIfTrue { target, .. } => {
3523                t.insert(*target as usize);
3524            }
3525            Op::IterNext { exit, .. } => {
3526                t.insert(*exit as usize);
3527            }
3528            _ => {}
3529        }
3530    }
3531    for f in &program.functions {
3532        t.insert(f.entry_pc);
3533    }
3534    t
3535}
3536
3537/// For each `SliceOp` code index, the folded 1-based-inclusive bounds `(start, end)` IFF both bounds are
3538/// provably small integer constants (`1..=15`, fitting the chip word's b-byte nibbles) — the immediate form
3539/// the RTL slice unit (`soft_core.v` op 65) decodes. A runtime / out-of-nibble-range bound is ABSENT, so the
3540/// encoder rejects it loudly (a genuine chip limitation, honestly declined — the op still lowers for the
3541/// common `items 2 through 5 of xs` constant-bound case, exactly like `FormatValue`'s `.N`-only float form).
3542fn slice_bound_folds(program: &CompiledProgram) -> std::collections::HashMap<usize, (u8, u8)> {
3543    let targets = branch_targets(program);
3544    let is_target = |ci: usize| targets.contains(&ci);
3545    let mut folds = std::collections::HashMap::new();
3546    for (i, op) in program.code.iter().enumerate() {
3547        if let Op::SliceOp { start, end, .. } = op {
3548            let s = const_reg_value(&program.code, &program.constants, &is_target, i, *start, 0);
3549            let e = const_reg_value(&program.code, &program.constants, &is_target, i, *end, 0);
3550            if let (Some(s), Some(e)) = (s, e) {
3551                if (1..=15).contains(&s) && (1..=15).contains(&e) {
3552                    folds.insert(i, (s as u8, e as u8));
3553                }
3554            }
3555        }
3556    }
3557    folds
3558}
3559
3560/// Assemble a whole compiled program (from [`crate::vm::Compiler::compile_for_chip`]) into full soft-core
3561/// imem words. TWO-PASS: pass 1 lays out Main then every function body, computing each code index's imem
3562/// word offset (a `Call` and a wide `LoadConst` occupy two words); pass 2 emits the words, resolving every
3563/// Jump/branch/iter target and callee entry through that pc→imem map. `Err` if the program uses an op with
3564/// no chip lowering (floats, Text, closures, CRDTs, channels, …) or overflows an operand field.
3565///
3566/// The full-core image (`width` = 32). Because the value datapath auto-promotes to BigInt on overflow and
3567/// over-i32 constants are carried in full by op 222 at every width, the emitted words are IDENTICAL at 32 and
3568/// 64 — the width no longer changes the encoding. Pass an explicit width to [`encode_soft_core_full_w`].
3569pub fn encode_soft_core_full(program: &CompiledProgram) -> Result<Vec<u32>, String> {
3570    encode_soft_core_full_w(program, 32)
3571}
3572
3573/// The width-parametric form of [`encode_soft_core_full`]. `width` is retained as an API/experimental knob
3574/// describing the runtime datapath's fast-path size, but it NO LONGER changes the emitted image: every
3575/// over-i32 constant is a 3-word `LoadConstWide64` (op 222) carrying the full `i64` at every width, and the
3576/// value plane auto-promotes to BigInt on overflow exactly like the VM's unbounded `Int`. So the full core
3577/// reproduces the VM EXACTLY — every constant and every arithmetic result, into the BigInt tail — and does so
3578/// identically at width 32 and 64 (BigInt handles exactness; the width is a perf/area knob, not a correctness
3579/// switch). Runs on [`crate::codegen_sva::soft_core_model::run_soft_core_w`] at the matching width. (The
3580/// distinct 32-bit tape-out core in `scalar_asm.rs` genuinely wraps at 2³² — that is the small-area chip.)
3581pub fn encode_soft_core_full_w(program: &CompiledProgram, width: u32) -> Result<Vec<u32>, String> {
3582    // Value-semantics isolation set: mutator code indices → the collection register to DeepClone-isolate
3583    // first (one extra word each). See `isolation_targets`.
3584    let iso = isolation_targets(program);
3585    let deep_clone =
3586        crate::vm::binisa::opcode_of_name("DeepClone").expect("DeepClone is a registered opcode");
3587
3588    // The STRUCT-TYPE ORACLE: which registers hold which struct type at each code index, plus the
3589    // usage-derived positional field layout — drives the (field name → slot) lowering of every struct /
3590    // CRDT field op (`NewStruct`/`StructInsert`/`GetField`/`CrdtBump`/`CrdtResolve`).
3591    let st = StructTyping::analyze(program);
3592
3593    // The TYPE ORACLE: which registers hold f64 / a `Seq of Float` handle / a `Text` handle at each code
3594    // index — drives the Int-vs-Float-vs-Text lowering of every arithmetic/compare/Show/const op (the integer
3595    // path is untouched when nothing is float or text). A field read takes the field's declared type, which
3596    // the struct oracle names.
3597    let ft = FloatTyping::analyze(program, &st);
3598
3599    // The SLICE-BOUND FOLDS: for each `SliceOp`, its 1-based-inclusive bounds IFF both are provably small
3600    // integer constants (1..=15, the chip's immediate nibble form). A runtime/large bound is absent → the
3601    // encoder rejects it (a genuine hardware limit). Computed ONCE so pass 1 (`imem_width`) and pass 2
3602    // (`encode_op`) agree on which slices lower and their word count.
3603    let folds = slice_bound_folds(program);
3604    let slice_fold = |ci: usize| folds.get(&ci).copied();
3605
3606    // A non-capturing-closure predicate for `MakeClosure`: only a closure whose `captures` list is EMPTY
3607    // lowers (the single-word op 40 carries only the entry, no capture frame).
3608    let captures_nonempty = |func: u16| -> bool {
3609        program
3610            .functions
3611            .get(func as usize)
3612            .map(|f| !f.captures.is_empty())
3613            .unwrap_or(false)
3614    };
3615
3616    // ── STRING-CONSTANT POOL — the char data of every DISTINCT Text constant, appended AFTER the code as one
3617    // shared block per string (compact: an N-char string is N imem words, not ~2N NewEmptyList+push ops).
3618    // A `TextConst` copies its block into a fresh heap char-list. Distinct strings are deduped by content
3619    // (two `""`/two `" "` constants share one block). The pool base (imem index of the first block) is the
3620    // code word count `total`, resolved after pass 1 sizes the code. `pool_index[const_idx]` maps a Text
3621    // constant to its distinct-block ordinal; `pool_blocks[ordinal]` is that block's char codepoints.
3622    let mut pool_blocks: Vec<Vec<i32>> = Vec::new();
3623    let mut pool_by_content: std::collections::HashMap<&str, usize> =
3624        std::collections::HashMap::new();
3625    let mut pool_index: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
3626    for (ci, c) in program.constants.iter().enumerate() {
3627        if let Constant::Text(s) = c {
3628            let ord = *pool_by_content.entry(s.as_str()).or_insert_with(|| {
3629                let ord = pool_blocks.len();
3630                // The char-list stores i32 CODE POINTS (one per `char`), matching the model + the VM's char
3631                // granularity for `item i of` / `length` (ASCII: codepoint == byte, char count == byte length).
3632                pool_blocks.push(s.chars().map(|ch| ch as i32).collect());
3633                ord
3634            });
3635            pool_index.insert(ci as u32, ord);
3636        }
3637    }
3638    // A failure may need Text no constant spells: the Bool renderings and the refusals.
3639    if program.code.iter().any(|op| {
3640        matches!(
3641            op,
3642            Op::CallBuiltin {
3643                builtin: crate::semantics::builtins::BuiltinId::Fail
3644                    | crate::semantics::builtins::BuiltinId::FailReason
3645                    | crate::semantics::builtins::BuiltinId::Format,
3646                ..
3647            }
3648        )
3649    }) {
3650        for &(key, text) in FAILURE_POOL {
3651            let ord = *pool_by_content.entry(text).or_insert_with(|| {
3652                pool_blocks.push(text.chars().map(|ch| ch as i32).collect());
3653                pool_blocks.len() - 1
3654            });
3655            pool_index.insert(key, ord);
3656        }
3657    }
3658    // `args()` needs the program name `logos` in the pool even when no Text constant spells it.
3659    if program.code.iter().any(|op| matches!(op, Op::Args { .. })) {
3660        let ord = *pool_by_content
3661            .entry(crate::semantics::PROGRAM_NAME)
3662            .or_insert_with(|| {
3663                pool_blocks.push(
3664                    crate::semantics::PROGRAM_NAME
3665                        .chars()
3666                        .map(|ch| ch as i32)
3667                        .collect(),
3668                );
3669                pool_blocks.len() - 1
3670            });
3671        pool_index.insert(ARGV0_POOL_KEY, ord);
3672    }
3673
3674    // A SCRATCH register for the FormatValue-then-Concat lowering of a `Text + Int` interpolation: a
3675    // program-wide-UNUSED register (max index any op touches + 1), so writing it can never clobber a live
3676    // value. It is dead the instant the Concat consumes it, so no liveness tracking is needed. Only computed
3677    // (and required to exist) when a Text interpolation is actually emitted.
3678    let scratch: Reg = {
3679        let mut hi = 0u16;
3680        for op in &program.code {
3681            for r in op_registers(op) {
3682                hi = hi.max(r);
3683            }
3684        }
3685        for f in &program.functions {
3686            hi = hi.max((f.register_count.min(256).saturating_sub(1)) as u16);
3687        }
3688        hi
3689    };
3690    let scratch = scratch
3691        .checked_add(1)
3692        .filter(|&s| s <= 255)
3693        .ok_or_else(|| {
3694            "no free scratch register for a Text interpolation (the program uses all 256 registers)"
3695                .to_string()
3696        })?;
3697
3698    // Pass 1 — pc (code index) → imem word index.
3699    let mut pc_to_imem: Vec<usize> = Vec::with_capacity(program.code.len());
3700    let mut cursor = 0usize;
3701    for (i, op) in program.code.iter().enumerate() {
3702        pc_to_imem.push(cursor);
3703        cursor += imem_width(i, op, &program.constants, &ft, &slice_fold, scratch)
3704            .map_err(|e| format!("instruction {i}: {e}"))?
3705            + usize::from(iso.contains_key(&i));
3706    }
3707    let total = cursor; // the pool base: string blocks are laid out starting here, after the code.
3708
3709    // Function entry (FuncIdx → imem word index) and branch-target (code index → imem word index) resolvers.
3710    let entry_of = |func: u16| -> Result<u32, String> {
3711        let f = program
3712            .functions
3713            .get(func as usize)
3714            .ok_or_else(|| format!("Call references missing function #{func}"))?;
3715        let imem = *pc_to_imem
3716            .get(f.entry_pc)
3717            .ok_or_else(|| format!("function #{func} entry_pc {} out of range", f.entry_pc))?;
3718        Ok(imem as u32)
3719    };
3720    let imem_of = |code_idx: usize| -> Result<u32, String> {
3721        let imem = *pc_to_imem
3722            .get(code_idx)
3723            .ok_or_else(|| format!("branch target code index {code_idx} out of range"))?;
3724        if imem > 0xffff {
3725            return Err(format!(
3726                "branch target imem index {imem} exceeds the 16-bit target field"
3727            ));
3728        }
3729        Ok(imem as u32)
3730    };
3731    // Each distinct string block's (imem word offset of its first char, char length), laid out consecutively
3732    // after the code. Resolves a Text constant (`TextConst`'s pool operand) to where its chars live in imem.
3733    let mut block_offset: Vec<u32> = Vec::with_capacity(pool_blocks.len());
3734    {
3735        let mut at = total;
3736        for b in &pool_blocks {
3737            block_offset.push(at as u32);
3738            at += b.len();
3739        }
3740    }
3741    let pool_of = |const_idx: u32| -> Result<(u32, u32), String> {
3742        let ord = *pool_index
3743            .get(&const_idx)
3744            .ok_or_else(|| format!("Text constant #{const_idx} missing from the string pool"))?;
3745        let off = block_offset[ord];
3746        let len = pool_blocks[ord].len() as u32;
3747        Ok((off, len))
3748    };
3749
3750    // Pass 2 — emit the code.
3751    let mut imem: Vec<u32> =
3752        Vec::with_capacity(total + pool_blocks.iter().map(|b| b.len()).sum::<usize>());
3753    for (i, op) in program.code.iter().enumerate() {
3754        // Value-semantics barrier: isolate an aliased collection into a uniquely-owned copy before the
3755        // in-place mutation, so the chip's flat heap preserves LOGOS's value semantics (the VM's Rc-COW).
3756        if let Some(&target) = iso.get(&i) {
3757            imem.push(w(deep_clone, reg(target)?, reg(target)?, 0)); // DeepClone(target, target) — isolate in place
3758        }
3759        let is_float_iter = |ci: usize| is_float_iternext(ci, program, &ft);
3760        let words = encode_op(
3761            i,
3762            op,
3763            &program.constants,
3764            &ft,
3765            &st,
3766            &entry_of,
3767            &imem_of,
3768            &pool_of,
3769            &captures_nonempty,
3770            &is_float_iter,
3771            &slice_fold,
3772            scratch,
3773        )
3774        .map_err(|e| format!("instruction {i}: {e}"))?;
3775        debug_assert_eq!(
3776            words.len() + usize::from(iso.contains_key(&i)),
3777            imem_width(i, op, &program.constants, &ft, &slice_fold, scratch).unwrap()
3778                + usize::from(iso.contains_key(&i)),
3779            "imem_width disagrees with encode_op for {}",
3780            op.name()
3781        );
3782        imem.extend(words);
3783    }
3784    debug_assert_eq!(
3785        imem.len(),
3786        total,
3787        "pass-2 code size disagrees with pass-1 layout"
3788    );
3789    // Append the string-constant pool AFTER the code — one word per char codepoint, blocks in the same order
3790    // `block_offset` recorded. `TextConst` copies from `imem[offset..offset+len]`.
3791    for b in &pool_blocks {
3792        for &ch in b {
3793            imem.push(ch as u32);
3794        }
3795    }
3796    Ok(imem)
3797}
3798
3799/// The register indices an op reads or writes — a conservative upper set used only to pick a program-wide
3800/// unused SCRATCH register for the Text-interpolation FormatValue temp. Covers the operand + windowed forms
3801/// (a `Call`/`NewList` touches its whole `[start .. start+count)` window).
3802fn op_registers(op: &Op) -> Vec<u16> {
3803    use Op::*;
3804    let mut v = Vec::new();
3805    let mut push = |r: u16| v.push(r);
3806    match op {
3807        Add { dst, lhs, rhs }
3808        | Sub { dst, lhs, rhs }
3809        | Mul { dst, lhs, rhs }
3810        | Div { dst, lhs, rhs }
3811        | Mod { dst, lhs, rhs }
3812        | ExactDiv { dst, lhs, rhs }
3813        | FloorDiv { dst, lhs, rhs }
3814        | Lt { dst, lhs, rhs }
3815        | Gt { dst, lhs, rhs }
3816        | LtEq { dst, lhs, rhs }
3817        | GtEq { dst, lhs, rhs }
3818        | Eq { dst, lhs, rhs }
3819        | NotEq { dst, lhs, rhs }
3820        | ApproxEq { dst, lhs, rhs }
3821        | BitXor { dst, lhs, rhs }
3822        | BitAnd { dst, lhs, rhs }
3823        | BitOr { dst, lhs, rhs }
3824        | Shl { dst, lhs, rhs }
3825        | Shr { dst, lhs, rhs } => {
3826            push(*dst);
3827            push(*lhs);
3828            push(*rhs);
3829        }
3830        Index {
3831            dst,
3832            collection,
3833            index,
3834        }
3835        | IndexUnchecked {
3836            dst,
3837            collection,
3838            index,
3839        } => {
3840            push(*dst);
3841            push(*collection);
3842            push(*index);
3843        }
3844        SetIndex {
3845            collection,
3846            index,
3847            value,
3848        }
3849        | SetIndexUnchecked {
3850            collection,
3851            index,
3852            value,
3853        } => {
3854            push(*collection);
3855            push(*index);
3856            push(*value);
3857        }
3858        NewRange { dst, start, end } => {
3859            push(*dst);
3860            push(*start);
3861            push(*end);
3862        }
3863        Not { dst, src }
3864        | Move { dst, src }
3865        | Length {
3866            dst,
3867            collection: src,
3868        }
3869        | ListPop { list: src, dst }
3870        | AddAssign { dst, src } => {
3871            push(*dst);
3872            push(*src);
3873        }
3874        Show { src } => push(*src),
3875        Contains {
3876            dst,
3877            collection,
3878            value,
3879        } => {
3880            push(*dst);
3881            push(*collection);
3882            push(*value);
3883        }
3884        SeqConcat { dst, lhs, rhs } => {
3885            push(*dst);
3886            push(*lhs);
3887            push(*rhs);
3888        }
3889        DeepClone { dst, src } => {
3890            push(*dst);
3891            push(*src);
3892        }
3893        ListPush { list, value } | SetAdd { set: list, value } => {
3894            push(*list);
3895            push(*value);
3896        }
3897        LoadConst { dst, .. }
3898        | NewEmptyList { dst }
3899        | NewEmptyListFixed { dst, .. }
3900        | NewEmptyListI32 { dst }
3901        | NewEmptySet { dst }
3902        | NewEmptyMap { dst }
3903        | GlobalGet { dst, .. }
3904        | IterNext { dst, .. } => push(*dst),
3905        NewEmptyListWithCapacity { dst, capacity } | NewEmptyMapWithCapacity { dst, capacity } => {
3906            push(*dst);
3907            push(*capacity);
3908        }
3909        GlobalSet { src, .. } | IterPrepare { iterable: src } | Return { src } => push(*src),
3910        Call {
3911            dst,
3912            args_start,
3913            arg_count,
3914            ..
3915        } => {
3916            push(*dst);
3917            for k in 0..*arg_count {
3918                push(args_start.saturating_add(k));
3919            }
3920        }
3921        NewList { dst, start, count } | NewTuple { dst, start, count } => {
3922            push(*dst);
3923            for k in 0..*count {
3924                push(start.saturating_add(k));
3925            }
3926        }
3927        CallBuiltin {
3928            dst,
3929            args_start,
3930            arg_count,
3931            ..
3932        } => {
3933            push(*dst);
3934            for k in 0..*arg_count {
3935                push(args_start.saturating_add(k));
3936            }
3937        }
3938        JumpIfFalse { cond, .. } | JumpIfTrue { cond, .. } => push(*cond),
3939        _ => {}
3940    }
3941    v
3942}
3943
3944/// Render imem words as a `.hex` file body (one 8-hex-digit word per line).
3945pub fn to_hex(words: &[u32]) -> String {
3946    let mut s = String::with_capacity(words.len() * 9);
3947    for x in words {
3948        s.push_str(&format!("{x:08x}\n"));
3949    }
3950    s
3951}
3952
3953/// Compile a `.lg` source all the way to full soft-core imem words, through the chip compile path
3954/// (`compile_for_chip` — plain Div/Mod, no magic reciprocals). `Err` if it doesn't parse/compile or
3955/// uses anything outside the chip's integer subset. Targets the 32-bit datapath (the tape-out core); for
3956/// a 64-bit datapath (VM parity on 64-bit-range arithmetic) use [`compile_to_full_words_w`].
3957pub fn compile_to_full_words(source: &str) -> Result<Vec<u32>, String> {
3958    compile_to_full_words_w(source, 32)
3959}
3960
3961/// The WIDTH-PARAMETRIC form of [`compile_to_full_words`]: compile `.lg` source to full soft-core imem
3962/// words for a datapath of `width` bits (`32` = the tape-out core, byte-identical to the historical output;
3963/// `64` = full-i64 VM parity — a constant that exceeds i32, like the benchmark PRNGs' `% 2147483648` /
3964/// `% 4294967296` moduli, is emitted as a 3-word `LoadConstWide64` instead of being truncated). Pair with
3965/// [`crate::codegen_sva::soft_core_model::run_soft_core_w`] at the SAME width.
3966pub fn compile_to_full_words_w(source: &str, width: u32) -> Result<Vec<u32>, String> {
3967    if let Some(refusal) = crate::compile::exact_refusal(source, "soft core", "the soft core") {
3968        return Err(refusal);
3969    }
3970    // PRUNE the auto-imported stdlib prelude's DEAD functions before the chip encode: `apply_prelude` splices
3971    // a WHOLE module when the program mentions ANY of its names, but the chip encoder — like the flat C
3972    // namespace — must LOWER every op in the image, so an UNCALLED prelude helper that reaches a host builtin
3973    // (`parseFloat`, `format`, …) would make the whole program reject even though it never runs. `prune_
3974    // unreachable_prelude` drops exactly those dead PRELUDE helpers (never a user function, so LOGOS's
3975    // non-`name(` call forms like `Launch a task to worker` are always kept), so the encoder sees only the ops
3976    // the program executes — output-identical to the VM (the pruned functions were dead). `## NoPrelude` stops
3977    // `parse_seam` re-splicing the full module.
3978    let spliced = crate::loader::apply_prelude(source);
3979    let pruned = crate::loader::prune_unreachable_prelude_from(source, spliced.as_ref());
3980    let shaken = format!("## NoPrelude\n{pruned}");
3981    crate::ui_bridge::with_parsed_program(&shaken, |parsed, interner| {
3982        let (stmts, types, _policies) = parsed.map_err(|advice| format!("{advice}"))?;
3983        let oracle = crate::optimize::oracle_analyze_with(stmts, interner);
3984        let program =
3985            crate::vm::Compiler::compile_for_chip(stmts, interner, Some(types), Some(oracle))?;
3986        crate::compile::refuse_unlinkable(&program, interner, crate::compile::Backend::SoftCore)?;
3987        encode_soft_core_full_w(&program, width)
3988    })
3989}