From 450fe910c2034b025238726568805491c19bf92d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:05:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?ogar-blockly:=20everything=20is=20a=20call?= =?UTF-8?q?=20=E2=80=94=20(function=20:=20value),=20shape-carved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-ruled rework of the function body from a flat opcode stream to the V3 indexed reading: every 12-byte lane is 6x(u8:u8) rails indexed against a label codebook, and the unit is a Call -- function index : value byte(s). There is no opcode/function distinction any more. ADD is function 0x40, a user block is another index in the same <256 codebook, and invoking either is the same bytes. PaletteOp is renamed FnIndex; the Inventory SoA is the label codebook those indices resolve against, which is why labels stay out of payloads (slot purity) while one byte still names anything in scope. Three earlier defects/claims retire in place: - The "nesting gap" is withdrawn. It 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. No END marker, no jump offset, no need. - The operand gap (codex P1 on #235) closes. The value byte is the immediate (WAIT:10, REPEAT:4); computed arguments use a stack discipline ((NUMBER:5)(NUMBER:3)(ADD:_)); wide literals spend the value byte as a constant-pool index (pool is a named follow-up). - Arity is a classid property, not an encoding trick. 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. Narrowing is loud: BodyError::ValueBeyondShape refuses a call the shape would truncate instead of dropping a byte. Length recovery is call-level, per shape -- the byte-level rposition regression is caught by test in every shape. Both guards were verified by breaking them: the truncation guard fails the suite with "two immediates must not fit Pairs", the len regression with "Quads: trailing bare call lost". The retired edge-block design (12 in-family + 4 out-of-family) is stripped from this crate's docs; slot 1 is documented reserved- zeroed with the retirement named, so the deprecated shape cannot be re-learned from here. Relations ride the payload rails as indexed calls. Also recorded in the ledger: the operator's literal-over-grammar ruling (grammar lines like A = B + C are a projection over the pair stream, never the storage format) and the baby-steps roadmap -- ABI-shaped Blockly/Scratch first, later a PowerAutomate-shaped low-code editor, both Mario-editor ergonomics over ClassView : WideFieldMask projections. Ledger: docs/DISCOVERY-MAP.md D-BLOCKS-PALETTE (correction 2). Gates: 16 tests pass (2 falsifier break-runs verified), fmt clean, clippy -D warnings clean, workspace check + tests clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DN5odWu4eisdevH9bPWyz --- crates/ogar-blockly/examples/density.rs | 70 +- crates/ogar-blockly/src/lib.rs | 1346 +++++++++++++++-------- docs/DISCOVERY-MAP.md | 36 + 3 files changed, 956 insertions(+), 496 deletions(-) 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..c6644bd 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 +//! `(CONST:5) (CONST: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 //! @@ -138,18 +166,85 @@ pub const PAYLOAD_BYTES_PER_SLOT: usize = SLOT_STRIDE - CLASSID_BYTES; /// 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 +257,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 +308,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 +560,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 +836,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 +964,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 +1015,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 +1242,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 +1329,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). From 4471f478ba75681031c24419dd06396261b5e8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 17:21:19 +0000 Subject: [PATCH 2/2] ogar-blockly: fix two doc falsehoods the review fleet confirmed Adversarial review of 450fe91 (4 finder lenses, per-finding refutation): 7 raw findings, 3 confirmed, 4 refuted. The confirmed three collapse to two distinct defects, both doc-level, both real falsehoods about the public surface: - VALUE_SLAB_LEN's doc still linked [OPS_PER_FUNCTION], a constant this rework renamed to BODY_BYTES -- a dangling intra-doc link that also carried the retired "360 of them are operations" framing, contradicting the crate's own "no opcode distinct from a function call". Now links BODY_BYTES and says call payload (180/120/90 calls per LaneShape). - The crate doc's stack example wrote (CONST:5)(CONST:3)(ADD:0), but FnIndex::CONST does not exist -- the numeric literal is NUMBER (0x46), which the test for this exact example already used. Worse, a near-miss FnIndex::CONSTANT (0x5C) exists and means a named math constant, so a reader searching CONST would land on wrong bytes. Now (NUMBER:5)(NUMBER:3)(ADD:0). Deliberately NOT changed: the earlier D-BLOCKS-PALETTE ledger text that names OPS_PER_FUNCTION. That entry is append-only history and accurately described the crate as it stood when written; correction 2 supersedes it in place. Gate added to the routine: RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc -- the check that catches this class (clippy does not). Clean, alongside 16 tests, fmt, clippy -D warnings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DN5odWu4eisdevH9bPWyz --- crates/ogar-blockly/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index c6644bd..0594e72 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -58,7 +58,7 @@ //! **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 -//! `(CONST:5) (CONST:3) (ADD:0)` in any shape. That is what lowers cleanly into +//! `(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 @@ -162,8 +162,9 @@ 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; /// Payload bytes one function body carries: `30 × 12` = **360**.