diff --git a/crates/ogar-blockly/examples/density.rs b/crates/ogar-blockly/examples/density.rs index 79af198..1795f1c 100644 --- a/crates/ogar-blockly/examples/density.rs +++ b/crates/ogar-blockly/examples/density.rs @@ -1,7 +1,7 @@ //! Layout + density accounting for a block function node. //! -//! Prints the byte budget and the amortized cost per operation at several -//! occupancies, so the density claims in `docs/DISCOVERY-MAP.md` +//! Prints the byte budget and the amortized cost per call at several lane +//! shapes and occupancies, so the density claims in `docs/DISCOVERY-MAP.md` //! `D-BLOCKS-PALETTE` can be re-measured rather than trusted. //! //! ```sh @@ -9,50 +9,74 @@ //! ``` use ogar_blockly::{ - CLASSID_BYTES, CONTENT_SLOTS, FunctionBody, OPS_PER_FUNCTION, PAYLOAD_BYTES_PER_SLOT, - SLOT_STRIDE, VALUE_SLAB_LEN, + CLASSID_BYTES, CONTENT_SLOTS, FunctionBody, LaneShape, PAYLOAD_BYTES_PER_SLOT, SLOT_STRIDE, + VALUE_SLAB_LEN, }; -/// Bytes of a whole node: key(16) + edges(16) + value(480). +/// Bytes of a whole node: key(16) + reserved(16) + value(480). const NODE_BYTES: usize = 512; fn main() { println!("── node layout ──"); println!(" node {NODE_BYTES} B = 32 × {SLOT_STRIDE} B slots"); println!(" key 16 B (slot 0)"); - println!(" edges 16 B (slot 1)"); - println!(" value slab {VALUE_SLAB_LEN} B (slots 2..31 = {CONTENT_SLOTS} facets)"); + println!(" reserved 16 B (slot 1 — zeroed; retired edge-block NOT revived)"); + println!(" value slab {VALUE_SLAB_LEN} B (slots 2..31 = {CONTENT_SLOTS} lanes)"); println!( " classid overhead {} B ({CONTENT_SLOTS} × {CLASSID_BYTES}, interleaved)", CONTENT_SLOTS * CLASSID_BYTES ); println!( - " operation bytes {OPS_PER_FUNCTION} B ({CONTENT_SLOTS} × {PAYLOAD_BYTES_PER_SLOT})" + " call bytes {} B ({CONTENT_SLOTS} × {PAYLOAD_BYTES_PER_SLOT})", + CONTENT_SLOTS * PAYLOAD_BYTES_PER_SLOT ); + println!("\n── lane shapes: same 360 bytes, three carvings ──"); + for shape in LaneShape::ALL { + println!( + " {shape:?}: {} B/call ({} immediate{}) → {} calls/lane, {} calls/node", + shape.bytes_per_call(), + shape.values_per_call(), + if shape.values_per_call() == 1 { + "" + } else { + "s" + }, + shape.calls_per_lane(), + shape.calls_per_function() + ); + } + println!("\n── in-memory vs wire ──"); println!( - " FunctionBody {} B ([u8; {OPS_PER_FUNCTION}] + u16 len)", + " FunctionBody {} B ([u8; 360] + u16 len + LaneShape)", size_of::() ); println!( - " wire payload {OPS_PER_FUNCTION} B (len is NOT written; NOP padding is the signal)" + " wire payload 360 B (len & shape NOT written: padding is length, classid is shape)" ); - println!("\n── amortized cost per operation (whole {NODE_BYTES} B node) ──"); - for ops in [OPS_PER_FUNCTION, 180, 90, 30] { - let per_op = NODE_BYTES as f64 / ops as f64; - let occupancy = 100.0 * ops as f64 / OPS_PER_FUNCTION as f64; - println!(" {ops:3} ops ({occupancy:5.1}% full) {per_op:6.3} B/op"); + println!("\n── amortized cost per call (whole {NODE_BYTES} B node) ──"); + for shape in LaneShape::ALL { + let cap = shape.calls_per_function(); + for div in [1usize, 2, 4] { + let calls = cap / div; + let per_call = NODE_BYTES as f64 / calls as f64; + let occupancy = 100.0 / div as f64; + println!(" {shape:?} {calls:3} calls ({occupancy:5.1}% full) {per_call:6.3} B/call"); + } } - println!("\n── the operations are NOT contiguous in the slab ──"); - for i in [0usize, 11, 12, 23, 24, OPS_PER_FUNCTION - 1] { - println!( - " op {i:3} → slab offset {:3} (facet {:2}, byte {:2} of its payload lane)", - FunctionBody::slab_offset(i), - i / PAYLOAD_BYTES_PER_SLOT, - i % PAYLOAD_BYTES_PER_SLOT - ); + println!("\n── calls are NOT contiguous in the slab ──"); + for shape in [LaneShape::Pairs, LaneShape::Quads] { + let cpl = shape.calls_per_lane(); + for i in [0usize, cpl - 1, cpl, shape.calls_per_function() - 1] { + println!( + " {shape:?} call {i:3} → slab offset {:3} (lane {:2}, byte {:2} of its payload)", + FunctionBody::call_slab_offset(shape, i), + i / cpl, + (i % cpl) * shape.bytes_per_call() + ); + } } } diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index 5ce5981..0594e72 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -10,65 +10,93 @@ //! on two ids that merely sit in the same domain. //! //! That convergence is the entire point. `logic_compare[LT]` (Blockly) and -//! `operator_lt` (Scratch) are ONE slot — [`PaletteOp::LT`]. `operator_mathop` +//! `operator_lt` (Scratch) are ONE slot — [`FnIndex::LT`]. `operator_mathop` //! (one Scratch block with a dropdown) fans out to the same slots that //! `math_single` + `math_trig` (two Blockly blocks) fan out to. The palette is //! where the two vocabularies actually meet. //! -//! # The shape: one classid for content, 360 ops per function +//! # The shape: everything is a call — `(function : value)` //! -//! A V3 node is 512 bytes = 32 × 16-byte slots: `key(16) | edges(16) | -//! value(480)`. The value slab is 30 slots, each a `classid(4) + 12-byte -//! payload` facet — so a node carries **30 × 12 = 360 payload bytes**. -//! -//! One palette byte is one operation, so: +//! The V3 substrate reads every 12-byte payload as **6 × (u8:u8)** — six +//! two-byte rails, either semantic pairs or **indexed** into a codebook. Block +//! content takes the indexed reading, and the pair is +//! **[`Call`] = `(function : value)`**: //! //! ```text //! one function = one node = 512 bytes -//! key slot 0 classid = CONTENT (one) · identity = which function -//! edges slot 1 wiring — callers / callees -//! value slots 2..31 30 × 12 = 360 operation bytes +//! key slot 0 classid = CONTENT (one) · identity = which function +//! slot 1 reserved (16 B, zeroed; the retired edge-block +//! design is NOT revived — relations ride the +//! payload rails as indexed calls) +//! value slots 2..31 30 lanes, each carved by the body's LaneShape: +//! 6×(fn:val) · 4×(fn:val:val) · 3×(fn:val:val:val) +//! → 180 / 120 / 90 calls, always 360 bytes //! ``` //! -//! **A function body is capped at [`OPS_PER_FUNCTION`] = 360 operations, and -//! the cap is enforced** ([`FunctionBody::push`] / [`FunctionBody::from_ops`]). -//! Over-length is not a bigger row — it is a **split into two functions**. This -//! is the substrate's own rule applied to program structure: *scale is the next -//! cascade level, never field-widening.* The cap is a forcing function for -//! decomposition, and it makes "does this function fit?" a fact you can check -//! before you write, not a surprise at runtime. +//! **There is no "opcode" distinct from a "function call".** `ADD` is function +//! `0x40`; a user-defined block is another index in the same `<256` codebook; +//! invoking either is the same two bytes. What used to look like a palette of +//! operations is simply the low range of the function codebook — see +//! [`FnIndex`]. +//! +//! ## Arity — two mechanisms, and they compose +//! +//! **1. The classid widens the lane.** A 12-byte lane carves three sanctioned +//! ways, and the classid selects which ([`LaneShape`], mirroring the LE +//! contract's `CascadeShape`): +//! +//! | shape | carving | per call | calls / node | +//! |---|---|---|---| +//! | [`LaneShape::Pairs`] | `6 × (u8:u8)` | `function : value` | **180** | +//! | [`LaneShape::Triples`] | `4 × (u8:u8:u8)` | `function : value : value` | **120** | +//! | [`LaneShape::Quads`] | `3 × (u8:u8:u8:u8)` | `function : value ×3` | **90** | +//! +//! A function needing more than one immediate does not get a wider *field* — +//! its class picks a wider *carving* of the same 12 bytes. Byte budget is +//! constant at 360; only the call count moves. +//! +//! **2. The stack carries nested expressions.** Immediates are what the value +//! bytes hold; *computed* arguments come from a stack discipline — each call +//! consumes its operands and pushes its result, so `5 + 3` is +//! `(NUMBER:5) (NUMBER:3) (ADD:0)` in any shape. That is what lowers cleanly into +//! a recursive `Input` tree, which is how Scratch operands nest. +//! +//! Either way every call stays **independently readable**: call `i` is at a +//! computed offset ([`FunctionBody::call_slab_offset`]) with no scan from the +//! start — a property a variable-arity or immediate-following encoding would +//! destroy. The shape is uniform within one body because it comes from that +//! body's classid. +//! +//! ## Nesting is by reference, not by delimiter //! -//! Only ONE concept id is spent on content ([`BlockConcept::Content`]) — the -//! operations live in the payload, not in the classid space. An earlier design -//! pass sized a per-operation concept space and hit an imagined 255-slot -//! ceiling; that space does not need to exist. +//! A function index can name *another function*, so `IF` calls a body living in +//! its own node. There is no `END` marker, no jump offset, and no need for one +//! — the same way Scratch's own SB3 format nests via block references rather +//! than implicit length. An earlier pass of this crate treated the absence of a +//! stream delimiter as a defect; under `(function : value)` the question does +//! not arise. //! -//! # ⚠ What this does NOT yet encode: operands +//! ## Budgets //! -//! **The palette names WHICH operation, never its operands.** A body of bare -//! palette bytes cannot distinguish `WAIT(1)` from `WAIT(10)`, and -//! [`PaletteOp::NUMBER`] says *a literal is here* without saying which literal. -//! So a [`FunctionBody`] as it stands is the **vocabulary layer of a program, -//! not a complete program**: enough to say what a function does, not enough to -//! execute or round-trip it. +//! **A body is capped at [`LaneShape::calls_per_function`] — 180 / 120 / 90 +//! depending on the shape, always 360 bytes — and the cap is enforced** +//! ([`FunctionBody::push`] / [`FunctionBody::from_calls`]). Over-length is a +//! **split into two functions**, never a bigger row — the substrate's own rule +//! (*scale is the next cascade level, never field-widening*) applied to program +//! structure. //! -//! This is a **named gap, deliberately open** — raised on OGAR #235 and not -//! yet resolved, because the resolution is an architecture decision rather than -//! an implementation detail. The two candidates: +//! The codebook is capped at **`<256` functions per scope** by the same logic: +//! one byte names any function in scope, and scopes cascade rather than widen. //! -//! - **Immediates in the stream** — the 360 bytes are a byte-coded instruction -//! stream in which some palette entries are followed by operand bytes (a -//! constant-pool index, a variable slot). Keeps 360 as an upper bound on -//! *bytes*, lowers the effective operation count, and needs a home for the -//! constant pool. -//! - **`(opcode : operand)` pairs** — read each 12-byte lane as the LE -//! contract's L4 `6 × (u8:u8)` rail, giving 6 pairs per facet and **180** -//! pairs per node. Operands become palette-addressed in the same byte space, -//! at the cost of halving the op budget. This is a sanctioned payload layout, -//! not a new invention. +//! Only ONE concept id is spent on content ([`BlockConcept::Content`]) — calls +//! live in the payload, not in the classid space. //! -//! Until one is chosen, treat a `FunctionBody` as an **opcode skeleton**, and -//! do NOT advertise it as a lossless program encoding. +//! ## Wide literals +//! +//! A value byte holds `0..=255`. A call needing more (`WAIT:1.5`, a string) +//! spends its value byte as a **constant-pool index** instead of the value — +//! same pair shape, different codebook. The pool is a named follow-up; nothing +//! in the encoding changes when it lands. //! //! # Storage shape — inventory SoA + N content SoAs, split by function //! @@ -134,22 +162,90 @@ pub const PAYLOAD_BYTES_PER_SLOT: usize = SLOT_STRIDE - CLASSID_BYTES; /// Bytes of a node's value slab: `30 × 16` = **480**. /// /// Note the asymmetry that catches people: the slab is **480** bytes but only -/// [`OPS_PER_FUNCTION`] = 360 of them are operations. The other 120 are the 30 -/// facets' 4-byte classids, interleaved — never a contiguous run. +/// [`BODY_BYTES`] = 360 of them are call payload (180 / 120 / 90 calls, +/// depending on the [`LaneShape`]). The other 120 are the 30 facets' 4-byte +/// classids, interleaved — never a contiguous run. pub const VALUE_SLAB_LEN: usize = CONTENT_SLOTS * SLOT_STRIDE; -/// Operations one function body carries: `30 × 12` = **360**. +/// Payload bytes one function body carries: `30 × 12` = **360**. /// -/// This is a derived budget, not a chosen constant — it is exactly the payload -/// capacity of a node's value slab once the key and edge slots are taken. A -/// function that needs more is **split**, never widened. -pub const OPS_PER_FUNCTION: usize = CONTENT_SLOTS * PAYLOAD_BYTES_PER_SLOT; +/// A derived budget, not a chosen constant — exactly the payload capacity of a +/// node's value slab. Constant across every [`LaneShape`]; what changes with +/// the shape is how many CALLS those bytes hold, never how many bytes there +/// are. +pub const BODY_BYTES: usize = CONTENT_SLOTS * PAYLOAD_BYTES_PER_SLOT; const _: () = assert!( - OPS_PER_FUNCTION == 360, + BODY_BYTES == 360, "360 = 30 value-slab facet slots × 12 payload bytes each" ); +/// How a 12-byte lane is carved into calls — selected by the body's **classid**, +/// uniform within one body. +/// +/// Mirrors the LE contract's `CascadeShape` (`G6D2` / `G4D3` / `G3D4`); defined +/// locally so this crate keeps its plug-and-play posture and takes no +/// substrate dependency. +/// +/// Every shape spends the same 12 bytes per lane and the same [`BODY_BYTES`] +/// per node. A function needing more immediates picks a wider **carving**, not +/// a wider field — the canon's *scale is the next cascade level, never +/// field-widening*, applied one level down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum LaneShape { + /// `6 × (u8:u8)` — `function : value`. One immediate per call, 180 calls + /// per node. The default: most calls take zero or one immediate. + #[default] + Pairs, + /// `4 × (u8:u8:u8)` — `function : value : value`. Two immediates, 120 calls. + Triples, + /// `3 × (u8:u8:u8:u8)` — `function : value × 3`. Three immediates, 90 calls. + Quads, +} + +impl LaneShape { + /// Every shape, widest call first. + pub const ALL: [LaneShape; 3] = [LaneShape::Quads, LaneShape::Triples, LaneShape::Pairs]; + + /// Bytes one call occupies: 2, 3, or 4. + #[must_use] + pub const fn bytes_per_call(self) -> usize { + match self { + LaneShape::Pairs => 2, + LaneShape::Triples => 3, + LaneShape::Quads => 4, + } + } + + /// Immediate value bytes one call carries: 1, 2, or 3 (the call's byte + /// width minus its one function-index byte). + #[must_use] + pub const fn values_per_call(self) -> usize { + self.bytes_per_call() - 1 + } + + /// Calls in one 12-byte lane: 6, 4, or 3. + #[must_use] + pub const fn calls_per_lane(self) -> usize { + PAYLOAD_BYTES_PER_SLOT / self.bytes_per_call() + } + + /// Calls one function body carries: `30 × calls_per_lane` = 180 / 120 / 90. + #[must_use] + pub const fn calls_per_function(self) -> usize { + CONTENT_SLOTS * self.calls_per_lane() + } +} + +// Every shape divides the 12-byte lane exactly — no remainder, no dead bytes. +const _: () = assert!(LaneShape::Pairs.calls_per_lane() * 2 == PAYLOAD_BYTES_PER_SLOT); +const _: () = assert!(LaneShape::Triples.calls_per_lane() * 3 == PAYLOAD_BYTES_PER_SLOT); +const _: () = assert!(LaneShape::Quads.calls_per_lane() * 4 == PAYLOAD_BYTES_PER_SLOT); +const _: () = assert!(LaneShape::Pairs.calls_per_function() == 180); +const _: () = assert!(LaneShape::Triples.calls_per_function() == 120); +const _: () = assert!(LaneShape::Quads.calls_per_function() == 90); + // ── Concept ids (authoritative here, NOT in the shared codebook) ──────────── /// The concepts this crate owns inside `0x17XX`. @@ -162,8 +258,8 @@ const _: () = assert!( #[non_exhaustive] pub enum BlockConcept { /// `0x1701` — a **function body**: identity names the function, the value - /// slab carries up to [`OPS_PER_FUNCTION`] palette bytes. The one content - /// classid. + /// slab carries up to [`LaneShape::calls_per_function`] calls. The one + /// content classid. Content, /// `0x1702` — the **inventory** row: the function registry entry (which /// functions exist, addressed by identity). Reads never touch a body. @@ -213,238 +309,244 @@ impl BlockConcept { /// they mint when a consumer needs them. Reserve, don't reclaim. pub const DEVICE_FAMILY_FLOOR: u8 = 0x90; -/// One operation — a single byte of a function body, and the unit the palette -/// indexes. +/// An index into the **function codebook** — one byte that names any callable +/// thing in scope. +/// +/// There is no opcode/function distinction: the named constants below are the +/// primitive low range of the same `<256` codebook that user-defined functions +/// mint into (resolved through the [`SoaSplit::Inventory`] registry, which is +/// the label codebook these indices point at). A [`Call`]'s first byte is a +/// `FnIndex`; the editor's pick-from palette is a *rendering* of this codebook. /// /// `0x00` is reserved as the zero-fallback: an unwritten payload byte reads as -/// [`PaletteOp::NOP`], so a partially-filled body is well-defined without a +/// [`FnIndex::NOP`], so a partially-filled body is well-defined without a /// length field. This mirrors the substrate's monotonic zero ladder (a zero /// tier means *not consulted*, never *compacted away*). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[repr(transparent)] -pub struct PaletteOp(pub u8); +pub struct FnIndex(pub u8); -impl PaletteOp { +impl FnIndex { /// The zero slot — an unwritten body byte. Never a real operation. - pub const NOP: PaletteOp = PaletteOp(0x00); + pub const NOP: FnIndex = FnIndex(0x00); // ── control (0x01..0x1F) ──────────────────────────────────────────── /// Conditional with no else arm. `controls_if` · `control_if`. - pub const IF: PaletteOp = PaletteOp(0x01); + pub const IF: FnIndex = FnIndex(0x01); /// Conditional with an else arm. `controls_ifelse` · `control_if_else`. - pub const IF_ELSE: PaletteOp = PaletteOp(0x02); + pub const IF_ELSE: FnIndex = FnIndex(0x02); /// Bounded repeat. `controls_repeat`/`_ext` · `control_repeat`. - pub const REPEAT: PaletteOp = PaletteOp(0x03); + pub const REPEAT: FnIndex = FnIndex(0x03); /// Repeat until a condition holds. `controls_whileUntil[UNTIL]` · /// `control_repeat_until`. - pub const REPEAT_UNTIL: PaletteOp = PaletteOp(0x04); + pub const REPEAT_UNTIL: FnIndex = FnIndex(0x04); /// Repeat while a condition holds. `controls_whileUntil[WHILE]` · /// `control_while`. - pub const WHILE: PaletteOp = PaletteOp(0x05); + pub const WHILE: FnIndex = FnIndex(0x05); /// Unbounded repeat. `control_forever` (no Blockly counterpart). - pub const FOREVER: PaletteOp = PaletteOp(0x06); + pub const FOREVER: FnIndex = FnIndex(0x06); /// Iterate a list. `controls_forEach` · `control_for_each`. - pub const FOR_EACH: PaletteOp = PaletteOp(0x07); + pub const FOR_EACH: FnIndex = FnIndex(0x07); /// Iterate a numeric range. `controls_for` (no Scratch counterpart). - pub const FOR_RANGE: PaletteOp = PaletteOp(0x08); + pub const FOR_RANGE: FnIndex = FnIndex(0x08); /// Suspend for a duration. `control_wait`. - pub const WAIT: PaletteOp = PaletteOp(0x09); + pub const WAIT: FnIndex = FnIndex(0x09); /// Suspend until a condition holds. `control_wait_until`. - pub const WAIT_UNTIL: PaletteOp = PaletteOp(0x0A); + pub const WAIT_UNTIL: FnIndex = FnIndex(0x0A); /// Stop this script / all / others. `control_stop`. - pub const STOP: PaletteOp = PaletteOp(0x0B); + pub const STOP: FnIndex = FnIndex(0x0B); /// Leave the enclosing loop. `controls_flow_statements[BREAK]`. - pub const BREAK: PaletteOp = PaletteOp(0x0C); + pub const BREAK: FnIndex = FnIndex(0x0C); /// Skip to the enclosing loop's next iteration. /// `controls_flow_statements[CONTINUE]`. - pub const CONTINUE: PaletteOp = PaletteOp(0x0D); + pub const CONTINUE: FnIndex = FnIndex(0x0D); /// Return from the enclosing function. `procedures_ifreturn`. - pub const RETURN: PaletteOp = PaletteOp(0x0E); + pub const RETURN: FnIndex = FnIndex(0x0E); // ── logic (0x20..0x2F) ────────────────────────────────────────────── /// Boolean conjunction. `logic_operation[AND]` · `operator_and`. - pub const AND: PaletteOp = PaletteOp(0x20); + pub const AND: FnIndex = FnIndex(0x20); /// Boolean disjunction. `logic_operation[OR]` · `operator_or`. - pub const OR: PaletteOp = PaletteOp(0x21); + pub const OR: FnIndex = FnIndex(0x21); /// Boolean negation. `logic_negate` · `operator_not`. - pub const NOT: PaletteOp = PaletteOp(0x22); + pub const NOT: FnIndex = FnIndex(0x22); /// Literal true. `logic_boolean[TRUE]`. - pub const TRUE: PaletteOp = PaletteOp(0x23); + pub const TRUE: FnIndex = FnIndex(0x23); /// Literal false. `logic_boolean[FALSE]`. - pub const FALSE: PaletteOp = PaletteOp(0x24); + pub const FALSE: FnIndex = FnIndex(0x24); /// Literal null. `logic_null` (no Scratch counterpart). - pub const NULL: PaletteOp = PaletteOp(0x25); + pub const NULL: FnIndex = FnIndex(0x25); /// Conditional expression. `logic_ternary` (no Scratch counterpart). - pub const TERNARY: PaletteOp = PaletteOp(0x26); + pub const TERNARY: FnIndex = FnIndex(0x26); // ── comparison (0x30..0x3F) ───────────────────────────────────────── /// Equality. `logic_compare[EQ]` · `operator_equals`. - pub const EQ: PaletteOp = PaletteOp(0x30); + pub const EQ: FnIndex = FnIndex(0x30); /// Inequality. `logic_compare[NEQ]` (no Scratch counterpart). - pub const NEQ: PaletteOp = PaletteOp(0x31); + pub const NEQ: FnIndex = FnIndex(0x31); /// Less than. `logic_compare[LT]` · `operator_lt`. - pub const LT: PaletteOp = PaletteOp(0x32); + pub const LT: FnIndex = FnIndex(0x32); /// Less than or equal. `logic_compare[LTE]` (no Scratch counterpart). - pub const LTE: PaletteOp = PaletteOp(0x33); + pub const LTE: FnIndex = FnIndex(0x33); /// Greater than. `logic_compare[GT]` · `operator_gt`. - pub const GT: PaletteOp = PaletteOp(0x34); + pub const GT: FnIndex = FnIndex(0x34); /// Greater than or equal. `logic_compare[GTE]` (no Scratch counterpart). - pub const GTE: PaletteOp = PaletteOp(0x35); + pub const GTE: FnIndex = FnIndex(0x35); // ── math (0x40..0x5F) ─────────────────────────────────────────────── /// Addition. `math_arithmetic[ADD]` · `operator_add`. - pub const ADD: PaletteOp = PaletteOp(0x40); + pub const ADD: FnIndex = FnIndex(0x40); /// Subtraction. `math_arithmetic[MINUS]` · `operator_subtract`. - pub const SUB: PaletteOp = PaletteOp(0x41); + pub const SUB: FnIndex = FnIndex(0x41); /// Multiplication. `math_arithmetic[MULTIPLY]` · `operator_multiply`. - pub const MUL: PaletteOp = PaletteOp(0x42); + pub const MUL: FnIndex = FnIndex(0x42); /// Division. `math_arithmetic[DIVIDE]` · `operator_divide`. - pub const DIV: PaletteOp = PaletteOp(0x43); + pub const DIV: FnIndex = FnIndex(0x43); /// Exponentiation. `math_arithmetic[POWER]` (no Scratch counterpart). - pub const POW: PaletteOp = PaletteOp(0x44); + pub const POW: FnIndex = FnIndex(0x44); /// Modulo. `math_modulo` · `operator_mod`. - pub const MOD: PaletteOp = PaletteOp(0x45); + pub const MOD: FnIndex = FnIndex(0x45); /// Numeric literal. `math_number` (Scratch uses a field, not a block). - pub const NUMBER: PaletteOp = PaletteOp(0x46); + pub const NUMBER: FnIndex = FnIndex(0x46); /// Absolute value. `math_single[ABS]` · `operator_mathop[abs]`. - pub const ABS: PaletteOp = PaletteOp(0x47); + pub const ABS: FnIndex = FnIndex(0x47); /// Negation. `math_single[NEG]`. - pub const NEG: PaletteOp = PaletteOp(0x48); + pub const NEG: FnIndex = FnIndex(0x48); /// Round to nearest. `math_round[ROUND]` · `operator_round`. - pub const ROUND: PaletteOp = PaletteOp(0x49); + pub const ROUND: FnIndex = FnIndex(0x49); /// Round toward -inf. `math_round[ROUNDDOWN]` · `operator_mathop[floor]`. - pub const FLOOR: PaletteOp = PaletteOp(0x4A); + pub const FLOOR: FnIndex = FnIndex(0x4A); /// Round toward +inf. `math_round[ROUNDUP]` · `operator_mathop[ceiling]`. - pub const CEIL: PaletteOp = PaletteOp(0x4B); + pub const CEIL: FnIndex = FnIndex(0x4B); /// Square root. `math_single[ROOT]` · `operator_mathop[sqrt]`. - pub const SQRT: PaletteOp = PaletteOp(0x4C); + pub const SQRT: FnIndex = FnIndex(0x4C); /// Natural logarithm. `math_single[LN]` · `operator_mathop[ln]`. - pub const LN: PaletteOp = PaletteOp(0x4D); + pub const LN: FnIndex = FnIndex(0x4D); /// Base-10 logarithm. `math_single[LOG10]` · `operator_mathop[log]`. - pub const LOG10: PaletteOp = PaletteOp(0x4E); + pub const LOG10: FnIndex = FnIndex(0x4E); /// `e^x`. `math_single[EXP]` · `operator_mathop[e ^]`. - pub const EXP_E: PaletteOp = PaletteOp(0x4F); + pub const EXP_E: FnIndex = FnIndex(0x4F); /// `10^x`. `math_single[POW10]` · `operator_mathop[10 ^]`. - pub const EXP_10: PaletteOp = PaletteOp(0x50); + pub const EXP_10: FnIndex = FnIndex(0x50); /// Sine. `math_trig[SIN]` · `operator_mathop[sin]`. - pub const SIN: PaletteOp = PaletteOp(0x51); + pub const SIN: FnIndex = FnIndex(0x51); /// Cosine. `math_trig[COS]` · `operator_mathop[cos]`. - pub const COS: PaletteOp = PaletteOp(0x52); + pub const COS: FnIndex = FnIndex(0x52); /// Tangent. `math_trig[TAN]` · `operator_mathop[tan]`. - pub const TAN: PaletteOp = PaletteOp(0x53); + pub const TAN: FnIndex = FnIndex(0x53); /// Arcsine. `math_trig[ASIN]` · `operator_mathop[asin]`. - pub const ASIN: PaletteOp = PaletteOp(0x54); + pub const ASIN: FnIndex = FnIndex(0x54); /// Arccosine. `math_trig[ACOS]` · `operator_mathop[acos]`. - pub const ACOS: PaletteOp = PaletteOp(0x55); + pub const ACOS: FnIndex = FnIndex(0x55); /// Arctangent. `math_trig[ATAN]` · `operator_mathop[atan]`. - pub const ATAN: PaletteOp = PaletteOp(0x56); + pub const ATAN: FnIndex = FnIndex(0x56); /// Two-argument arctangent. `math_atan2` (no Scratch counterpart). - pub const ATAN2: PaletteOp = PaletteOp(0x57); + pub const ATAN2: FnIndex = FnIndex(0x57); /// Random integer in a range. `math_random_int` · `operator_random`. - pub const RANDOM_INT: PaletteOp = PaletteOp(0x58); + pub const RANDOM_INT: FnIndex = FnIndex(0x58); /// Random fraction. `math_random_float` (no Scratch counterpart). - pub const RANDOM_FLOAT: PaletteOp = PaletteOp(0x59); + pub const RANDOM_FLOAT: FnIndex = FnIndex(0x59); /// Clamp to a range. `math_constrain` (no Scratch counterpart). - pub const CONSTRAIN: PaletteOp = PaletteOp(0x5A); + pub const CONSTRAIN: FnIndex = FnIndex(0x5A); /// Numeric predicate (even/odd/prime/whole/positive/negative/divisible). /// `math_number_property` (no Scratch counterpart). - pub const NUMBER_PROPERTY: PaletteOp = PaletteOp(0x5B); + pub const NUMBER_PROPERTY: FnIndex = FnIndex(0x5B); /// Named constant (pi/e/phi/sqrt2/sqrt1_2/infinity). `math_constant`. - pub const CONSTANT: PaletteOp = PaletteOp(0x5C); + pub const CONSTANT: FnIndex = FnIndex(0x5C); /// Aggregate over a list (sum/min/max/average/median/mode/std_dev). /// `math_on_list` (no Scratch counterpart). - pub const ON_LIST: PaletteOp = PaletteOp(0x5D); + pub const ON_LIST: FnIndex = FnIndex(0x5D); // ── text (0x60..0x6F) ─────────────────────────────────────────────── /// String literal. `text`. - pub const TEXT: PaletteOp = PaletteOp(0x60); + pub const TEXT: FnIndex = FnIndex(0x60); /// Concatenate. `text_join` · `operator_join`. - pub const JOIN: PaletteOp = PaletteOp(0x61); + pub const JOIN: FnIndex = FnIndex(0x61); /// Character count. `text_length` · `operator_length`. - pub const LENGTH: PaletteOp = PaletteOp(0x62); + pub const LENGTH: FnIndex = FnIndex(0x62); /// Character at a position. `text_charAt` · `operator_letter_of`. - pub const CHAR_AT: PaletteOp = PaletteOp(0x63); + pub const CHAR_AT: FnIndex = FnIndex(0x63); /// Substring search. `text_indexOf` (no Scratch counterpart). - pub const INDEX_OF: PaletteOp = PaletteOp(0x64); + pub const INDEX_OF: FnIndex = FnIndex(0x64); /// Emptiness test. `text_isEmpty` (no Scratch counterpart). - pub const IS_EMPTY: PaletteOp = PaletteOp(0x65); + pub const IS_EMPTY: FnIndex = FnIndex(0x65); /// Substring extraction. `text_getSubstring` (no Scratch counterpart). - pub const SUBSTRING: PaletteOp = PaletteOp(0x66); + pub const SUBSTRING: FnIndex = FnIndex(0x66); /// Case conversion. `text_changeCase` (no Scratch counterpart). - pub const CHANGE_CASE: PaletteOp = PaletteOp(0x67); + pub const CHANGE_CASE: FnIndex = FnIndex(0x67); /// Whitespace trim. `text_trim` (no Scratch counterpart). - pub const TRIM: PaletteOp = PaletteOp(0x68); + pub const TRIM: FnIndex = FnIndex(0x68); /// Containment test. `text_contains`-shaped · `operator_contains`. - pub const CONTAINS: PaletteOp = PaletteOp(0x69); + pub const CONTAINS: FnIndex = FnIndex(0x69); /// Append to a variable. `text_append`. - pub const APPEND: PaletteOp = PaletteOp(0x6A); + pub const APPEND: FnIndex = FnIndex(0x6A); /// Emit to output. `text_print`. - pub const PRINT: PaletteOp = PaletteOp(0x6B); + pub const PRINT: FnIndex = FnIndex(0x6B); /// Prompt for input. `text_prompt`/`_ext`. - pub const PROMPT: PaletteOp = PaletteOp(0x6C); + pub const PROMPT: FnIndex = FnIndex(0x6C); /// Occurrence count. `text_count` (no Scratch counterpart). - pub const COUNT: PaletteOp = PaletteOp(0x6D); + pub const COUNT: FnIndex = FnIndex(0x6D); /// Substring replacement. `text_replace` (no Scratch counterpart). - pub const REPLACE: PaletteOp = PaletteOp(0x6E); + pub const REPLACE: FnIndex = FnIndex(0x6E); /// Reversal. `text_reverse` (no Scratch counterpart). - pub const REVERSE: PaletteOp = PaletteOp(0x6F); + pub const REVERSE: FnIndex = FnIndex(0x6F); // ── list (0x70..0x7F) ─────────────────────────────────────────────── /// Empty list literal. `lists_create_empty`. - pub const LIST_EMPTY: PaletteOp = PaletteOp(0x70); + pub const LIST_EMPTY: FnIndex = FnIndex(0x70); /// List literal with items. `lists_create_with`. - pub const LIST_WITH: PaletteOp = PaletteOp(0x71); + pub const LIST_WITH: FnIndex = FnIndex(0x71); /// Repeat an item into a list. `lists_repeat`. - pub const LIST_REPEAT: PaletteOp = PaletteOp(0x72); + pub const LIST_REPEAT: FnIndex = FnIndex(0x72); /// Item count. `lists_length` · `data_lengthoflist`. - pub const LIST_LENGTH: PaletteOp = PaletteOp(0x73); + pub const LIST_LENGTH: FnIndex = FnIndex(0x73); /// Emptiness test. `lists_isEmpty`. - pub const LIST_IS_EMPTY: PaletteOp = PaletteOp(0x74); + pub const LIST_IS_EMPTY: FnIndex = FnIndex(0x74); /// Position of an item. `lists_indexOf` · `data_itemnumoflist`. - pub const LIST_INDEX_OF: PaletteOp = PaletteOp(0x75); + pub const LIST_INDEX_OF: FnIndex = FnIndex(0x75); /// Read an item. `lists_getIndex` · `data_itemoflist`. - pub const LIST_GET: PaletteOp = PaletteOp(0x76); + pub const LIST_GET: FnIndex = FnIndex(0x76); /// Write an item. `lists_setIndex[SET]` · `data_replaceitemoflist`. - pub const LIST_SET: PaletteOp = PaletteOp(0x77); + pub const LIST_SET: FnIndex = FnIndex(0x77); /// Insert an item. `lists_setIndex[INSERT]` · `data_insertatlist`. - pub const LIST_INSERT: PaletteOp = PaletteOp(0x78); + pub const LIST_INSERT: FnIndex = FnIndex(0x78); /// Append an item. `data_addtolist`. - pub const LIST_ADD: PaletteOp = PaletteOp(0x79); + pub const LIST_ADD: FnIndex = FnIndex(0x79); /// Remove an item. `lists_getIndex[REMOVE]` · `data_deleteoflist`. - pub const LIST_DELETE: PaletteOp = PaletteOp(0x7A); + pub const LIST_DELETE: FnIndex = FnIndex(0x7A); /// Remove every item. `data_deletealloflist`. - pub const LIST_DELETE_ALL: PaletteOp = PaletteOp(0x7B); + pub const LIST_DELETE_ALL: FnIndex = FnIndex(0x7B); /// Sublist extraction. `lists_getSublist` (no Scratch counterpart). - pub const LIST_SUBLIST: PaletteOp = PaletteOp(0x7C); + pub const LIST_SUBLIST: FnIndex = FnIndex(0x7C); /// Split / join against a delimiter. `lists_split`. - pub const LIST_SPLIT: PaletteOp = PaletteOp(0x7D); + pub const LIST_SPLIT: FnIndex = FnIndex(0x7D); /// Ordering. `lists_sort` (no Scratch counterpart). - pub const LIST_SORT: PaletteOp = PaletteOp(0x7E); + pub const LIST_SORT: FnIndex = FnIndex(0x7E); /// Containment test. `lists_indexOf`-shaped · `data_listcontainsitem`. - pub const LIST_CONTAINS: PaletteOp = PaletteOp(0x7F); + pub const LIST_CONTAINS: FnIndex = FnIndex(0x7F); // ── variable + procedure (0x80..0x8F) ─────────────────────────────── /// Read a variable. `variables_get` · `data_variable`. - pub const VAR_GET: PaletteOp = PaletteOp(0x80); + pub const VAR_GET: FnIndex = FnIndex(0x80); /// Write a variable. `variables_set` · `data_setvariableto`. - pub const VAR_SET: PaletteOp = PaletteOp(0x81); + pub const VAR_SET: FnIndex = FnIndex(0x81); /// Increment a variable. `math_change` · `data_changevariableby`. - pub const VAR_CHANGE: PaletteOp = PaletteOp(0x82); + pub const VAR_CHANGE: FnIndex = FnIndex(0x82); /// Define a function. `procedures_defnoreturn`/`_defreturn` · /// `procedures_definition`. - pub const PROC_DEF: PaletteOp = PaletteOp(0x83); + pub const PROC_DEF: FnIndex = FnIndex(0x83); /// Invoke a function. `procedures_callnoreturn`/`_callreturn` · /// `procedures_call`. - pub const PROC_CALL: PaletteOp = PaletteOp(0x84); + pub const PROC_CALL: FnIndex = FnIndex(0x84); /// Read a call argument. `procedures_defreturn` argument access. - pub const PROC_ARG: PaletteOp = PaletteOp(0x85); + pub const PROC_ARG: FnIndex = FnIndex(0x85); /// Is this a **shared computational** operation — one that means the same /// thing in every frontend? /// /// A one-compare test, no table lookup: everything below - /// [`DEVICE_FAMILY_FLOOR`] is shared. [`PaletteOp::NOP`] is not an + /// [`DEVICE_FAMILY_FLOOR`] is shared. [`FnIndex::NOP`] is not an /// operation and answers `false`. #[must_use] pub const fn is_shared_core(self) -> bool { @@ -459,122 +561,271 @@ impl PaletteOp { } } -// ── Function bodies ───────────────────────────────────────────────────────── +// ── Calls ─────────────────────────────────────────────────────────────────── + +/// The widest immediate a call can carry — [`LaneShape::Quads`]' three value +/// bytes. Narrower shapes use a prefix of [`Call::values`]; the unused tail +/// MUST be zero (enforced by [`FunctionBody::push`] via [`Call::fits`]). +pub const MAX_VALUES_PER_CALL: usize = 3; -/// A function body exceeded [`OPS_PER_FUNCTION`]. +/// One call — a function index plus up to three immediate value bytes. /// -/// The remedy is a **split into two functions**, never a wider row. +/// This is the unit of a function body. How many of [`values`](Self::values) +/// are actually stored is decided by the body's [`LaneShape`] (1, 2, or 3); +/// a `Call` itself always carries the widest form so the same value moves +/// freely between shapes when it fits. +/// +/// Computed (non-immediate) arguments do not live here — they come from the +/// stack discipline (see the crate docs): each call consumes its operands from +/// the stack and pushes its result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Call { + /// Which function — an index into the scope's `<256` codebook. + pub function: FnIndex, + /// Immediate value bytes, execution-order. Under [`LaneShape::Pairs`] only + /// `values[0]` is stored; [`Triples`](LaneShape::Triples) store two; + /// [`Quads`](LaneShape::Quads) all three. + pub values: [u8; MAX_VALUES_PER_CALL], +} + +impl Call { + /// The all-zero call — an unwritten slot. Never a real call. + pub const NOP: Call = Call { + function: FnIndex::NOP, + values: [0; MAX_VALUES_PER_CALL], + }; + + /// A call with no immediates (arguments, if any, come from the stack). + #[must_use] + pub const fn new(function: FnIndex) -> Self { + Self { + function, + values: [0; MAX_VALUES_PER_CALL], + } + } + + /// A call with one immediate — the [`LaneShape::Pairs`] shape: + /// `WAIT:10`, `REPEAT:4`, `VAR_GET:slot`. + #[must_use] + pub const fn with_value(function: FnIndex, v0: u8) -> Self { + Self { + function, + values: [v0, 0, 0], + } + } + + /// A call with up to three immediates. + #[must_use] + pub const fn with_values(function: FnIndex, values: [u8; MAX_VALUES_PER_CALL]) -> Self { + Self { function, values } + } + + /// Is this the unwritten slot? (All bytes zero — the zero-fallback.) + #[must_use] + pub const fn is_nop(&self) -> bool { + self.function.0 == 0 && self.values[0] == 0 && self.values[1] == 0 && self.values[2] == 0 + } + + /// Can `shape` store this call without dropping a value byte? + /// + /// True iff every value byte beyond the shape's + /// [`values_per_call`](LaneShape::values_per_call) is zero. This is the + /// guard that makes narrowing LOUD: a two-immediate call refuses to enter + /// a [`Pairs`](LaneShape::Pairs) body instead of silently truncating. + #[must_use] + pub const fn fits(&self, shape: LaneShape) -> bool { + let keep = shape.values_per_call(); + let mut i = keep; + while i < MAX_VALUES_PER_CALL { + if self.values[i] != 0 { + return false; + } + i += 1; + } + true + } +} + +// ── Function bodies ───────────────────────────────────────────────────────── + +/// Why a call could not enter a [`FunctionBody`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BodyOverflow { - /// How many operations were offered. - pub offered: usize, +pub enum BodyError { + /// The body is at its shape's call budget. The remedy is a **split into + /// two functions**, never a wider row. + Overflow { + /// How many calls were offered. + offered: usize, + /// The shape's budget ([`LaneShape::calls_per_function`]). + capacity: usize, + }, + /// The call carries a nonzero value byte the body's shape cannot store. + /// The remedy is a wider [`LaneShape`] (a different class), never silent + /// truncation. + ValueBeyondShape { + /// Position of the offending call in the offered sequence. + index: usize, + /// The shape that cannot hold it. + shape: LaneShape, + }, } -impl core::fmt::Display for BodyOverflow { +impl core::fmt::Display for BodyError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "function body of {} operations exceeds the {}-op budget \ - (30 value-slab slots × 12 payload bytes); split the function", - self.offered, OPS_PER_FUNCTION - ) + match self { + BodyError::Overflow { offered, capacity } => write!( + f, + "function body of {offered} calls exceeds the {capacity}-call \ + budget of its lane shape; split the function" + ), + BodyError::ValueBeyondShape { index, shape } => write!( + f, + "call {index} carries an immediate beyond what {shape:?} can \ + store; use a wider lane shape, never truncate" + ), + } } } -impl core::error::Error for BodyOverflow {} +impl core::error::Error for BodyError {} -/// One function's operations — exactly the payload capacity of a node's value +/// One function's calls — exactly the payload capacity of a node's value /// slab, and never more. /// -/// The cap is enforced at every entry point, so a `FunctionBody` that exists is -/// a function that fits in one 512-byte node. There is no partially-valid -/// state and no runtime surprise at write time. -/// -/// # ⚠ Opcode skeleton, not a complete program -/// -/// This carries **operations only** — no operands. `WAIT(1)` and `WAIT(10)` -/// produce identical bodies. See the crate-level *"What this does NOT yet -/// encode"* section: the operand layer is a named open decision (OGAR #235), -/// and until it lands a `FunctionBody` must not be advertised as a lossless -/// program encoding. +/// The cap is enforced at every entry point, so a `FunctionBody` that exists +/// is a function that fits in one 512-byte node. The [`LaneShape`] is fixed at +/// construction (it comes from the body's classid) and uniform across the +/// body; there is no partially-valid state and no runtime surprise at write +/// time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FunctionBody { - /// Stored as raw bytes, not `[PaletteOp; N]`, so - /// [`as_payload_bytes`](Self::as_payload_bytes) is a plain borrow of the - /// wire form — no transmute, no copy, no `unsafe`. - ops: [u8; OPS_PER_FUNCTION], + /// The gathered 360 payload bytes, execution order. Stored as raw bytes so + /// [`as_body_bytes`](Self::as_body_bytes) is a plain borrow — no + /// transmute, no copy, no `unsafe`. + bytes: [u8; BODY_BYTES], + /// How many CALLS are written (not bytes). len: u16, + /// How the lanes are carved — from the body's classid, uniform. + shape: LaneShape, } impl Default for FunctionBody { fn default() -> Self { - Self::new() + Self::new(LaneShape::Pairs) } } impl FunctionBody { - /// An empty body — every byte [`PaletteOp::NOP`]. + /// An empty body of the given shape — every byte zero ([`Call::NOP`]). #[must_use] - pub const fn new() -> Self { + pub const fn new(shape: LaneShape) -> Self { Self { - ops: [0u8; OPS_PER_FUNCTION], + bytes: [0u8; BODY_BYTES], len: 0, + shape, } } - /// Build from a slice, rejecting anything past the budget. + /// This body's lane carving. + #[must_use] + pub const fn shape(&self) -> LaneShape { + self.shape + } + + /// The shape's call budget — 180 / 120 / 90. + #[must_use] + pub const fn capacity(&self) -> usize { + self.shape.calls_per_function() + } + + /// Build from a slice, rejecting anything past the budget and any call the + /// shape cannot store losslessly. /// /// # Errors /// - /// [`BodyOverflow`] when `ops.len() > OPS_PER_FUNCTION` — split the - /// function rather than widening the row. - pub fn from_ops(ops: &[PaletteOp]) -> Result { - if ops.len() > OPS_PER_FUNCTION { - return Err(BodyOverflow { offered: ops.len() }); + /// [`BodyError::Overflow`] when `calls.len()` exceeds the shape's budget; + /// [`BodyError::ValueBeyondShape`] when a call carries an immediate the + /// shape would truncate. + pub fn from_calls(shape: LaneShape, calls: &[Call]) -> Result { + let mut body = Self::new(shape); + for (i, call) in calls.iter().enumerate() { + body.push(*call).map_err(|e| match e { + // Re-index the per-call error to the offered sequence. + BodyError::ValueBeyondShape { shape, .. } => { + BodyError::ValueBeyondShape { index: i, shape } + } + BodyError::Overflow { capacity, .. } => BodyError::Overflow { + offered: calls.len(), + capacity, + }, + })?; } - let mut body = Self::new(); - let mut written = 0usize; - for (slot, op) in body.ops.iter_mut().zip(ops) { - *slot = op.0; - written += 1; - } - // Count what was COPIED, never what was offered: `len` indexes a - // fixed 360-byte array, so deriving it from the caller's length would - // make the guard above solely responsible for keeping `ops()` in - // bounds. Belt and braces — a future edit to the guard cannot produce - // an out-of-range `len`. - body.len = written as u16; Ok(body) } - /// Append one operation. + /// Append one call. /// /// # Errors /// - /// [`BodyOverflow`] when the body is already full. - pub fn push(&mut self, op: PaletteOp) -> Result<(), BodyOverflow> { + /// [`BodyError::Overflow`] when the body is at its shape's budget; + /// [`BodyError::ValueBeyondShape`] when the call carries an immediate the + /// shape would truncate. A failed push does not mutate the body. + pub fn push(&mut self, call: Call) -> Result<(), BodyError> { let n = self.len as usize; - if n >= OPS_PER_FUNCTION { - return Err(BodyOverflow { offered: n + 1 }); + let capacity = self.shape.calls_per_function(); + if n >= capacity { + return Err(BodyError::Overflow { + offered: n + 1, + capacity, + }); + } + if !call.fits(self.shape) { + return Err(BodyError::ValueBeyondShape { + index: n, + shape: self.shape, + }); + } + let bpc = self.shape.bytes_per_call(); + let at = n * bpc; + self.bytes[at] = call.function.0; + let vals = self.shape.values_per_call(); + let mut v = 0usize; + while v < vals { + self.bytes[at + 1 + v] = call.values[v]; + v += 1; } - self.ops[n] = op.0; self.len = (n + 1) as u16; Ok(()) } - /// The operations written so far. (`impl Iterator` is already `#[must_use]` - /// — a second attribute here is `clippy::double_must_use`.) - pub fn ops(&self) -> impl Iterator + '_ { - self.ops[..self.len as usize].iter().copied().map(PaletteOp) + /// The call at `index`, or `None` past [`len`](Self::len). + /// + /// Value bytes beyond the shape's width read as zero — the call comes back + /// exactly as [`push`](Self::push) accepted it. + #[must_use] + pub fn call(&self, index: usize) -> Option { + (index < self.len as usize).then(|| self.call_unchecked(index)) } - /// The operation at `index`, or `None` past [`len`](Self::len). - #[must_use] - pub fn op(&self, index: usize) -> Option { - (index < self.len as usize).then(|| PaletteOp(self.ops[index])) + fn call_unchecked(&self, index: usize) -> Call { + let bpc = self.shape.bytes_per_call(); + let at = index * bpc; + let mut values = [0u8; MAX_VALUES_PER_CALL]; + let vals = self.shape.values_per_call(); + values[..vals].copy_from_slice(&self.bytes[at + 1..at + 1 + vals]); + Call { + function: FnIndex(self.bytes[at]), + values, + } } - /// How many operations are written. + /// The calls written so far, in execution order. + pub fn calls(&self) -> impl Iterator + '_ { + (0..self.len as usize).map(|i| self.call_unchecked(i)) + } + + /// How many calls are written. #[must_use] pub const fn len(&self) -> usize { self.len as usize @@ -586,99 +837,117 @@ impl FunctionBody { self.len == 0 } - /// Remaining budget before a split is required. + /// Remaining call budget before a split is required. #[must_use] pub const fn remaining(&self) -> usize { - OPS_PER_FUNCTION - self.len as usize + self.shape.calls_per_function() - self.len as usize } - /// The body's 360 operation bytes in **execution order**, `NOP`-padded past - /// [`len`](Self::len). + /// The body's 360 payload bytes in **execution order**, zero-padded past + /// the written calls. /// /// # This is the GATHERED form, NOT the slab layout /// /// These bytes are **not** contiguous in a node's value slab. The slab is - /// `CONTENT_SLOTS × 16` = 480 bytes of `classid(4) + payload(12)` facets, so - /// operation `i` lives at slab offset - /// [`slab_offset(i)`](Self::slab_offset) — stride 16, `+4` into each facet, - /// never at offset `i`. + /// `CONTENT_SLOTS × 16` = 480 bytes of `classid(4) + payload(12)` facets, + /// so call `i` starts at slab offset + /// [`call_slab_offset(shape, i)`](Self::call_slab_offset) — stride 16, + /// `+4` into each facet, never at `i × bytes_per_call`. /// /// Copying this array over the front of a slab would overwrite the first /// 22½ facets' classids *and* payloads. Use /// [`write_into_value_slab`](Self::write_into_value_slab) to place it, or - /// [`op_in_slab`] to read one operation in place without gathering at all. + /// [`call_in_slab`] to read one call in place without gathering at all. #[must_use] - pub const fn as_ops_bytes(&self) -> &[u8; OPS_PER_FUNCTION] { - &self.ops + pub const fn as_body_bytes(&self) -> &[u8; BODY_BYTES] { + &self.bytes } - /// Byte offset of operation `index` within a node's **480-byte value slab**. + /// Byte offset where call `index` STARTS within a node's **480-byte value + /// slab**, under `shape`. /// - /// `(index / 12) * 16 + 4 + (index % 12)` — pick the facet, skip its - /// 4-byte classid, then index within its 12-byte payload lane. + /// `(index / calls_per_lane) × 16 + 4 + (index % calls_per_lane) × + /// bytes_per_call` — pick the lane, skip its 4-byte classid, then step by + /// whole calls within its 12-byte payload. No call straddles a lane + /// boundary: every shape divides 12 exactly. /// /// # Panics /// - /// When `index >= OPS_PER_FUNCTION`. + /// When `index >= shape.calls_per_function()`. #[must_use] - pub const fn slab_offset(index: usize) -> usize { - assert!(index < OPS_PER_FUNCTION, "operation index out of range"); - let facet = index / PAYLOAD_BYTES_PER_SLOT; - let within = index % PAYLOAD_BYTES_PER_SLOT; - facet * SLOT_STRIDE + CLASSID_BYTES + within + pub const fn call_slab_offset(shape: LaneShape, index: usize) -> usize { + assert!( + index < shape.calls_per_function(), + "call index out of range for this lane shape" + ); + let cpl = shape.calls_per_lane(); + let lane = index / cpl; + let within = (index % cpl) * shape.bytes_per_call(); + lane * SLOT_STRIDE + CLASSID_BYTES + within } /// Scatter the body into a node's value slab, writing **only** the 12-byte /// payload lane of each facet. /// /// The 4-byte classid of every facet is left untouched — this writes the - /// operations, never the addressing. + /// calls, never the addressing. (The scatter is shape-independent: gathered + /// bytes map lane-linearly, `bytes[l×12..][..12] → slab[l×16+4..][..12]`.) pub fn write_into_value_slab(&self, slab: &mut [u8; VALUE_SLAB_LEN]) { - for facet in 0..CONTENT_SLOTS { - let src = facet * PAYLOAD_BYTES_PER_SLOT; - let dst = facet * SLOT_STRIDE + CLASSID_BYTES; + for lane in 0..CONTENT_SLOTS { + let src = lane * PAYLOAD_BYTES_PER_SLOT; + let dst = lane * SLOT_STRIDE + CLASSID_BYTES; slab[dst..dst + PAYLOAD_BYTES_PER_SLOT] - .copy_from_slice(&self.ops[src..src + PAYLOAD_BYTES_PER_SLOT]); + .copy_from_slice(&self.bytes[src..src + PAYLOAD_BYTES_PER_SLOT]); } } /// Gather a body back out of a node's value slab — the inverse of /// [`write_into_value_slab`](Self::write_into_value_slab). /// - /// `len` is recovered as the position after the last non-`NOP` byte, since - /// the wire form carries no length field (that is the whole point of the - /// zero-fallback padding — the 362-byte in-memory `FunctionBody` has a - /// `u16 len`, the 360-byte wire form deliberately does not). + /// The shape is a parameter because it is NOT in the slab — it comes from + /// the body's classid (slot purity: the payload is dumb bytes; the class + /// selects the reading). `len` is recovered as the position after the last + /// non-[`NOP`](Call::NOP) call, since the wire form carries no length + /// field — zero padding IS the length signal, which also means an interior + /// all-zero call is indistinguishable from padding and is not a valid + /// program element. #[must_use] - pub fn read_from_value_slab(slab: &[u8; VALUE_SLAB_LEN]) -> Self { - let mut body = Self::new(); - for facet in 0..CONTENT_SLOTS { - let src = facet * SLOT_STRIDE + CLASSID_BYTES; - let dst = facet * PAYLOAD_BYTES_PER_SLOT; - body.ops[dst..dst + PAYLOAD_BYTES_PER_SLOT] + pub fn read_from_value_slab(shape: LaneShape, slab: &[u8; VALUE_SLAB_LEN]) -> Self { + let mut body = Self::new(shape); + for lane in 0..CONTENT_SLOTS { + let src = lane * SLOT_STRIDE + CLASSID_BYTES; + let dst = lane * PAYLOAD_BYTES_PER_SLOT; + body.bytes[dst..dst + PAYLOAD_BYTES_PER_SLOT] .copy_from_slice(&slab[src..src + PAYLOAD_BYTES_PER_SLOT]); } - body.len = body - .ops - .iter() - .rposition(|&b| b != PaletteOp::NOP.0) - .map_or(0, |last| last as u16 + 1); + let bpc = shape.bytes_per_call(); + let last_live = (0..shape.calls_per_function()) + .rev() + .find(|&i| body.bytes[i * bpc..(i + 1) * bpc].iter().any(|&b| b != 0)); + body.len = last_live.map_or(0, |i| i as u16 + 1); body } } -/// Read one operation **in place** from a node's value slab — no gather, no -/// copy, one indexed byte read. +/// Read one call **in place** from a node's value slab — no gather, no copy, +/// `bytes_per_call` indexed reads. /// -/// This is the zero-copy read the substrate wants: a consumer that needs -/// operation `i` never materialises the other 359. +/// This is the zero-copy read the substrate wants: a consumer that needs call +/// `i` never materialises the rest of the body. /// /// # Panics /// -/// When `index >= OPS_PER_FUNCTION`. +/// When `index >= shape.calls_per_function()`. #[must_use] -pub const fn op_in_slab(slab: &[u8; VALUE_SLAB_LEN], index: usize) -> PaletteOp { - PaletteOp(slab[FunctionBody::slab_offset(index)]) +pub fn call_in_slab(slab: &[u8; VALUE_SLAB_LEN], shape: LaneShape, index: usize) -> Call { + let at = FunctionBody::call_slab_offset(shape, index); + let mut values = [0u8; MAX_VALUES_PER_CALL]; + let vals = shape.values_per_call(); + values[..vals].copy_from_slice(&slab[at + 1..at + 1 + vals]); + Call { + function: FnIndex(slab[at]), + values, + } } /// How block content is partitioned across SoA tables. @@ -696,7 +965,7 @@ pub const fn op_in_slab(slab: &[u8; VALUE_SLAB_LEN], index: usize) -> PaletteOp pub enum SoaSplit { /// The function registry — one row per function, addressed by identity. Inventory, - /// One function's body — up to [`OPS_PER_FUNCTION`] palette bytes. + /// One function's body — up to [`LaneShape::calls_per_function`] calls. Content, } @@ -747,120 +1016,225 @@ mod tests { } #[test] - fn the_360_budget_is_derived_from_the_node_layout() { - // 512-byte node = key(16) | edges(16) | value(480); value = 30 facet - // slots of classid(4)+12. If any of those change, this must fail rather - // than silently re-budget. + fn the_byte_budget_is_derived_from_the_node_layout() { + // 512-byte node = key(16) | reserved(16) | value(480); value = 30 lanes + // of classid(4)+12. If any of those change, this must fail rather than + // silently re-budget. assert_eq!(CONTENT_SLOTS, 480 / 16); assert_eq!(PAYLOAD_BYTES_PER_SLOT, 16 - 4); - assert_eq!(OPS_PER_FUNCTION, 360); + assert_eq!(BODY_BYTES, 360); + // Per-shape call budgets: same 360 bytes, different carving. + assert_eq!(LaneShape::Pairs.calls_per_function(), 180); + assert_eq!(LaneShape::Triples.calls_per_function(), 120); + assert_eq!(LaneShape::Quads.calls_per_function(), 90); + for shape in LaneShape::ALL { + // Every shape divides a lane exactly and spends exactly 360 bytes. + assert_eq!(shape.calls_per_lane() * shape.bytes_per_call(), 12); + assert_eq!( + shape.calls_per_function() * shape.bytes_per_call(), + BODY_BYTES + ); + assert_eq!(shape.values_per_call(), shape.bytes_per_call() - 1); + } } #[test] - fn a_body_at_the_budget_is_accepted_and_one_past_it_is_rejected() { - // Two-sided: the cap must admit exactly 360 and refuse 361 — a cap that - // only ever rejects, or only ever accepts, carries no information. - let exact = vec![PaletteOp::ADD; OPS_PER_FUNCTION]; - let body = FunctionBody::from_ops(&exact).expect("360 ops must fit"); - assert_eq!(body.len(), OPS_PER_FUNCTION); - assert_eq!(body.remaining(), 0); - - let over = vec![PaletteOp::ADD; OPS_PER_FUNCTION + 1]; - let err = FunctionBody::from_ops(&over).expect_err("361 ops must not fit"); - assert_eq!(err.offered, OPS_PER_FUNCTION + 1); + fn a_body_at_capacity_is_accepted_and_one_past_is_rejected_in_every_shape() { + // Two-sided per shape: the cap must admit exactly the budget and refuse + // one more — a cap that only rejects, or only accepts, carries no + // information. + for shape in LaneShape::ALL { + let cap = shape.calls_per_function(); + let exact = vec![Call::new(FnIndex::ADD); cap]; + let body = FunctionBody::from_calls(shape, &exact) + .unwrap_or_else(|e| panic!("{cap} calls must fit {shape:?}: {e}")); + assert_eq!(body.len(), cap); + assert_eq!(body.remaining(), 0); + assert_eq!(body.capacity(), cap); + + let over = vec![Call::new(FnIndex::ADD); cap + 1]; + match FunctionBody::from_calls(shape, &over) { + Err(BodyError::Overflow { offered, capacity }) => { + assert_eq!(offered, cap + 1); + assert_eq!(capacity, cap); + } + other => panic!( + "{} calls in {shape:?} must overflow, got {other:?}", + cap + 1 + ), + } + } } #[test] - fn push_enforces_the_same_budget_as_from_ops() { - // The cap must not be reachable by a back door: filling one op at a + fn push_enforces_the_same_budget_as_from_calls() { + // The cap must not be reachable by a back door: filling one call at a // time has to stop at exactly the same place. - let mut body = FunctionBody::new(); - for _ in 0..OPS_PER_FUNCTION { - body.push(PaletteOp::MUL).expect("within budget"); + let mut body = FunctionBody::new(LaneShape::Pairs); + for _ in 0..LaneShape::Pairs.calls_per_function() { + body.push(Call::new(FnIndex::MUL)).expect("within budget"); } - assert_eq!(body.len(), OPS_PER_FUNCTION); - let err = body.push(PaletteOp::MUL).expect_err("the 361st must fail"); - assert_eq!(err.offered, OPS_PER_FUNCTION + 1); + assert_eq!(body.len(), 180); + let err = body + .push(Call::new(FnIndex::MUL)) + .expect_err("the 181st must fail"); + assert!(matches!( + err, + BodyError::Overflow { + offered: 181, + capacity: 180 + } + )); // and the failed push must not have mutated the body - assert_eq!(body.len(), OPS_PER_FUNCTION); + assert_eq!(body.len(), 180); } #[test] - fn ops_bytes_are_execution_order_nop_padded() { - let body = FunctionBody::from_ops(&[PaletteOp::IF, PaletteOp::LT]).unwrap(); - let bytes = body.as_ops_bytes(); - assert_eq!(bytes.len(), OPS_PER_FUNCTION); - assert_eq!(bytes[0], PaletteOp::IF.0); - assert_eq!(bytes[1], PaletteOp::LT.0); - // Everything past len is the zero-fallback, so a partially-filled body - // needs no length field on the wire. - assert!(bytes[2..].iter().all(|&b| b == PaletteOp::NOP.0)); + fn a_call_too_wide_for_the_shape_is_rejected_not_truncated() { + // The narrowing guard, two-sided: the SAME call must be refused by the + // shape that would drop a value byte and accepted by the shape that + // holds it. Silent truncation is the defect this exists to prevent. + let two_immediates = Call::with_values(FnIndex::CONSTRAIN, [10, 20, 0]); + let three_immediates = Call::with_values(FnIndex::CONSTRAIN, [10, 20, 30]); + + let mut pairs = FunctionBody::new(LaneShape::Pairs); + match pairs.push(two_immediates) { + Err(BodyError::ValueBeyondShape { index: 0, shape }) => { + assert_eq!(shape, LaneShape::Pairs); + } + other => panic!("two immediates must not fit Pairs, got {other:?}"), + } + assert!(pairs.is_empty(), "a rejected push must not mutate the body"); + + let mut triples = FunctionBody::new(LaneShape::Triples); + triples + .push(two_immediates) + .expect("two immediates fit Triples"); + assert_eq!(triples.call(0), Some(two_immediates)); + match triples.push(three_immediates) { + Err(BodyError::ValueBeyondShape { index: 1, shape }) => { + assert_eq!(shape, LaneShape::Triples); + } + other => panic!("three immediates must not fit Triples, got {other:?}"), + } + + let mut quads = FunctionBody::new(LaneShape::Quads); + quads + .push(three_immediates) + .expect("three immediates fit Quads"); + assert_eq!(quads.call(0), Some(three_immediates)); + + // from_calls applies the same guard and reports the offending index. + let seq = [Call::new(FnIndex::ADD), two_immediates]; + match FunctionBody::from_calls(LaneShape::Pairs, &seq) { + Err(BodyError::ValueBeyondShape { index: 1, .. }) => {} + other => panic!("from_calls must index the offending call, got {other:?}"), + } } #[test] - fn the_slab_interleaves_classids_so_ops_are_not_contiguous() { - // The defect this guards: the gathered array is NOT the slab layout. - // Op i lives at stride 16, +4 into each facet — never at offset i. - assert_eq!(FunctionBody::slab_offset(0), 4); - assert_eq!(FunctionBody::slab_offset(11), 15); // last byte of facet 0 - assert_eq!(FunctionBody::slab_offset(12), 20); // facet 1 skips its classid - assert_eq!(FunctionBody::slab_offset(OPS_PER_FUNCTION - 1), 479); - - // Anti-vacuity: the mapping must be a genuine permutation, not identity. - let identity_matches = (0..OPS_PER_FUNCTION) - .filter(|&i| FunctionBody::slab_offset(i) == i) - .count(); - assert_eq!( - identity_matches, 0, - "no operation may sit at its own index; the slab interleaves" - ); + fn body_bytes_are_execution_order_zero_padded() { + // `5 + 3` under the stack discipline: (NUMBER:5) (NUMBER:3) (ADD:_). + let body = FunctionBody::from_calls( + LaneShape::Pairs, + &[ + Call::with_value(FnIndex::NUMBER, 5), + Call::with_value(FnIndex::NUMBER, 3), + Call::new(FnIndex::ADD), + ], + ) + .unwrap(); + let bytes = body.as_body_bytes(); + assert_eq!(bytes.len(), BODY_BYTES); + assert_eq!(&bytes[..6], &[0x46, 5, 0x46, 3, 0x40, 0]); + // Everything past the written calls is the zero-fallback, so a + // partially-filled body needs no length field on the wire. + assert!(bytes[6..].iter().all(|&b| b == 0)); + // And the calls read back exactly as pushed. + let calls: Vec = body.calls().collect(); + assert_eq!(calls.len(), 3); + assert_eq!(calls[0], Call::with_value(FnIndex::NUMBER, 5)); + assert_eq!(calls[2], Call::new(FnIndex::ADD)); + } - // Every offset lands inside a payload lane, never on a classid byte. - for i in 0..OPS_PER_FUNCTION { - let off = FunctionBody::slab_offset(i); - assert!( - off % SLOT_STRIDE >= CLASSID_BYTES, - "op {i} at slab offset {off} lands on a classid byte" - ); - assert!(off < VALUE_SLAB_LEN); + #[test] + fn the_slab_interleaves_classids_so_calls_are_not_contiguous() { + // The defect this guards: the gathered array is NOT the slab layout. + // Call i starts at (i/cpl)*16 + 4 + (i%cpl)*bpc — never at i*bpc. + assert_eq!(FunctionBody::call_slab_offset(LaneShape::Pairs, 0), 4); + assert_eq!(FunctionBody::call_slab_offset(LaneShape::Pairs, 5), 14); // last pair, lane 0 + assert_eq!(FunctionBody::call_slab_offset(LaneShape::Pairs, 6), 20); // lane 1 skips classid + assert_eq!(FunctionBody::call_slab_offset(LaneShape::Pairs, 179), 478); + assert_eq!(FunctionBody::call_slab_offset(LaneShape::Quads, 89), 476); + + for shape in LaneShape::ALL { + let bpc = shape.bytes_per_call(); + for i in 0..shape.calls_per_function() { + let off = FunctionBody::call_slab_offset(shape, i); + // Anti-vacuity: a genuine permutation, never identity — the + // gathered offset i*bpc must never equal the slab offset. + assert_ne!( + off, + i * bpc, + "{shape:?} call {i} sits at its own gathered offset" + ); + // The whole call lands inside one payload lane: past the + // classid, and not running off the lane's end (no straddle). + assert!(off % SLOT_STRIDE >= CLASSID_BYTES); + assert!(off % SLOT_STRIDE + bpc <= SLOT_STRIDE); + assert!(off + bpc <= VALUE_SLAB_LEN); + } } } #[test] fn scatter_gather_round_trips_and_never_touches_a_classid() { - let ops: Vec = (0..40u8).map(|i| PaletteOp(i.max(1))).collect(); - let body = FunctionBody::from_ops(&ops).unwrap(); - - // Pre-stamp every facet's classid with a sentinel; the write must - // leave all 120 of those bytes untouched — it writes operations, not - // addressing. - let mut slab = [0u8; VALUE_SLAB_LEN]; - for facet in 0..CONTENT_SLOTS { - for b in 0..CLASSID_BYTES { - slab[facet * SLOT_STRIDE + b] = 0xC1; + for shape in LaneShape::ALL { + // Distinct function bytes, shape-widest immediates. + let mut vals = [0u8; MAX_VALUES_PER_CALL]; + vals[..shape.values_per_call()].copy_from_slice(&[7, 9, 11][..shape.values_per_call()]); + let calls: Vec = (1..=40u8) + .map(|i| Call::with_values(FnIndex(i), vals)) + .collect(); + let body = FunctionBody::from_calls(shape, &calls).unwrap(); + + // Pre-stamp every lane's classid with a sentinel; the write must + // leave all 120 of those bytes untouched — it writes calls, not + // addressing. + let mut slab = [0u8; VALUE_SLAB_LEN]; + for lane in 0..CONTENT_SLOTS { + for b in 0..CLASSID_BYTES { + slab[lane * SLOT_STRIDE + b] = 0xC1; + } + } + body.write_into_value_slab(&mut slab); + + for lane in 0..CONTENT_SLOTS { + for b in 0..CLASSID_BYTES { + assert_eq!( + slab[lane * SLOT_STRIDE + b], + 0xC1, + "{shape:?}: lane {lane} classid byte {b} was overwritten" + ); + } } - } - body.write_into_value_slab(&mut slab); - for facet in 0..CONTENT_SLOTS { - for b in 0..CLASSID_BYTES { + // In-place read agrees with the pushed calls, call by call. + for (i, call) in calls.iter().enumerate() { assert_eq!( - slab[facet * SLOT_STRIDE + b], - 0xC1, - "facet {facet} classid byte {b} was overwritten" + call_in_slab(&slab, shape, i), + *call, + "{shape:?}: call {i} misplaced in the slab" ); } - } - // In-place read agrees with the gathered order, op by op. - for (i, op) in ops.iter().enumerate() { - assert_eq!(op_in_slab(&slab, i), *op, "op {i} misplaced in the slab"); + // And the gather is the exact inverse — shape supplied by the + // caller (it lives in the classid, never in the slab). + let back = FunctionBody::read_from_value_slab(shape, &slab); + assert_eq!(back.len(), calls.len(), "{shape:?}: len not recovered"); + assert_eq!(back.as_body_bytes(), body.as_body_bytes()); + assert_eq!(back.shape(), shape); } - - // And the gather is the exact inverse. - let back = FunctionBody::read_from_value_slab(&slab); - assert_eq!(back.len(), ops.len()); - assert_eq!(back.as_ops_bytes(), body.as_ops_bytes()); } #[test] @@ -869,59 +1243,86 @@ mod tests { // the gathered 360 bytes as the front of the slab. It must be // observably different from the correct scatter, or the distinction // this API draws carries no information. - let ops: Vec = (1..=30u8).map(PaletteOp).collect(); - let body = FunctionBody::from_ops(&ops).unwrap(); + let calls: Vec = (1..=30u8) + .map(|i| Call::with_value(FnIndex(i), i)) + .collect(); + let body = FunctionBody::from_calls(LaneShape::Pairs, &calls).unwrap(); let mut correct = [0u8; VALUE_SLAB_LEN]; body.write_into_value_slab(&mut correct); let mut naive = [0u8; VALUE_SLAB_LEN]; - naive[..OPS_PER_FUNCTION].copy_from_slice(body.as_ops_bytes()); + naive[..BODY_BYTES].copy_from_slice(body.as_body_bytes()); assert_ne!( correct, naive, "scatter and contiguous copy must not coincide" ); - // Concretely: the naive copy puts op 0 on facet 0's FIRST classid byte. - assert_eq!(naive[0], ops[0].0); - assert_eq!(correct[0], 0, "facet 0's classid must stay untouched"); - assert_eq!(correct[FunctionBody::slab_offset(0)], ops[0].0); + // Concretely: the naive copy puts call 0's function byte on lane 0's + // FIRST classid byte. + assert_eq!(naive[0], calls[0].function.0); + assert_eq!(correct[0], 0, "lane 0's classid must stay untouched"); + assert_eq!( + correct[FunctionBody::call_slab_offset(LaneShape::Pairs, 0)], + calls[0].function.0 + ); } #[test] fn in_memory_body_is_larger_than_the_wire_form() { - // 362 = [u8; 360] + u16 len. The len is deliberately NOT written to the - // slab — zero padding is the length signal — so the wire form is - // exactly 360 and this gap must stay visible rather than surprising a - // consumer that assumed size_of == payload size. - assert_eq!(core::mem::size_of::(), 362); - assert_eq!(body_wire_len(), OPS_PER_FUNCTION); - assert_eq!(OPS_PER_FUNCTION, 360); + // [u8; 360] + u16 len + LaneShape(1 B) → 363, padded to 364 by the u16's + // alignment. Neither len nor shape is written to the slab — zero padding + // is the length signal, the classid is the shape signal — so the wire + // form is exactly 360 and this gap must stay visible rather than + // surprising a consumer that assumed size_of == payload size. + assert_eq!(core::mem::size_of::(), 364); + assert_eq!(BODY_BYTES, 360); assert_eq!(VALUE_SLAB_LEN, 480); - assert_eq!( - VALUE_SLAB_LEN - OPS_PER_FUNCTION, - CONTENT_SLOTS * CLASSID_BYTES - ); + assert_eq!(VALUE_SLAB_LEN - BODY_BYTES, CONTENT_SLOTS * CLASSID_BYTES); } - const fn body_wire_len() -> usize { - core::mem::size_of::<[u8; OPS_PER_FUNCTION]>() + #[test] + fn len_recovery_finds_the_last_live_call_per_shape() { + // A body whose LAST call is function-only (all value bytes zero) must + // still recover its full length — the liveness test is "any nonzero + // byte in the call", not "nonzero value". + for shape in LaneShape::ALL { + let calls = [ + Call::with_value(FnIndex::NUMBER, 9), + Call::new(FnIndex::ADD), // function byte only + ]; + let body = FunctionBody::from_calls(shape, &calls).unwrap(); + let mut slab = [0u8; VALUE_SLAB_LEN]; + body.write_into_value_slab(&mut slab); + let back = FunctionBody::read_from_value_slab(shape, &slab); + assert_eq!(back.len(), 2, "{shape:?}: trailing bare call lost"); + assert_eq!(back.call(1), Some(Call::new(FnIndex::ADD))); + } + // And an empty body recovers as empty (the silence half). + let empty = [0u8; VALUE_SLAB_LEN]; + for shape in LaneShape::ALL { + assert_eq!( + FunctionBody::read_from_value_slab(shape, &empty).len(), + 0, + "{shape:?}: empty slab must read as empty" + ); + } } #[test] fn shared_core_and_device_family_partition_the_palette() { // Can-fire AND can-stay-silent on the same predicate: a classifier that // answers the same way for everything is worthless. - assert!(PaletteOp::LT.is_shared_core()); - assert!(!PaletteOp::LT.is_device_family()); + assert!(FnIndex::LT.is_shared_core()); + assert!(!FnIndex::LT.is_device_family()); - let device = PaletteOp(DEVICE_FAMILY_FLOOR); + let device = FnIndex(DEVICE_FAMILY_FLOOR); assert!(device.is_device_family()); assert!(!device.is_shared_core()); // NOP is not an operation at all — neither bucket claims it. - assert!(!PaletteOp::NOP.is_shared_core()); - assert!(!PaletteOp::NOP.is_device_family()); + assert!(!FnIndex::NOP.is_shared_core()); + assert!(!FnIndex::NOP.is_device_family()); } #[test] @@ -929,102 +1330,102 @@ mod tests { // The whole value of the palette is that two frontends land on ONE // slot. A duplicate here would silently merge two operations; a slot at // or above the device floor would misclassify a shared op. - let named: &[(&str, PaletteOp)] = &[ - ("IF", PaletteOp::IF), - ("IF_ELSE", PaletteOp::IF_ELSE), - ("REPEAT", PaletteOp::REPEAT), - ("REPEAT_UNTIL", PaletteOp::REPEAT_UNTIL), - ("WHILE", PaletteOp::WHILE), - ("FOREVER", PaletteOp::FOREVER), - ("FOR_EACH", PaletteOp::FOR_EACH), - ("FOR_RANGE", PaletteOp::FOR_RANGE), - ("WAIT", PaletteOp::WAIT), - ("WAIT_UNTIL", PaletteOp::WAIT_UNTIL), - ("STOP", PaletteOp::STOP), - ("BREAK", PaletteOp::BREAK), - ("CONTINUE", PaletteOp::CONTINUE), - ("RETURN", PaletteOp::RETURN), - ("AND", PaletteOp::AND), - ("OR", PaletteOp::OR), - ("NOT", PaletteOp::NOT), - ("TRUE", PaletteOp::TRUE), - ("FALSE", PaletteOp::FALSE), - ("NULL", PaletteOp::NULL), - ("TERNARY", PaletteOp::TERNARY), - ("EQ", PaletteOp::EQ), - ("NEQ", PaletteOp::NEQ), - ("LT", PaletteOp::LT), - ("LTE", PaletteOp::LTE), - ("GT", PaletteOp::GT), - ("GTE", PaletteOp::GTE), - ("ADD", PaletteOp::ADD), - ("SUB", PaletteOp::SUB), - ("MUL", PaletteOp::MUL), - ("DIV", PaletteOp::DIV), - ("POW", PaletteOp::POW), - ("MOD", PaletteOp::MOD), - ("NUMBER", PaletteOp::NUMBER), - ("ABS", PaletteOp::ABS), - ("NEG", PaletteOp::NEG), - ("ROUND", PaletteOp::ROUND), - ("FLOOR", PaletteOp::FLOOR), - ("CEIL", PaletteOp::CEIL), - ("SQRT", PaletteOp::SQRT), - ("LN", PaletteOp::LN), - ("LOG10", PaletteOp::LOG10), - ("EXP_E", PaletteOp::EXP_E), - ("EXP_10", PaletteOp::EXP_10), - ("SIN", PaletteOp::SIN), - ("COS", PaletteOp::COS), - ("TAN", PaletteOp::TAN), - ("ASIN", PaletteOp::ASIN), - ("ACOS", PaletteOp::ACOS), - ("ATAN", PaletteOp::ATAN), - ("ATAN2", PaletteOp::ATAN2), - ("RANDOM_INT", PaletteOp::RANDOM_INT), - ("RANDOM_FLOAT", PaletteOp::RANDOM_FLOAT), - ("CONSTRAIN", PaletteOp::CONSTRAIN), - ("NUMBER_PROPERTY", PaletteOp::NUMBER_PROPERTY), - ("CONSTANT", PaletteOp::CONSTANT), - ("ON_LIST", PaletteOp::ON_LIST), - ("TEXT", PaletteOp::TEXT), - ("JOIN", PaletteOp::JOIN), - ("LENGTH", PaletteOp::LENGTH), - ("CHAR_AT", PaletteOp::CHAR_AT), - ("INDEX_OF", PaletteOp::INDEX_OF), - ("IS_EMPTY", PaletteOp::IS_EMPTY), - ("SUBSTRING", PaletteOp::SUBSTRING), - ("CHANGE_CASE", PaletteOp::CHANGE_CASE), - ("TRIM", PaletteOp::TRIM), - ("CONTAINS", PaletteOp::CONTAINS), - ("APPEND", PaletteOp::APPEND), - ("PRINT", PaletteOp::PRINT), - ("PROMPT", PaletteOp::PROMPT), - ("COUNT", PaletteOp::COUNT), - ("REPLACE", PaletteOp::REPLACE), - ("REVERSE", PaletteOp::REVERSE), - ("LIST_EMPTY", PaletteOp::LIST_EMPTY), - ("LIST_WITH", PaletteOp::LIST_WITH), - ("LIST_REPEAT", PaletteOp::LIST_REPEAT), - ("LIST_LENGTH", PaletteOp::LIST_LENGTH), - ("LIST_IS_EMPTY", PaletteOp::LIST_IS_EMPTY), - ("LIST_INDEX_OF", PaletteOp::LIST_INDEX_OF), - ("LIST_GET", PaletteOp::LIST_GET), - ("LIST_SET", PaletteOp::LIST_SET), - ("LIST_INSERT", PaletteOp::LIST_INSERT), - ("LIST_ADD", PaletteOp::LIST_ADD), - ("LIST_DELETE", PaletteOp::LIST_DELETE), - ("LIST_DELETE_ALL", PaletteOp::LIST_DELETE_ALL), - ("LIST_SUBLIST", PaletteOp::LIST_SUBLIST), - ("LIST_SPLIT", PaletteOp::LIST_SPLIT), - ("LIST_SORT", PaletteOp::LIST_SORT), - ("LIST_CONTAINS", PaletteOp::LIST_CONTAINS), - ("VAR_GET", PaletteOp::VAR_GET), - ("VAR_SET", PaletteOp::VAR_SET), - ("VAR_CHANGE", PaletteOp::VAR_CHANGE), - ("PROC_DEF", PaletteOp::PROC_DEF), - ("PROC_CALL", PaletteOp::PROC_CALL), - ("PROC_ARG", PaletteOp::PROC_ARG), + let named: &[(&str, FnIndex)] = &[ + ("IF", FnIndex::IF), + ("IF_ELSE", FnIndex::IF_ELSE), + ("REPEAT", FnIndex::REPEAT), + ("REPEAT_UNTIL", FnIndex::REPEAT_UNTIL), + ("WHILE", FnIndex::WHILE), + ("FOREVER", FnIndex::FOREVER), + ("FOR_EACH", FnIndex::FOR_EACH), + ("FOR_RANGE", FnIndex::FOR_RANGE), + ("WAIT", FnIndex::WAIT), + ("WAIT_UNTIL", FnIndex::WAIT_UNTIL), + ("STOP", FnIndex::STOP), + ("BREAK", FnIndex::BREAK), + ("CONTINUE", FnIndex::CONTINUE), + ("RETURN", FnIndex::RETURN), + ("AND", FnIndex::AND), + ("OR", FnIndex::OR), + ("NOT", FnIndex::NOT), + ("TRUE", FnIndex::TRUE), + ("FALSE", FnIndex::FALSE), + ("NULL", FnIndex::NULL), + ("TERNARY", FnIndex::TERNARY), + ("EQ", FnIndex::EQ), + ("NEQ", FnIndex::NEQ), + ("LT", FnIndex::LT), + ("LTE", FnIndex::LTE), + ("GT", FnIndex::GT), + ("GTE", FnIndex::GTE), + ("ADD", FnIndex::ADD), + ("SUB", FnIndex::SUB), + ("MUL", FnIndex::MUL), + ("DIV", FnIndex::DIV), + ("POW", FnIndex::POW), + ("MOD", FnIndex::MOD), + ("NUMBER", FnIndex::NUMBER), + ("ABS", FnIndex::ABS), + ("NEG", FnIndex::NEG), + ("ROUND", FnIndex::ROUND), + ("FLOOR", FnIndex::FLOOR), + ("CEIL", FnIndex::CEIL), + ("SQRT", FnIndex::SQRT), + ("LN", FnIndex::LN), + ("LOG10", FnIndex::LOG10), + ("EXP_E", FnIndex::EXP_E), + ("EXP_10", FnIndex::EXP_10), + ("SIN", FnIndex::SIN), + ("COS", FnIndex::COS), + ("TAN", FnIndex::TAN), + ("ASIN", FnIndex::ASIN), + ("ACOS", FnIndex::ACOS), + ("ATAN", FnIndex::ATAN), + ("ATAN2", FnIndex::ATAN2), + ("RANDOM_INT", FnIndex::RANDOM_INT), + ("RANDOM_FLOAT", FnIndex::RANDOM_FLOAT), + ("CONSTRAIN", FnIndex::CONSTRAIN), + ("NUMBER_PROPERTY", FnIndex::NUMBER_PROPERTY), + ("CONSTANT", FnIndex::CONSTANT), + ("ON_LIST", FnIndex::ON_LIST), + ("TEXT", FnIndex::TEXT), + ("JOIN", FnIndex::JOIN), + ("LENGTH", FnIndex::LENGTH), + ("CHAR_AT", FnIndex::CHAR_AT), + ("INDEX_OF", FnIndex::INDEX_OF), + ("IS_EMPTY", FnIndex::IS_EMPTY), + ("SUBSTRING", FnIndex::SUBSTRING), + ("CHANGE_CASE", FnIndex::CHANGE_CASE), + ("TRIM", FnIndex::TRIM), + ("CONTAINS", FnIndex::CONTAINS), + ("APPEND", FnIndex::APPEND), + ("PRINT", FnIndex::PRINT), + ("PROMPT", FnIndex::PROMPT), + ("COUNT", FnIndex::COUNT), + ("REPLACE", FnIndex::REPLACE), + ("REVERSE", FnIndex::REVERSE), + ("LIST_EMPTY", FnIndex::LIST_EMPTY), + ("LIST_WITH", FnIndex::LIST_WITH), + ("LIST_REPEAT", FnIndex::LIST_REPEAT), + ("LIST_LENGTH", FnIndex::LIST_LENGTH), + ("LIST_IS_EMPTY", FnIndex::LIST_IS_EMPTY), + ("LIST_INDEX_OF", FnIndex::LIST_INDEX_OF), + ("LIST_GET", FnIndex::LIST_GET), + ("LIST_SET", FnIndex::LIST_SET), + ("LIST_INSERT", FnIndex::LIST_INSERT), + ("LIST_ADD", FnIndex::LIST_ADD), + ("LIST_DELETE", FnIndex::LIST_DELETE), + ("LIST_DELETE_ALL", FnIndex::LIST_DELETE_ALL), + ("LIST_SUBLIST", FnIndex::LIST_SUBLIST), + ("LIST_SPLIT", FnIndex::LIST_SPLIT), + ("LIST_SORT", FnIndex::LIST_SORT), + ("LIST_CONTAINS", FnIndex::LIST_CONTAINS), + ("VAR_GET", FnIndex::VAR_GET), + ("VAR_SET", FnIndex::VAR_SET), + ("VAR_CHANGE", FnIndex::VAR_CHANGE), + ("PROC_DEF", FnIndex::PROC_DEF), + ("PROC_CALL", FnIndex::PROC_CALL), + ("PROC_ARG", FnIndex::PROC_ARG), ]; let mut seen: Vec<(u8, &str)> = Vec::new(); diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 8a30489..ee57b2f 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1695,3 +1695,39 @@ isolation. The map's job is to keep them visible. ~16-32 B/op for a compact conventional AST. `FunctionBody` is **362 B in memory** (`[u8; 360]` + `u16 len`) but exactly **360 B on the wire**: the len is deliberately not written, because NOP padding IS the length signal. + **Correction 2 + supersession, operator-ruled (2026-08-04, the + `(function : value)` call model):** the body is NOT a flat opcode stream — + every 12-byte lane is carved per the V3 `6 × (u8:u8)` reading, **indexed** + against a label codebook, and the unit is a **call**: `function : value`. + Three consequences, each retiring an earlier defect or claim IN PLACE. + (1) *The "nesting gap" is withdrawn* — an earlier correction recorded the + absence of a stream delimiter as a real defect; that was an artifact of + treating the body as self-delimiting bytecode. Nesting is **by reference** + (a function index names another function's node), exactly as SB3 nests via + block ids — the question does not arise under the call model. (2) *The + operand gap (codex P1) closes* — the value byte is the immediate + (`WAIT:10`); computed arguments use a stack discipline + (`(NUMBER:5)(NUMBER:3)(ADD:_)`); wide literals spend the value byte as a + constant-pool index (pool = named follow-up). (3) *Arity is a classid + property* — `LaneShape` (mirroring `CascadeShape` G6D2/G4D3/G3D4) carves + the same 360 bytes as 180 pairs / 120 triples / 90 quads; a function + needing more immediates picks a wider CARVING, never a wider field. + `PaletteOp` → `FnIndex` (there is no opcode/function distinction — one + `<256` codebook, primitives in the low range, user functions resolved via + the Inventory registry = the label codebook). Narrowing is LOUD: + `BodyError::ValueBeyondShape` refuses a call the shape would truncate + (falsifier-verified, as is call-level len recovery — the byte-level + rposition regression is caught per-shape). **Also retired here: the + edge-block slot-1 design** (12 in-family + 4 out-of-family) — operator- + deprecated this session; slot 1 is reserved-zeroed and relations ride the + payload rails as indexed calls. This crate's docs no longer carry the + edges language (the lance-graph CLAUDE.md canon block still does — its + `⊘ SUPERSEDED` banner is the operator's, in another session). + **Roadmap, operator-set (baby steps):** (i) ABI-shaped Blockly/Scratch + first; (ii) later a PowerAutomate-shaped low-code editor — BOTH + Mario-editor ergonomics over `ClassView : WideFieldMask` projections, two + skins over one ABI. **Grammar ruling:** operator chose LITERAL storage + (shape-carved calls) over grammar-parsed lines (`A = B + C`); grammar is a + *projection* that renders from and parses back into the pair stream — + never the storage format (preserves positional addressing, the SIMD sweep, + and single-pass lowering into rash's `Input` tree).