From 89d0d3a91896063ac292b7f6f16f2d4cfc53e02e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:23:44 +0000 Subject: [PATCH] ogar-loco: hoist the vocabulary-agnostic call ABI out of ogar-blockly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator direction: elixir-shaped templates are a rails-shaped semantic over classid index, 256:256 — not much different than blockly, just a different vocabulary — and the surface must be reusable for any further frontend (Power-Automate-style flows are next in line). So the surface splits: ogar-loco carries everything that is the same no matter what the bytes MEAN; a vocabulary crate per domain carries the meanings. ogar-loco (new, zero-dep): - FnIndex / Call / LaneShape / FunctionBody / call_in_slab + layout constants + budgets, moved verbatim from ogar-blockly — bytes, semantics, and tests unchanged. - DOMAIN_FLOOR generalizes DEVICE_FAMILY_FLOOR: below the floor is the shared computational core, byte-stable across every vocabulary; at or above is the classid-selected vocabulary's own range. - vocabulary::shared_core — the core's stack_arity / body_refs / branches / min_shape tables defined ONCE (transcribed from the proven blockly-rs tables: the two-quantity split + expression arities). Uncovered shared-core bytes (WAIT, STOP, RETURN, ...) refuse everywhere; coverage grows here, for everyone. - trait Vocabulary + vocabulary::conformance::check — the seam a sibling codebook implements, and the mechanical no-drift gate every vocabulary crate must run. Both failure modes verified able to fire: a drifted ADD arity and a truncating min_shape are caught by name. - node / pool / program hoisted from blockly-abi, generalized where they were palette-typed (references_are_resolvable and branches_of are vocabulary-parameterized), tests ported. ogar-blockly becomes the Blockly/Scratch vocabulary crate over the core: re-exports the old surface unchanged (the palette census test now doubles as the re-export completeness proof; blockly-rs compiles with zero changes), keeps BlockConcept / SoaSplit / BLOCKS_DOMAIN, and adds BlocklyVocabulary — domain hooks empty-and-refusing until the device families mint, because that range is reserved, not allocated. Deliberately NOT taken here (recorded in the plan's W6 section): the template and flow vocabulary crates (gated on the rung-2 144-verb unification and on operator mints), the blockly-rs flip off its local copies (a scheduled obligation, not an aspiration), and every concept mint (M1-M3 unchanged). Gates: fmt, clippy -D warnings, tests (36 loco + 7 blockly), rustdoc -D warnings, density example — all green. --- Cargo.toml | 1 + crates/ogar-blockly/Cargo.toml | 5 +- crates/ogar-blockly/src/lib.rs | 1256 +++------------------------- crates/ogar-loco/Cargo.toml | 16 + crates/ogar-loco/src/lib.rs | 1208 ++++++++++++++++++++++++++ crates/ogar-loco/src/node.rs | 302 +++++++ crates/ogar-loco/src/pool.rs | 469 +++++++++++ crates/ogar-loco/src/program.rs | 239 ++++++ crates/ogar-loco/src/vocabulary.rs | 486 +++++++++++ docs/BLOCK-EDITOR-PLAN.md | 64 ++ 10 files changed, 2899 insertions(+), 1147 deletions(-) create mode 100644 crates/ogar-loco/Cargo.toml create mode 100644 crates/ogar-loco/src/lib.rs create mode 100644 crates/ogar-loco/src/node.rs create mode 100644 crates/ogar-loco/src/pool.rs create mode 100644 crates/ogar-loco/src/program.rs create mode 100644 crates/ogar-loco/src/vocabulary.rs diff --git a/Cargo.toml b/Cargo.toml index e44dbae..8071142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/ogar-from-docv1", "crates/ogar-render-typst", "crates/ogar-blockly", + "crates/ogar-loco", ] [workspace.package] diff --git a/crates/ogar-blockly/Cargo.toml b/crates/ogar-blockly/Cargo.toml index f9add17..35dc9bc 100644 --- a/crates/ogar-blockly/Cargo.toml +++ b/crates/ogar-blockly/Cargo.toml @@ -6,12 +6,13 @@ license.workspace = true repository.workspace = true authors.workspace = true rust-version.workspace = true -description = "Visual block-programming vocabulary — the 256-slot command/concept palette shared by Blockly and Scratch frontends (the Blocks domain, 0x17XX). One content classid; a function body is 360 palette bytes in one 512-byte node. Plug-and-play: concept ids are authoritative here, never in the shared codebook." +description = "Visual block-programming vocabulary — the 256-slot command/concept palette shared by Blockly and Scratch frontends (the Blocks domain, 0x17XX), over the vocabulary-agnostic call ABI in ogar-loco. One content classid; a function body is 360 palette bytes in one 512-byte node. Plug-and-play: concept ids are authoritative here, never in the shared codebook." [features] default = [] -serde = ["dep:serde", "ogar-vocab/serde"] +serde = ["dep:serde", "ogar-vocab/serde", "ogar-loco/serde"] [dependencies] +ogar-loco = { path = "../ogar-loco" } ogar-vocab = { path = "../ogar-vocab" } serde = { workspace = true, optional = true } diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index 0594e72..e2ca872 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -15,88 +15,33 @@ //! `math_single` + `math_trig` (two Blockly blocks) fan out to. The palette is //! where the two vocabularies actually meet. //! -//! # The shape: everything is a call — `(function : value)` +//! # The ABI lives one level down, in `ogar-loco` //! -//! 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)`**: +//! The call encoding this palette rides on — `Call = (function : value)` +//! rails, [`LaneShape`] carvings, [`FunctionBody`] budgets, the stored-node +//! round-trip, the constant pool, the [`Program`](ogar_loco::Program) +//! reference rules, and the shared computational core's arity tables — is the +//! **vocabulary-agnostic surface** every sibling codebook shares +//! (elixir-shaped templates and flow frontends are next in line). It was +//! hoisted from this crate into [`ogar_loco`]; this crate re-exports that +//! surface unchanged, so existing consumers keep compiling, and adds what is +//! genuinely Blockly/Scratch: //! -//! ```text -//! one function = one node = 512 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 -//! ``` +//! - the palette constants' *meanings* (documented on [`FnIndex`]'s +//! associated constants, re-exported from the core where the shared +//! computational range is defined once for every vocabulary), +//! - the Blocks concept domain (`0x17XX`) and its two concept ids, +//! - the [`SoaSplit`] storage partitioning, +//! - [`BlocklyVocabulary`], this palette's [`Vocabulary`] implementation. //! -//! **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 -//! -//! 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. -//! -//! ## Budgets -//! -//! **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. -//! -//! 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. -//! -//! Only ONE concept id is spent on content ([`BlockConcept::Content`]) — calls -//! live in the payload, not in the classid space. -//! -//! ## Wide literals +//! # Classid routing — the reserved Blocks domain (`0x17XX`) //! -//! 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. +//! `ogar_vocab` reserves `ConceptDomain::Blocks` (`0x17XX`) and ships ZERO +//! concept rows there. This crate is the authoritative home for the ids inside +//! that domain — the same plug-and-play posture as `ogar-obo` over +//! `ConceptDomain::Ontology`: only consumers that dep `ogar-blockly` compile +//! them, so ERP / clinical / project consumers never pull a block vocabulary +//! they have no use for. //! //! # Storage shape — inventory SoA + N content SoAs, split by function //! @@ -107,15 +52,6 @@ //! = its own SoA, so every write is owned and no singleton table accumulates //! writers. //! -//! # Classid routing — the reserved Blocks domain (`0x17XX`) -//! -//! `ogar_vocab` reserves `ConceptDomain::Blocks` (`0x17XX`) and ships ZERO -//! concept rows there. This crate is the authoritative home for the ids inside -//! that domain — the same plug-and-play posture as `ogar-obo` over -//! `ConceptDomain::Ontology`: only consumers that dep `ogar-blockly` compile -//! them, so ERP / clinical / project consumers never pull a block vocabulary -//! they have no use for. -//! //! # Provenance fence (load-bearing, not decorative) //! //! Every palette entry here is derived from **permissively-licensed or @@ -136,6 +72,19 @@ use serde::{Deserialize, Serialize}; pub use ogar_vocab::ConceptDomain; +// ── The shared surface, re-exported unchanged ─────────────────────────────── +// The ABI hoist (ogar-loco) must be invisible to existing consumers: every +// name this crate exported before the hoist is re-exported here, same paths, +// same semantics. New surface (Vocabulary, node/pool/program modules) is NOT +// re-exported — a consumer that wants the vocabulary-agnostic machinery deps +// `ogar-loco` directly. +pub use ogar_loco::{ + BODY_BYTES, BodyError, CLASSID_BYTES, CONTENT_SLOTS, Call, FnIndex, FunctionBody, LaneShape, + MAX_VALUES_PER_CALL, PAYLOAD_BYTES_PER_SLOT, SLOT_STRIDE, VALUE_SLAB_LEN, call_in_slab, +}; + +use ogar_loco::{DOMAIN_FLOOR, Vocabulary}; + /// The reserved Blocks [`ConceptDomain`] every block node routes on. Live in /// `ogar_vocab` with zero shared codebook rows, so a consumer can branch on it /// today. @@ -145,106 +94,20 @@ pub const BLOCKS_DOMAIN: ConceptDomain = ConceptDomain::Blocks; /// matches when routing a block node from a bare classid. pub const BLOCKS_DOMAIN_HI: u8 = 0x17; -// ── The function-body budget ──────────────────────────────────────────────── - -/// Value-slab facet slots in a 512-byte node: `value(480) / 16` = **30**. -pub const CONTENT_SLOTS: usize = 30; - -/// Bytes of one facet slot: the V3 16-byte facet stride. -pub const SLOT_STRIDE: usize = 16; - -/// Bytes of a facet's classid prefix. -pub const CLASSID_BYTES: usize = 4; - -/// Payload bytes in one 16-byte facet: `16 - classid(4)` = **12**. -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 -/// [`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**. -/// -/// 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!( - 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. +/// First palette slot reserved for **device-specific** families — the +/// sprite/stage vocabulary (motion, looks, sound, events, sensing) that exists +/// in a Scratch-style frontend and has no counterpart in a general block +/// editor. /// -/// 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); +/// This is this palette's reading of the core's +/// [`DOMAIN_FLOOR`] (re-exported under the +/// historical name): below the floor is the shared computational core, whose +/// tables live once in `ogar_loco::vocabulary::shared_core`; at/above it is +/// this vocabulary's own range. The range above the floor is **reserved, not +/// allocated** — 108 device opcodes were measured in the Apache-2.0 +/// `scratch-blocks` definitions, and they mint when a consumer needs them. +/// Reserve, don't reclaim. +pub const DEVICE_FAMILY_FLOOR: u8 = DOMAIN_FLOOR; // ── Concept ids (authoritative here, NOT in the shared codebook) ──────────── @@ -292,663 +155,7 @@ impl BlockConcept { } } -// ── The 256-slot palette ──────────────────────────────────────────────────── - -/// First palette slot reserved for **device-specific** families — the -/// sprite/stage vocabulary (motion, looks, sound, events, sensing) that exists -/// in a Scratch-style frontend and has no counterpart in a general block -/// editor. -/// -/// Slots below this floor are the **shared computational core**: every one of -/// them means the same thing in every frontend. `slot >= DEVICE_FAMILY_FLOOR` -/// is therefore a one-compare test for "this op is frontend-specific", which a -/// renderer or a compiler can branch on without a table lookup. -/// -/// The range above the floor is **reserved, not allocated** — 108 device -/// opcodes were measured in the Apache-2.0 `scratch-blocks` definitions, and -/// they mint when a consumer needs them. Reserve, don't reclaim. -pub const DEVICE_FAMILY_FLOOR: u8 = 0x90; - -/// 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 -/// [`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 FnIndex(pub u8); - -impl FnIndex { - /// The zero slot — an unwritten body byte. Never a real operation. - pub const NOP: FnIndex = FnIndex(0x00); - - // ── control (0x01..0x1F) ──────────────────────────────────────────── - /// Conditional with no else arm. `controls_if` · `control_if`. - pub const IF: FnIndex = FnIndex(0x01); - /// Conditional with an else arm. `controls_ifelse` · `control_if_else`. - pub const IF_ELSE: FnIndex = FnIndex(0x02); - /// Bounded repeat. `controls_repeat`/`_ext` · `control_repeat`. - pub const REPEAT: FnIndex = FnIndex(0x03); - /// Repeat until a condition holds. `controls_whileUntil[UNTIL]` · - /// `control_repeat_until`. - pub const REPEAT_UNTIL: FnIndex = FnIndex(0x04); - /// Repeat while a condition holds. `controls_whileUntil[WHILE]` · - /// `control_while`. - pub const WHILE: FnIndex = FnIndex(0x05); - /// Unbounded repeat. `control_forever` (no Blockly counterpart). - pub const FOREVER: FnIndex = FnIndex(0x06); - /// Iterate a list. `controls_forEach` · `control_for_each`. - pub const FOR_EACH: FnIndex = FnIndex(0x07); - /// Iterate a numeric range. `controls_for` (no Scratch counterpart). - pub const FOR_RANGE: FnIndex = FnIndex(0x08); - /// Suspend for a duration. `control_wait`. - pub const WAIT: FnIndex = FnIndex(0x09); - /// Suspend until a condition holds. `control_wait_until`. - pub const WAIT_UNTIL: FnIndex = FnIndex(0x0A); - /// Stop this script / all / others. `control_stop`. - pub const STOP: FnIndex = FnIndex(0x0B); - /// Leave the enclosing loop. `controls_flow_statements[BREAK]`. - pub const BREAK: FnIndex = FnIndex(0x0C); - /// Skip to the enclosing loop's next iteration. - /// `controls_flow_statements[CONTINUE]`. - pub const CONTINUE: FnIndex = FnIndex(0x0D); - /// Return from the enclosing function. `procedures_ifreturn`. - pub const RETURN: FnIndex = FnIndex(0x0E); - - // ── logic (0x20..0x2F) ────────────────────────────────────────────── - /// Boolean conjunction. `logic_operation[AND]` · `operator_and`. - pub const AND: FnIndex = FnIndex(0x20); - /// Boolean disjunction. `logic_operation[OR]` · `operator_or`. - pub const OR: FnIndex = FnIndex(0x21); - /// Boolean negation. `logic_negate` · `operator_not`. - pub const NOT: FnIndex = FnIndex(0x22); - /// Literal true. `logic_boolean[TRUE]`. - pub const TRUE: FnIndex = FnIndex(0x23); - /// Literal false. `logic_boolean[FALSE]`. - pub const FALSE: FnIndex = FnIndex(0x24); - /// Literal null. `logic_null` (no Scratch counterpart). - pub const NULL: FnIndex = FnIndex(0x25); - /// Conditional expression. `logic_ternary` (no Scratch counterpart). - pub const TERNARY: FnIndex = FnIndex(0x26); - - // ── comparison (0x30..0x3F) ───────────────────────────────────────── - /// Equality. `logic_compare[EQ]` · `operator_equals`. - pub const EQ: FnIndex = FnIndex(0x30); - /// Inequality. `logic_compare[NEQ]` (no Scratch counterpart). - pub const NEQ: FnIndex = FnIndex(0x31); - /// Less than. `logic_compare[LT]` · `operator_lt`. - pub const LT: FnIndex = FnIndex(0x32); - /// Less than or equal. `logic_compare[LTE]` (no Scratch counterpart). - pub const LTE: FnIndex = FnIndex(0x33); - /// Greater than. `logic_compare[GT]` · `operator_gt`. - pub const GT: FnIndex = FnIndex(0x34); - /// Greater than or equal. `logic_compare[GTE]` (no Scratch counterpart). - pub const GTE: FnIndex = FnIndex(0x35); - - // ── math (0x40..0x5F) ─────────────────────────────────────────────── - /// Addition. `math_arithmetic[ADD]` · `operator_add`. - pub const ADD: FnIndex = FnIndex(0x40); - /// Subtraction. `math_arithmetic[MINUS]` · `operator_subtract`. - pub const SUB: FnIndex = FnIndex(0x41); - /// Multiplication. `math_arithmetic[MULTIPLY]` · `operator_multiply`. - pub const MUL: FnIndex = FnIndex(0x42); - /// Division. `math_arithmetic[DIVIDE]` · `operator_divide`. - pub const DIV: FnIndex = FnIndex(0x43); - /// Exponentiation. `math_arithmetic[POWER]` (no Scratch counterpart). - pub const POW: FnIndex = FnIndex(0x44); - /// Modulo. `math_modulo` · `operator_mod`. - pub const MOD: FnIndex = FnIndex(0x45); - /// Numeric literal. `math_number` (Scratch uses a field, not a block). - pub const NUMBER: FnIndex = FnIndex(0x46); - /// Absolute value. `math_single[ABS]` · `operator_mathop[abs]`. - pub const ABS: FnIndex = FnIndex(0x47); - /// Negation. `math_single[NEG]`. - pub const NEG: FnIndex = FnIndex(0x48); - /// Round to nearest. `math_round[ROUND]` · `operator_round`. - pub const ROUND: FnIndex = FnIndex(0x49); - /// Round toward -inf. `math_round[ROUNDDOWN]` · `operator_mathop[floor]`. - pub const FLOOR: FnIndex = FnIndex(0x4A); - /// Round toward +inf. `math_round[ROUNDUP]` · `operator_mathop[ceiling]`. - pub const CEIL: FnIndex = FnIndex(0x4B); - /// Square root. `math_single[ROOT]` · `operator_mathop[sqrt]`. - pub const SQRT: FnIndex = FnIndex(0x4C); - /// Natural logarithm. `math_single[LN]` · `operator_mathop[ln]`. - pub const LN: FnIndex = FnIndex(0x4D); - /// Base-10 logarithm. `math_single[LOG10]` · `operator_mathop[log]`. - pub const LOG10: FnIndex = FnIndex(0x4E); - /// `e^x`. `math_single[EXP]` · `operator_mathop[e ^]`. - pub const EXP_E: FnIndex = FnIndex(0x4F); - /// `10^x`. `math_single[POW10]` · `operator_mathop[10 ^]`. - pub const EXP_10: FnIndex = FnIndex(0x50); - /// Sine. `math_trig[SIN]` · `operator_mathop[sin]`. - pub const SIN: FnIndex = FnIndex(0x51); - /// Cosine. `math_trig[COS]` · `operator_mathop[cos]`. - pub const COS: FnIndex = FnIndex(0x52); - /// Tangent. `math_trig[TAN]` · `operator_mathop[tan]`. - pub const TAN: FnIndex = FnIndex(0x53); - /// Arcsine. `math_trig[ASIN]` · `operator_mathop[asin]`. - pub const ASIN: FnIndex = FnIndex(0x54); - /// Arccosine. `math_trig[ACOS]` · `operator_mathop[acos]`. - pub const ACOS: FnIndex = FnIndex(0x55); - /// Arctangent. `math_trig[ATAN]` · `operator_mathop[atan]`. - pub const ATAN: FnIndex = FnIndex(0x56); - /// Two-argument arctangent. `math_atan2` (no Scratch counterpart). - pub const ATAN2: FnIndex = FnIndex(0x57); - /// Random integer in a range. `math_random_int` · `operator_random`. - pub const RANDOM_INT: FnIndex = FnIndex(0x58); - /// Random fraction. `math_random_float` (no Scratch counterpart). - pub const RANDOM_FLOAT: FnIndex = FnIndex(0x59); - /// Clamp to a range. `math_constrain` (no Scratch counterpart). - 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: FnIndex = FnIndex(0x5B); - /// Named constant (pi/e/phi/sqrt2/sqrt1_2/infinity). `math_constant`. - 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: FnIndex = FnIndex(0x5D); - - // ── text (0x60..0x6F) ─────────────────────────────────────────────── - /// String literal. `text`. - pub const TEXT: FnIndex = FnIndex(0x60); - /// Concatenate. `text_join` · `operator_join`. - pub const JOIN: FnIndex = FnIndex(0x61); - /// Character count. `text_length` · `operator_length`. - pub const LENGTH: FnIndex = FnIndex(0x62); - /// Character at a position. `text_charAt` · `operator_letter_of`. - pub const CHAR_AT: FnIndex = FnIndex(0x63); - /// Substring search. `text_indexOf` (no Scratch counterpart). - pub const INDEX_OF: FnIndex = FnIndex(0x64); - /// Emptiness test. `text_isEmpty` (no Scratch counterpart). - pub const IS_EMPTY: FnIndex = FnIndex(0x65); - /// Substring extraction. `text_getSubstring` (no Scratch counterpart). - pub const SUBSTRING: FnIndex = FnIndex(0x66); - /// Case conversion. `text_changeCase` (no Scratch counterpart). - pub const CHANGE_CASE: FnIndex = FnIndex(0x67); - /// Whitespace trim. `text_trim` (no Scratch counterpart). - pub const TRIM: FnIndex = FnIndex(0x68); - /// Containment test. `text_contains`-shaped · `operator_contains`. - pub const CONTAINS: FnIndex = FnIndex(0x69); - /// Append to a variable. `text_append`. - pub const APPEND: FnIndex = FnIndex(0x6A); - /// Emit to output. `text_print`. - pub const PRINT: FnIndex = FnIndex(0x6B); - /// Prompt for input. `text_prompt`/`_ext`. - pub const PROMPT: FnIndex = FnIndex(0x6C); - /// Occurrence count. `text_count` (no Scratch counterpart). - pub const COUNT: FnIndex = FnIndex(0x6D); - /// Substring replacement. `text_replace` (no Scratch counterpart). - pub const REPLACE: FnIndex = FnIndex(0x6E); - /// Reversal. `text_reverse` (no Scratch counterpart). - pub const REVERSE: FnIndex = FnIndex(0x6F); - - // ── list (0x70..0x7F) ─────────────────────────────────────────────── - /// Empty list literal. `lists_create_empty`. - pub const LIST_EMPTY: FnIndex = FnIndex(0x70); - /// List literal with items. `lists_create_with`. - pub const LIST_WITH: FnIndex = FnIndex(0x71); - /// Repeat an item into a list. `lists_repeat`. - pub const LIST_REPEAT: FnIndex = FnIndex(0x72); - /// Item count. `lists_length` · `data_lengthoflist`. - pub const LIST_LENGTH: FnIndex = FnIndex(0x73); - /// Emptiness test. `lists_isEmpty`. - pub const LIST_IS_EMPTY: FnIndex = FnIndex(0x74); - /// Position of an item. `lists_indexOf` · `data_itemnumoflist`. - pub const LIST_INDEX_OF: FnIndex = FnIndex(0x75); - /// Read an item. `lists_getIndex` · `data_itemoflist`. - pub const LIST_GET: FnIndex = FnIndex(0x76); - /// Write an item. `lists_setIndex[SET]` · `data_replaceitemoflist`. - pub const LIST_SET: FnIndex = FnIndex(0x77); - /// Insert an item. `lists_setIndex[INSERT]` · `data_insertatlist`. - pub const LIST_INSERT: FnIndex = FnIndex(0x78); - /// Append an item. `data_addtolist`. - pub const LIST_ADD: FnIndex = FnIndex(0x79); - /// Remove an item. `lists_getIndex[REMOVE]` · `data_deleteoflist`. - pub const LIST_DELETE: FnIndex = FnIndex(0x7A); - /// Remove every item. `data_deletealloflist`. - pub const LIST_DELETE_ALL: FnIndex = FnIndex(0x7B); - /// Sublist extraction. `lists_getSublist` (no Scratch counterpart). - pub const LIST_SUBLIST: FnIndex = FnIndex(0x7C); - /// Split / join against a delimiter. `lists_split`. - pub const LIST_SPLIT: FnIndex = FnIndex(0x7D); - /// Ordering. `lists_sort` (no Scratch counterpart). - pub const LIST_SORT: FnIndex = FnIndex(0x7E); - /// Containment test. `lists_indexOf`-shaped · `data_listcontainsitem`. - pub const LIST_CONTAINS: FnIndex = FnIndex(0x7F); - - // ── variable + procedure (0x80..0x8F) ─────────────────────────────── - /// Read a variable. `variables_get` · `data_variable`. - pub const VAR_GET: FnIndex = FnIndex(0x80); - /// Write a variable. `variables_set` · `data_setvariableto`. - pub const VAR_SET: FnIndex = FnIndex(0x81); - /// Increment a variable. `math_change` · `data_changevariableby`. - pub const VAR_CHANGE: FnIndex = FnIndex(0x82); - /// Define a function. `procedures_defnoreturn`/`_defreturn` · - /// `procedures_definition`. - pub const PROC_DEF: FnIndex = FnIndex(0x83); - /// Invoke a function. `procedures_callnoreturn`/`_callreturn` · - /// `procedures_call`. - pub const PROC_CALL: FnIndex = FnIndex(0x84); - /// Read a call argument. `procedures_defreturn` argument access. - 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. [`FnIndex::NOP`] is not an - /// operation and answers `false`. - #[must_use] - pub const fn is_shared_core(self) -> bool { - self.0 != 0 && self.0 < DEVICE_FAMILY_FLOOR - } - - /// Is this a **device-specific** operation — sprite/stage vocabulary with - /// no counterpart in a general block editor? - #[must_use] - pub const fn is_device_family(self) -> bool { - self.0 >= DEVICE_FAMILY_FLOOR - } -} - -// ── 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; - -/// One call — a function index plus up to three immediate value bytes. -/// -/// 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 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 BodyError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - 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 BodyError {} - -/// 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. 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 { - /// 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(LaneShape::Pairs) - } -} - -impl FunctionBody { - /// An empty body of the given shape — every byte zero ([`Call::NOP`]). - #[must_use] - pub const fn new(shape: LaneShape) -> Self { - Self { - bytes: [0u8; BODY_BYTES], - len: 0, - shape, - } - } - - /// 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 - /// - /// [`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, - }, - })?; - } - Ok(body) - } - - /// Append one call. - /// - /// # Errors - /// - /// [`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; - 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.len = (n + 1) as u16; - Ok(()) - } - - /// 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)) - } - - 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, - } - } - - /// 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 - } - - /// Is the body empty? - #[must_use] - pub const fn is_empty(&self) -> bool { - self.len == 0 - } - - /// Remaining call budget before a split is required. - #[must_use] - pub const fn remaining(&self) -> usize { - self.shape.calls_per_function() - self.len as usize - } - - /// 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 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 - /// [`call_in_slab`] to read one call in place without gathering at all. - #[must_use] - pub const fn as_body_bytes(&self) -> &[u8; BODY_BYTES] { - &self.bytes - } - - /// Byte offset where call `index` STARTS within a node's **480-byte value - /// slab**, under `shape`. - /// - /// `(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 >= shape.calls_per_function()`. - #[must_use] - 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 - /// 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 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.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). - /// - /// 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(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]); - } - 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 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 call -/// `i` never materialises the rest of the body. -/// -/// # Panics -/// -/// When `index >= shape.calls_per_function()`. -#[must_use] -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, - } -} +// ── Storage partitioning ──────────────────────────────────────────────────── /// How block content is partitioned across SoA tables. /// @@ -980,9 +187,35 @@ impl SoaSplit { } } +// ── This palette's Vocabulary implementation ──────────────────────────────── + +/// The Blockly/Scratch palette as an `ogar-loco` [`Vocabulary`]. +/// +/// Every operation this palette has *allocated* sits below the floor — i.e. +/// in the shared computational core, whose tables live in the core crate — +/// so the domain hooks answer nothing yet. That is honest, not lazy: the +/// device families above [`DEVICE_FAMILY_FLOOR`] are **reserved, not +/// allocated**, and when they mint, their arity/body-reference tables land +/// here (and only here — the core never learns device vocabulary). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct BlocklyVocabulary; + +impl Vocabulary for BlocklyVocabulary { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + // No device family is minted yet; an above-floor byte is refused, + // never guessed. + None + } + + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } +} + #[cfg(test)] mod tests { use super::*; + use ogar_loco::vocabulary::{conformance, shared_core}; use ogar_vocab::canonical_concept_domain; #[test] @@ -1016,320 +249,59 @@ mod tests { } #[test] - 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!(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_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_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(LaneShape::Pairs); - for _ in 0..LaneShape::Pairs.calls_per_function() { - body.push(Call::new(FnIndex::MUL)).expect("within budget"); - } - 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(), 180); - } - - #[test] - 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 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)); - } - - #[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() { - 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" - ); - } - } - - // In-place read agrees with the pushed calls, call by call. - for (i, call) in calls.iter().enumerate() { - assert_eq!( - call_in_slab(&slab, shape, i), - *call, - "{shape:?}: call {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); - } - } - - #[test] - fn a_naive_contiguous_copy_is_detectably_wrong() { - // This is the bug an earlier doc comment would have caused: treating - // 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 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[..BODY_BYTES].copy_from_slice(body.as_body_bytes()); - + fn soa_split_maps_each_partition_to_its_own_concept() { + // Inventory and Content must NOT share a classid — the whole point of + // the split is that a registry read never touches a body. + assert_eq!(SoaSplit::Inventory.concept(), BlockConcept::Inventory); + assert_eq!(SoaSplit::Content.concept(), BlockConcept::Content); assert_ne!( - correct, naive, - "scatter and contiguous copy must not coincide" - ); - // 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 + SoaSplit::Inventory.concept().concept_id(), + SoaSplit::Content.concept().concept_id() ); } #[test] - fn in_memory_body_is_larger_than_the_wire_form() { - // [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 - BODY_BYTES, CONTENT_SLOTS * CLASSID_BYTES); - } - - #[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" - ); - } + fn the_blockly_vocabulary_conforms_to_the_sharing_discipline() { + // The mechanical gate every vocabulary crate must run: shared-core + // bytes answer from the core, the domain range refuses what is not + // minted, and no reported shape can truncate a call's own body + // references. + assert_eq!(conformance::check(&BlocklyVocabulary), Ok(())); + // Spot-check the routing this palette relies on: control flow and + // expressions answer from the shared core THROUGH the vocabulary. + let v = BlocklyVocabulary; + assert_eq!(v.stack_arity(FnIndex::REPEAT), Some(1)); + assert_eq!(v.body_refs(FnIndex::IF_ELSE), 2); + assert_eq!(v.stack_arity(FnIndex::ADD), Some(2)); + // …and an unminted device byte is refused, not guessed. + assert_eq!(v.stack_arity(FnIndex(DEVICE_FAMILY_FLOOR)), None); } #[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. + // answers the same way for everything is worthless. (`is_domain_specific` + // is the core's name; this palette reads it as "device family".) assert!(FnIndex::LT.is_shared_core()); - assert!(!FnIndex::LT.is_device_family()); + assert!(!FnIndex::LT.is_domain_specific()); let device = FnIndex(DEVICE_FAMILY_FLOOR); - assert!(device.is_device_family()); + assert!(device.is_domain_specific()); assert!(!device.is_shared_core()); // NOP is not an operation at all — neither bucket claims it. assert!(!FnIndex::NOP.is_shared_core()); - assert!(!FnIndex::NOP.is_device_family()); + assert!(!FnIndex::NOP.is_domain_specific()); } #[test] fn every_named_op_is_a_distinct_slot_in_the_shared_core() { // 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. + // or above the device floor would misclassify a shared op. This ALSO + // proves the re-export surface is complete for every named constant — + // the census compiles against `ogar_blockly::FnIndex`, exactly as the + // downstream consumers do. let named: &[(&str, FnIndex)] = &[ ("IF", FnIndex::IF), ("IF_ELSE", FnIndex::IF_ELSE), @@ -1444,17 +416,11 @@ mod tests { // Anti-vacuity: the table must actually be substantial, or "all // distinct" is trivially true of a near-empty list. assert!(seen.len() >= 90, "palette census shrank to {}", seen.len()); - } - #[test] - fn soa_split_maps_each_partition_to_its_own_concept() { - // Inventory and Content must NOT share a classid — the whole point of - // the split is that a registry read never touches a body. - assert_eq!(SoaSplit::Inventory.concept(), BlockConcept::Inventory); - assert_eq!(SoaSplit::Content.concept(), BlockConcept::Content); - assert_ne!( - SoaSplit::Inventory.concept().concept_id(), - SoaSplit::Content.concept().concept_id() - ); + // And the shared core's tables must cover the control range this + // palette's frontends lower through — spot-anchored so a core-side + // regression is caught from the vocabulary side too. + assert_eq!(shared_core::stack_arity(FnIndex::REPEAT), Some(1)); + assert_eq!(shared_core::body_refs(FnIndex::REPEAT), 1); } } diff --git a/crates/ogar-loco/Cargo.toml b/crates/ogar-loco/Cargo.toml new file mode 100644 index 0000000..f34237a --- /dev/null +++ b/crates/ogar-loco/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ogar-loco" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "The low-code program surface — the vocabulary-agnostic call ABI every block/template/flow frontend shares. One 512-byte node per function; calls as (function:value) rails over a <256 codebook; nesting by reference; shared computational core defined once; sibling vocabularies plug in via the Vocabulary trait. Zero-dep." + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +serde = { workspace = true, optional = true } diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs new file mode 100644 index 0000000..f5f61da --- /dev/null +++ b/crates/ogar-loco/src/lib.rs @@ -0,0 +1,1208 @@ +//! `ogar-loco` — the **low-code program surface**: the vocabulary-agnostic +//! call ABI that every block/template/flow frontend shares. +//! +//! # What this is, and why it exists +//! +//! The block-editor arc (`ogar-blockly` + the `blockly-rs` consumers) proved a +//! storage shape for programs on the V3 substrate. The operator direction that +//! created this crate generalizes it: *elixir-shaped templates are "just a +//! rails-shaped semantic over classid index, 256:256 — not much different than +//! blockly, just different vocabulary — a reusable surface for any other +//! purposes."* Power-Automate-style flows are the third consumer in line. +//! +//! So the surface splits in two: +//! +//! - **This crate** — everything that is the same no matter what the bytes +//! *mean*: the node layout, the lane carvings, the call encoding, the +//! budgets, the refuse-don't-truncate guards, the shared computational +//! core's tables, the constant pool, the program/reference rules, and the +//! [`Vocabulary`] seam a sibling codebook plugs into. +//! - **A vocabulary crate per domain** (`ogar-blockly` is the first) — the +//! palette *meanings* above the shared core, value-parameter codebooks, +//! frontend membranes (Blockly JSON, a template DSL, flow JSON), and +//! lowering from that frontend's records. +//! +//! # The shape: everything is a call — `(function : value)` +//! +//! The V3 substrate reads every 12-byte payload as rails; program content +//! takes the indexed reading, and the pair is **[`Call`] = `(function : +//! value)`** — each byte an index into a 256-entry codebook, which is what +//! makes every call an address into a 256×256 table: +//! +//! ```text +//! one function = one node = 512 bytes +//! key slot 0 classid = the content concept · 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 +//! ``` +//! +//! **There is no "opcode" distinct from a "function call".** `ADD` is function +//! `0x40`; a user-defined function is another index in the same `<256` +//! codebook; invoking either is the same two bytes — 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. +//! +//! 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. (This is the one deliberate divergence from otherwise-kindred +//! public designs like Wasm's LEB128 stream: fixed lanes buy addressability.) +//! +//! ## Nesting is by reference, not by delimiter +//! +//! A function index can name *another function*, so `IF` calls a body living +//! in its own node. No `END` marker, no jump offset — the same model as SB3 +//! block references and Forth threaded code. A loop body of any length costs +//! its parent exactly one byte. +//! +//! ## Budgets +//! +//! A body is capped at [`LaneShape::calls_per_function`] 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 rule (*scale is the next cascade level, never field-widening*) +//! applied to program structure. 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. +//! +//! ## 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** ([`pool`]) instead of +//! the value — same pair shape, different codebook. +//! +//! # The sharing discipline (load-bearing) +//! +//! Bytes **below [`DOMAIN_FLOOR`]** are the **shared computational core**: +//! they mean the same thing in every vocabulary, their arity/body-reference +//! tables live ONCE in [`vocabulary::shared_core`], and a sibling vocabulary +//! cannot answer for them — that is what keeps `IF` from quietly meaning two +//! things in two domains. Bytes at/above the floor belong to the vocabulary +//! the classid selects. [`vocabulary::conformance`] is the mechanical check. +//! +//! # What this crate deliberately does NOT do +//! +//! - **No concept mints.** Content/Inventory concept ids per domain are +//! operator decisions with ledger entries; vocabulary crates carry them +//! (`ogar-blockly`'s `0x1701`/`0x1702` are the precedent). +//! - **No GUID minting.** [`node::FunctionNode`] round-trips an opaque key. +//! - **No frontend lowering.** Casting a Blockly record / template DSL / flow +//! JSON into calls is the vocabulary crate's job; this crate holds the +//! [`Program`] shape and the reference rules those lowerings must satisfy. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +pub mod node; +pub mod pool; +pub mod program; +pub mod vocabulary; + +pub use node::FunctionNode; +pub use pool::{Constant, ConstantPool, PoolError}; +pub use program::{Program, branches_of}; +pub use vocabulary::Vocabulary; + +// ── The function-body budget ──────────────────────────────────────────────── + +/// Value-slab facet slots in a 512-byte node: `value(480) / 16` = **30**. +pub const CONTENT_SLOTS: usize = 30; + +/// Bytes of one facet slot: the V3 16-byte facet stride. +pub const SLOT_STRIDE: usize = 16; + +/// Bytes of a facet's classid prefix. +pub const CLASSID_BYTES: usize = 4; + +/// Payload bytes in one 16-byte facet: `16 - classid(4)` = **12**. +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 +/// [`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**. +/// +/// 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!( + 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); + +// ── The codebook floor ────────────────────────────────────────────────────── + +/// First codebook slot that belongs to the **vocabulary**, not the shared +/// core. +/// +/// Slots below this floor are the **shared computational core**: every one of +/// them means the same thing in every vocabulary, and their tables live once +/// in [`vocabulary::shared_core`]. `slot >= DOMAIN_FLOOR` is therefore a +/// one-compare test for "this op is vocabulary-specific", which a renderer, +/// validator, or compiler can branch on without a table lookup. +/// +/// In the first vocabulary (`ogar-blockly`) the domain range hosts the +/// Scratch-style *device families* (motion, looks, sound, …) and the constant +/// is re-exported there under its historical name `DEVICE_FAMILY_FLOOR`. The +/// range above the floor is **reserved, not allocated** — entries mint when a +/// vocabulary needs them. Reserve, don't reclaim. +pub const DOMAIN_FLOOR: u8 = 0x90; + +/// 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 (the shared computational core) of the same `<256` +/// codebook that user-defined functions mint into, resolved through the +/// vocabulary's inventory registry (see `ogar-blockly`'s `SoaSplit` for the +/// first concrete registry split). A [`Call`]'s first byte is a `FnIndex`; an +/// editor's pick-from palette is a *rendering* of this codebook. +/// +/// `0x00` is reserved as the zero-fallback: an unwritten payload byte reads as +/// [`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 FnIndex(pub u8); + +impl FnIndex { + /// The zero slot — an unwritten body byte. Never a real operation. + pub const NOP: FnIndex = FnIndex(0x00); + + // ── control (0x01..0x1F) ──────────────────────────────────────────── + /// Conditional with no else arm. `controls_if` · `control_if`. + pub const IF: FnIndex = FnIndex(0x01); + /// Conditional with an else arm. `controls_ifelse` · `control_if_else`. + pub const IF_ELSE: FnIndex = FnIndex(0x02); + /// Bounded repeat. `controls_repeat`/`_ext` · `control_repeat`. + pub const REPEAT: FnIndex = FnIndex(0x03); + /// Repeat until a condition holds. `controls_whileUntil[UNTIL]` · + /// `control_repeat_until`. + pub const REPEAT_UNTIL: FnIndex = FnIndex(0x04); + /// Repeat while a condition holds. `controls_whileUntil[WHILE]` · + /// `control_while`. + pub const WHILE: FnIndex = FnIndex(0x05); + /// Unbounded repeat. `control_forever` (no Blockly counterpart). + pub const FOREVER: FnIndex = FnIndex(0x06); + /// Iterate a list. `controls_forEach` · `control_for_each`. + pub const FOR_EACH: FnIndex = FnIndex(0x07); + /// Iterate a numeric range. `controls_for` (no Scratch counterpart). + pub const FOR_RANGE: FnIndex = FnIndex(0x08); + /// Suspend for a duration. `control_wait`. + pub const WAIT: FnIndex = FnIndex(0x09); + /// Suspend until a condition holds. `control_wait_until`. + pub const WAIT_UNTIL: FnIndex = FnIndex(0x0A); + /// Stop this script / all / others. `control_stop`. + pub const STOP: FnIndex = FnIndex(0x0B); + /// Leave the enclosing loop. `controls_flow_statements[BREAK]`. + pub const BREAK: FnIndex = FnIndex(0x0C); + /// Skip to the enclosing loop's next iteration. + /// `controls_flow_statements[CONTINUE]`. + pub const CONTINUE: FnIndex = FnIndex(0x0D); + /// Return from the enclosing function. `procedures_ifreturn`. + pub const RETURN: FnIndex = FnIndex(0x0E); + + // ── logic (0x20..0x2F) ────────────────────────────────────────────── + /// Boolean conjunction. `logic_operation[AND]` · `operator_and`. + pub const AND: FnIndex = FnIndex(0x20); + /// Boolean disjunction. `logic_operation[OR]` · `operator_or`. + pub const OR: FnIndex = FnIndex(0x21); + /// Boolean negation. `logic_negate` · `operator_not`. + pub const NOT: FnIndex = FnIndex(0x22); + /// Literal true. `logic_boolean[TRUE]`. + pub const TRUE: FnIndex = FnIndex(0x23); + /// Literal false. `logic_boolean[FALSE]`. + pub const FALSE: FnIndex = FnIndex(0x24); + /// Literal null. `logic_null` (no Scratch counterpart). + pub const NULL: FnIndex = FnIndex(0x25); + /// Conditional expression. `logic_ternary` (no Scratch counterpart). + pub const TERNARY: FnIndex = FnIndex(0x26); + + // ── comparison (0x30..0x3F) ───────────────────────────────────────── + /// Equality. `logic_compare[EQ]` · `operator_equals`. + pub const EQ: FnIndex = FnIndex(0x30); + /// Inequality. `logic_compare[NEQ]` (no Scratch counterpart). + pub const NEQ: FnIndex = FnIndex(0x31); + /// Less than. `logic_compare[LT]` · `operator_lt`. + pub const LT: FnIndex = FnIndex(0x32); + /// Less than or equal. `logic_compare[LTE]` (no Scratch counterpart). + pub const LTE: FnIndex = FnIndex(0x33); + /// Greater than. `logic_compare[GT]` · `operator_gt`. + pub const GT: FnIndex = FnIndex(0x34); + /// Greater than or equal. `logic_compare[GTE]` (no Scratch counterpart). + pub const GTE: FnIndex = FnIndex(0x35); + + // ── math (0x40..0x5F) ─────────────────────────────────────────────── + /// Addition. `math_arithmetic[ADD]` · `operator_add`. + pub const ADD: FnIndex = FnIndex(0x40); + /// Subtraction. `math_arithmetic[MINUS]` · `operator_subtract`. + pub const SUB: FnIndex = FnIndex(0x41); + /// Multiplication. `math_arithmetic[MULTIPLY]` · `operator_multiply`. + pub const MUL: FnIndex = FnIndex(0x42); + /// Division. `math_arithmetic[DIVIDE]` · `operator_divide`. + pub const DIV: FnIndex = FnIndex(0x43); + /// Exponentiation. `math_arithmetic[POWER]` (no Scratch counterpart). + pub const POW: FnIndex = FnIndex(0x44); + /// Modulo. `math_modulo` · `operator_mod`. + pub const MOD: FnIndex = FnIndex(0x45); + /// Numeric literal. `math_number` (Scratch uses a field, not a block). + pub const NUMBER: FnIndex = FnIndex(0x46); + /// Absolute value. `math_single[ABS]` · `operator_mathop[abs]`. + pub const ABS: FnIndex = FnIndex(0x47); + /// Negation. `math_single[NEG]`. + pub const NEG: FnIndex = FnIndex(0x48); + /// Round to nearest. `math_round[ROUND]` · `operator_round`. + pub const ROUND: FnIndex = FnIndex(0x49); + /// Round toward -inf. `math_round[ROUNDDOWN]` · `operator_mathop[floor]`. + pub const FLOOR: FnIndex = FnIndex(0x4A); + /// Round toward +inf. `math_round[ROUNDUP]` · `operator_mathop[ceiling]`. + pub const CEIL: FnIndex = FnIndex(0x4B); + /// Square root. `math_single[ROOT]` · `operator_mathop[sqrt]`. + pub const SQRT: FnIndex = FnIndex(0x4C); + /// Natural logarithm. `math_single[LN]` · `operator_mathop[ln]`. + pub const LN: FnIndex = FnIndex(0x4D); + /// Base-10 logarithm. `math_single[LOG10]` · `operator_mathop[log]`. + pub const LOG10: FnIndex = FnIndex(0x4E); + /// `e^x`. `math_single[EXP]` · `operator_mathop[e ^]`. + pub const EXP_E: FnIndex = FnIndex(0x4F); + /// `10^x`. `math_single[POW10]` · `operator_mathop[10 ^]`. + pub const EXP_10: FnIndex = FnIndex(0x50); + /// Sine. `math_trig[SIN]` · `operator_mathop[sin]`. + pub const SIN: FnIndex = FnIndex(0x51); + /// Cosine. `math_trig[COS]` · `operator_mathop[cos]`. + pub const COS: FnIndex = FnIndex(0x52); + /// Tangent. `math_trig[TAN]` · `operator_mathop[tan]`. + pub const TAN: FnIndex = FnIndex(0x53); + /// Arcsine. `math_trig[ASIN]` · `operator_mathop[asin]`. + pub const ASIN: FnIndex = FnIndex(0x54); + /// Arccosine. `math_trig[ACOS]` · `operator_mathop[acos]`. + pub const ACOS: FnIndex = FnIndex(0x55); + /// Arctangent. `math_trig[ATAN]` · `operator_mathop[atan]`. + pub const ATAN: FnIndex = FnIndex(0x56); + /// Two-argument arctangent. `math_atan2` (no Scratch counterpart). + pub const ATAN2: FnIndex = FnIndex(0x57); + /// Random integer in a range. `math_random_int` · `operator_random`. + pub const RANDOM_INT: FnIndex = FnIndex(0x58); + /// Random fraction. `math_random_float` (no Scratch counterpart). + pub const RANDOM_FLOAT: FnIndex = FnIndex(0x59); + /// Clamp to a range. `math_constrain` (no Scratch counterpart). + 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: FnIndex = FnIndex(0x5B); + /// Named constant (pi/e/phi/sqrt2/sqrt1_2/infinity). `math_constant`. + 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: FnIndex = FnIndex(0x5D); + + // ── text (0x60..0x6F) ─────────────────────────────────────────────── + /// String literal. `text`. + pub const TEXT: FnIndex = FnIndex(0x60); + /// Concatenate. `text_join` · `operator_join`. + pub const JOIN: FnIndex = FnIndex(0x61); + /// Character count. `text_length` · `operator_length`. + pub const LENGTH: FnIndex = FnIndex(0x62); + /// Character at a position. `text_charAt` · `operator_letter_of`. + pub const CHAR_AT: FnIndex = FnIndex(0x63); + /// Substring search. `text_indexOf` (no Scratch counterpart). + pub const INDEX_OF: FnIndex = FnIndex(0x64); + /// Emptiness test. `text_isEmpty` (no Scratch counterpart). + pub const IS_EMPTY: FnIndex = FnIndex(0x65); + /// Substring extraction. `text_getSubstring` (no Scratch counterpart). + pub const SUBSTRING: FnIndex = FnIndex(0x66); + /// Case conversion. `text_changeCase` (no Scratch counterpart). + pub const CHANGE_CASE: FnIndex = FnIndex(0x67); + /// Whitespace trim. `text_trim` (no Scratch counterpart). + pub const TRIM: FnIndex = FnIndex(0x68); + /// Containment test. `text_contains`-shaped · `operator_contains`. + pub const CONTAINS: FnIndex = FnIndex(0x69); + /// Append to a variable. `text_append`. + pub const APPEND: FnIndex = FnIndex(0x6A); + /// Emit to output. `text_print`. + pub const PRINT: FnIndex = FnIndex(0x6B); + /// Prompt for input. `text_prompt`/`_ext`. + pub const PROMPT: FnIndex = FnIndex(0x6C); + /// Occurrence count. `text_count` (no Scratch counterpart). + pub const COUNT: FnIndex = FnIndex(0x6D); + /// Substring replacement. `text_replace` (no Scratch counterpart). + pub const REPLACE: FnIndex = FnIndex(0x6E); + /// Reversal. `text_reverse` (no Scratch counterpart). + pub const REVERSE: FnIndex = FnIndex(0x6F); + + // ── list (0x70..0x7F) ─────────────────────────────────────────────── + /// Empty list literal. `lists_create_empty`. + pub const LIST_EMPTY: FnIndex = FnIndex(0x70); + /// List literal with items. `lists_create_with`. + pub const LIST_WITH: FnIndex = FnIndex(0x71); + /// Repeat an item into a list. `lists_repeat`. + pub const LIST_REPEAT: FnIndex = FnIndex(0x72); + /// Item count. `lists_length` · `data_lengthoflist`. + pub const LIST_LENGTH: FnIndex = FnIndex(0x73); + /// Emptiness test. `lists_isEmpty`. + pub const LIST_IS_EMPTY: FnIndex = FnIndex(0x74); + /// Position of an item. `lists_indexOf` · `data_itemnumoflist`. + pub const LIST_INDEX_OF: FnIndex = FnIndex(0x75); + /// Read an item. `lists_getIndex` · `data_itemoflist`. + pub const LIST_GET: FnIndex = FnIndex(0x76); + /// Write an item. `lists_setIndex[SET]` · `data_replaceitemoflist`. + pub const LIST_SET: FnIndex = FnIndex(0x77); + /// Insert an item. `lists_setIndex[INSERT]` · `data_insertatlist`. + pub const LIST_INSERT: FnIndex = FnIndex(0x78); + /// Append an item. `data_addtolist`. + pub const LIST_ADD: FnIndex = FnIndex(0x79); + /// Remove an item. `lists_getIndex[REMOVE]` · `data_deleteoflist`. + pub const LIST_DELETE: FnIndex = FnIndex(0x7A); + /// Remove every item. `data_deletealloflist`. + pub const LIST_DELETE_ALL: FnIndex = FnIndex(0x7B); + /// Sublist extraction. `lists_getSublist` (no Scratch counterpart). + pub const LIST_SUBLIST: FnIndex = FnIndex(0x7C); + /// Split / join against a delimiter. `lists_split`. + pub const LIST_SPLIT: FnIndex = FnIndex(0x7D); + /// Ordering. `lists_sort` (no Scratch counterpart). + pub const LIST_SORT: FnIndex = FnIndex(0x7E); + /// Containment test. `lists_indexOf`-shaped · `data_listcontainsitem`. + pub const LIST_CONTAINS: FnIndex = FnIndex(0x7F); + + // ── variable + procedure (0x80..0x8F) ─────────────────────────────── + /// Read a variable. `variables_get` · `data_variable`. + pub const VAR_GET: FnIndex = FnIndex(0x80); + /// Write a variable. `variables_set` · `data_setvariableto`. + pub const VAR_SET: FnIndex = FnIndex(0x81); + /// Increment a variable. `math_change` · `data_changevariableby`. + pub const VAR_CHANGE: FnIndex = FnIndex(0x82); + /// Define a function. `procedures_defnoreturn`/`_defreturn` · + /// `procedures_definition`. + pub const PROC_DEF: FnIndex = FnIndex(0x83); + /// Invoke a function. `procedures_callnoreturn`/`_callreturn` · + /// `procedures_call`. + pub const PROC_CALL: FnIndex = FnIndex(0x84); + /// Read a call argument. `procedures_defreturn` argument access. + pub const PROC_ARG: FnIndex = FnIndex(0x85); + + /// Is this a **shared computational** operation — one that means the same + /// thing in every vocabulary? + /// + /// A one-compare test, no table lookup: everything below [`DOMAIN_FLOOR`] + /// is shared. [`FnIndex::NOP`] is not an operation and answers `false`. + #[must_use] + pub const fn is_shared_core(self) -> bool { + self.0 != 0 && self.0 < DOMAIN_FLOOR + } + + /// Is this a **vocabulary-specific** operation — one whose meaning comes + /// from the classid-selected vocabulary rather than the shared core? + /// + /// (In the first vocabulary, `ogar-blockly`, this range hosts the + /// Scratch-style device families.) + #[must_use] + pub const fn is_domain_specific(self) -> bool { + self.0 >= DOMAIN_FLOOR + } +} + +// ── 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; + +/// One call — a function index plus up to three immediate value bytes. +/// +/// 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 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 BodyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + 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 BodyError {} + +/// 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. 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 { + /// 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(LaneShape::Pairs) + } +} + +impl FunctionBody { + /// An empty body of the given shape — every byte zero ([`Call::NOP`]). + #[must_use] + pub const fn new(shape: LaneShape) -> Self { + Self { + bytes: [0u8; BODY_BYTES], + len: 0, + shape, + } + } + + /// 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 + /// + /// [`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, + }, + })?; + } + Ok(body) + } + + /// Append one call. + /// + /// # Errors + /// + /// [`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; + 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.len = (n + 1) as u16; + Ok(()) + } + + /// 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)) + } + + 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, + } + } + + /// 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 + } + + /// Is the body empty? + #[must_use] + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Remaining call budget before a split is required. + #[must_use] + pub const fn remaining(&self) -> usize { + self.shape.calls_per_function() - self.len as usize + } + + /// 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 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 + /// [`call_in_slab`] to read one call in place without gathering at all. + #[must_use] + pub const fn as_body_bytes(&self) -> &[u8; BODY_BYTES] { + &self.bytes + } + + /// Byte offset where call `index` STARTS within a node's **480-byte value + /// slab**, under `shape`. + /// + /// `(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 >= shape.calls_per_function()`. + #[must_use] + 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 + /// 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 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.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). + /// + /// 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(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]); + } + 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 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 call +/// `i` never materialises the rest of the body. +/// +/// # Panics +/// +/// When `index >= shape.calls_per_function()`. +#[must_use] +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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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!(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_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_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(LaneShape::Pairs); + for _ in 0..LaneShape::Pairs.calls_per_function() { + body.push(Call::new(FnIndex::MUL)).expect("within budget"); + } + 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(), 180); + } + + #[test] + 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 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)); + } + + #[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() { + 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" + ); + } + } + + // In-place read agrees with the pushed calls, call by call. + for (i, call) in calls.iter().enumerate() { + assert_eq!( + call_in_slab(&slab, shape, i), + *call, + "{shape:?}: call {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); + } + } + + #[test] + fn a_naive_contiguous_copy_is_detectably_wrong() { + // This is the bug an earlier doc comment would have caused: treating + // 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 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[..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 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() { + // [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 - BODY_BYTES, CONTENT_SLOTS * CLASSID_BYTES); + } + + #[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_domain_range_partition_the_codebook() { + // Can-fire AND can-stay-silent on the same predicate: a classifier that + // answers the same way for everything is worthless. + assert!(FnIndex::LT.is_shared_core()); + assert!(!FnIndex::LT.is_domain_specific()); + + let domain = FnIndex(DOMAIN_FLOOR); + assert!(domain.is_domain_specific()); + assert!(!domain.is_shared_core()); + + // NOP is not an operation at all — neither bucket claims it. + assert!(!FnIndex::NOP.is_shared_core()); + assert!(!FnIndex::NOP.is_domain_specific()); + } +} diff --git a/crates/ogar-loco/src/node.rs b/crates/ogar-loco/src/node.rs new file mode 100644 index 0000000..8ca9057 --- /dev/null +++ b/crates/ogar-loco/src/node.rs @@ -0,0 +1,302 @@ +//! The **stored node** — where a function actually lives as bytes. +//! +//! # The claim this closes +//! +//! The surface rests on one sentence: *a program is V3 SoA rows, and the +//! blocks/steps a user sees are a projection of those rows.* Every other +//! layer — a block cast, a text projection, an editor address — is a +//! projection **of** something. This module is the something. +//! +//! # The layout, and the slot that is deliberately empty +//! +//! ```text +//! one function = one node = 512 bytes = 32 × 16-byte slots +//! slot 0 key 16 B the canonical GUID +//! slot 1 reserved 16 B ZEROED — the edge-block design is RETIRED +//! slots 2..31 value slab 480 B 30 lanes × 12 B, carved by LaneShape +//! ``` +//! +//! Slot 1 is **reserved, not reclaimed**. The edge-block design (12 in-family + +//! 4 out-of-family slots) was retired, and the temptation is to hand its 16 +//! bytes to the value slab and get two more calls. That is exactly the +//! field-widening the substrate's canon forbids: capacity comes from the next +//! cascade level, never from renegotiating this one. A zero tier means *not +//! consulted*, never *compacted away* — so a later mint can wake slot 1 with no +//! layout-version change, and [`FunctionNode::reserved_is_zeroed`] asserts it +//! stayed empty. +//! +//! # The key is opaque here, on purpose +//! +//! [`FunctionNode::key`] is a caller-supplied `[u8; 16]`. This crate does +//! **not** mint GUIDs: the canonical layout (classid · path tiers · tail) is +//! the substrate's, each vocabulary's app prefix is an operator decision, and +//! inventing either here would bake a guess into stored data. So the node +//! round-trips a key it does not interpret, and what this module actually +//! proves is the part it owns: **the value slab survives the trip +//! byte-for-byte**. +//! +//! # Interleave, not concatenation +//! +//! The slab is **not** 360 body bytes followed by 120 spare. Each 16-byte lane +//! is `classid(4) + payload(12)`, so a call's bytes sit at +//! `(i / calls_per_lane) * 16 + 4 + (i % calls_per_lane) * bytes_per_call`. +//! Writing the body as one contiguous run would produce a slab that looks +//! plausible, reads back correctly through the same wrong function, and is +//! wrong on the wire. [`FunctionBody::write_into_value_slab`] owns that +//! arithmetic; this module composes it and tests the composition against the +//! layout constants rather than against its own idea of them. + +use crate::{FunctionBody, LaneShape, SLOT_STRIDE, VALUE_SLAB_LEN}; + +/// Bytes in one stored node. +pub const NODE_BYTES: usize = 512; + +/// Byte offset of the key slot. +pub const KEY_OFFSET: usize = 0; +/// Bytes the key occupies (slot 0). +pub const KEY_BYTES: usize = SLOT_STRIDE; +/// Byte offset of the reserved slot (slot 1) — zeroed, never reclaimed. +pub const RESERVED_OFFSET: usize = SLOT_STRIDE; +/// Byte offset at which the value slab begins (slot 2). +pub const VALUE_OFFSET: usize = 2 * SLOT_STRIDE; + +// The layout is derived, not asserted twice: if a stride or a slot count ever +// changes, this fails to compile rather than silently storing a different +// shape. +const _: () = assert!(VALUE_OFFSET + VALUE_SLAB_LEN == NODE_BYTES); +const _: () = assert!(KEY_BYTES == SLOT_STRIDE); + +/// One function, as it is stored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FunctionNode { + /// The canonical GUID. **Opaque here** — this crate neither mints nor + /// interprets it; see the module docs. + pub key: [u8; 16], + /// The calls. + pub body: FunctionBody, +} + +impl FunctionNode { + /// A node holding `body` under `key`. + #[must_use] + pub fn new(key: [u8; 16], body: FunctionBody) -> Self { + Self { key, body } + } + + /// Serialize to the 512-byte block. + /// + /// `to_le_bytes` IS the wire format — no serde, no intermediate DTO (the + /// no-serialization-in-the-hot-path rule). Slot 1 is written as zeroes. + #[must_use] + pub fn to_le_bytes(&self) -> [u8; NODE_BYTES] { + let mut out = [0u8; NODE_BYTES]; + out[KEY_OFFSET..KEY_OFFSET + KEY_BYTES].copy_from_slice(&self.key); + // slot 1 stays zero — reserve, don't reclaim. + let mut slab = [0u8; VALUE_SLAB_LEN]; + self.body.write_into_value_slab(&mut slab); + out[VALUE_OFFSET..].copy_from_slice(&slab); + out + } + + /// Read a node back. + /// + /// `shape` is not stored in the node: the lane carving is a property of the + /// **class**, resolved through the key's classid, and duplicating it inside + /// the value slab would be a second source of truth that could disagree + /// with the first. The caller supplies what the ClassView says. + #[must_use] + pub fn from_le_bytes(bytes: &[u8; NODE_BYTES], shape: LaneShape) -> Self { + let mut key = [0u8; 16]; + key.copy_from_slice(&bytes[KEY_OFFSET..KEY_OFFSET + KEY_BYTES]); + let mut slab = [0u8; VALUE_SLAB_LEN]; + slab.copy_from_slice(&bytes[VALUE_OFFSET..]); + Self { + key, + body: FunctionBody::read_from_value_slab(shape, &slab), + } + } + + /// Whether the reserved slot is still empty in a serialized node. + /// + /// The retired edge block's 16 bytes must stay zeroed so a later mint can + /// wake them with no layout-version change. A caller that finds this false + /// is looking at a node from a layout this code does not describe. + #[must_use] + pub fn reserved_is_zeroed(bytes: &[u8; NODE_BYTES]) -> bool { + bytes[RESERVED_OFFSET..RESERVED_OFFSET + SLOT_STRIDE] + .iter() + .all(|b| *b == 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CLASSID_BYTES, Call, FnIndex}; + + fn key() -> [u8; 16] { + // An arbitrary opaque key — this crate does not mint, so the test must + // not model a minting scheme either. + let mut k = [0u8; 16]; + k[0..4].copy_from_slice(&0x1701_FF00_u32.to_le_bytes()); + k[10..16].copy_from_slice(&[1, 2, 3, 4, 5, 6]); + k + } + + /// `1 + 2 * 3` under the stack discipline. + fn expr_body(shape: LaneShape) -> FunctionBody { + FunctionBody::from_calls( + shape, + &[ + Call::with_value(FnIndex::NUMBER, 1), + Call::with_value(FnIndex::NUMBER, 2), + Call::with_value(FnIndex::NUMBER, 3), + Call::new(FnIndex::MUL), + Call::new(FnIndex::ADD), + ], + ) + .unwrap() + } + + #[test] + fn a_stored_node_round_trips_byte_for_byte() { + // THE claim: a program written to a row and read back is the same + // program. Not "reads without error" — the same CALLS. + let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs)); + let bytes = node.to_le_bytes(); + let back = FunctionNode::from_le_bytes(&bytes, LaneShape::Pairs); + + assert_eq!(back.key, node.key); + assert_eq!(back.body.len(), node.body.len()); + assert_eq!(back.body.as_body_bytes(), node.body.as_body_bytes()); + // …and serializing the recovered node reproduces the same 512 bytes, + // which a lossy read would fail even if the calls happened to match. + assert_eq!(back.to_le_bytes(), bytes); + } + + #[test] + fn a_different_program_stores_to_different_bytes() { + // Anti-vacuity for the round-trip: a `to_le_bytes` that returned a + // constant would round-trip perfectly and store nothing. + let one_plus_two = FunctionBody::from_calls( + LaneShape::Pairs, + &[ + Call::with_value(FnIndex::NUMBER, 1), + Call::with_value(FnIndex::NUMBER, 2), + Call::new(FnIndex::ADD), + ], + ) + .unwrap(); + let one_plus_three = FunctionBody::from_calls( + LaneShape::Pairs, + &[ + Call::with_value(FnIndex::NUMBER, 1), + Call::with_value(FnIndex::NUMBER, 3), + Call::new(FnIndex::ADD), + ], + ) + .unwrap(); + let a = FunctionNode::new(key(), one_plus_two); + let b = FunctionNode::new(key(), one_plus_three); + assert_ne!(a.to_le_bytes(), b.to_le_bytes()); + // …and the same program under a different KEY differs too, so the key + // is genuinely stored rather than dropped. + let mut k2 = key(); + k2[15] = 99; + let c = FunctionNode::new(k2, one_plus_two); + assert_ne!(a.to_le_bytes(), c.to_le_bytes()); + assert_eq!( + FunctionNode::from_le_bytes(&c.to_le_bytes(), LaneShape::Pairs).key, + k2 + ); + } + + #[test] + fn the_reserved_slot_stays_zeroed() { + // Reserve, don't reclaim. The retired edge block's 16 bytes are the + // obvious place to steal two more calls from, and stealing them is the + // field-widening the canon forbids. + let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs)); + let bytes = node.to_le_bytes(); + assert!(FunctionNode::reserved_is_zeroed(&bytes)); + assert!(bytes[RESERVED_OFFSET..VALUE_OFFSET].iter().all(|b| *b == 0)); + + // Two-sided: the guard must be able to say NO, or "stayed zeroed" is + // a function that returns true. + let mut tampered = bytes; + tampered[RESERVED_OFFSET + 7] = 1; + assert!(!FunctionNode::reserved_is_zeroed(&tampered)); + } + + #[test] + fn the_body_is_interleaved_across_lanes_not_written_contiguously() { + // The failure this catches is nasty precisely because it is + // self-consistent: a contiguous write reads back fine through the same + // wrong function, and is wrong on the wire. So the assertion is against + // the LAYOUT — a call's bytes must land at its lane offset, past the + // lane's 4 classid bytes. + let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs)); + let bytes = node.to_le_bytes(); + + // First call (NUMBER:1) sits at lane 0, just past the classid. + let first = VALUE_OFFSET + CLASSID_BYTES; + assert_eq!(bytes[first], 0x46, "first call's function byte"); + assert_eq!(bytes[first + 1], 1, "first call's value byte"); + + // Pairs packs 6 calls per 12-byte lane, so call 6 begins the SECOND + // lane — 16 bytes on, not 12. A contiguous writer puts it at 12. + let lane1 = VALUE_OFFSET + SLOT_STRIDE + CLASSID_BYTES; + assert_eq!(lane1 - first, SLOT_STRIDE, "lanes are strided by 16"); + // The classid gap really is a gap: those 4 bytes are untouched by the + // body writer. + let gap = VALUE_OFFSET + SLOT_STRIDE; + assert!( + bytes[gap..gap + CLASSID_BYTES].iter().all(|b| *b == 0), + "the body must not write into a lane's classid bytes" + ); + } + + #[test] + fn every_shape_round_trips_and_the_shape_is_not_stored() { + // The shape is a class property resolved through the key, deliberately + // not duplicated in the slab. So the same bytes read under a different + // shape yield a DIFFERENT program — which is correct, and is why the + // caller must supply what the ClassView says rather than guessing. + for shape in [LaneShape::Pairs, LaneShape::Triples, LaneShape::Quads] { + let node = FunctionNode::new(key(), expr_body(shape)); + let bytes = node.to_le_bytes(); + let back = FunctionNode::from_le_bytes(&bytes, shape); + let a: Vec = back.body.calls().collect(); + let b: Vec = node.body.calls().collect(); + assert_eq!(a, b, "{shape:?} did not round-trip"); + } + // Two-sided: reading Pairs bytes as Quads must NOT silently agree, or + // "the shape matters" is untested. + let pairs = FunctionNode::new(key(), expr_body(LaneShape::Pairs)); + let bytes = pairs.to_le_bytes(); + let misread = FunctionNode::from_le_bytes(&bytes, LaneShape::Quads); + let a: Vec = misread.body.calls().collect(); + let b: Vec = pairs.body.calls().collect(); + assert_ne!(a, b); + } + + #[test] + fn a_full_body_fills_the_slab_without_overrunning_the_node() { + // The boundary: a program at exactly the shape's budget must still fit + // in 512 bytes, and must not disturb the key or the reserved slot. + let cap = LaneShape::Pairs.calls_per_function(); + let calls: Vec = (0..cap) + .map(|i| Call::with_value(FnIndex::NUMBER, (i % 250) as u8 + 1)) + .collect(); + let body = FunctionBody::from_calls(LaneShape::Pairs, &calls).unwrap(); + assert_eq!(body.len(), cap); + + let node = FunctionNode::new(key(), body); + let bytes = node.to_le_bytes(); + assert_eq!(&bytes[..16], &key()); + assert!(FunctionNode::reserved_is_zeroed(&bytes)); + let back = FunctionNode::from_le_bytes(&bytes, LaneShape::Pairs); + assert_eq!(back.body.len(), cap); + assert_eq!(back.to_le_bytes(), bytes); + } +} diff --git a/crates/ogar-loco/src/pool.rs b/crates/ogar-loco/src/pool.rs new file mode 100644 index 0000000..857b421 --- /dev/null +++ b/crates/ogar-loco/src/pool.rs @@ -0,0 +1,469 @@ +//! The constant pool — where a wide literal spends its immediate byte. +//! +//! # Why a pool at all +//! +//! A [`Call`](crate::Call)'s immediate is one byte under +//! [`LaneShape::Pairs`](crate::LaneShape). A numeric field is a double; a +//! text field is arbitrary UTF-8. Neither fits. So the byte becomes an +//! **index**, and the only real design question is where the indexed bytes +//! live and what classid governs their reading. +//! +//! # The shape: a sibling node, not a wider row +//! +//! A pool is a second 512-byte V3 node whose identity IS the owning function's +//! — same 30 content slots, same 16-byte stride, same `classid(4) + 12` facet. +//! Each facet carries its own classid naming the constant's **type**, because +//! "your classid defines the schema, period": an `f64` and a UTF-8 string are +//! different readings of 12 bytes, so they are different classids, and the +//! per-facet classid is exactly where the substrate lets a slot say so. +//! +//! Three alternatives were considered and rejected, each by a specific +//! constraint rather than by taste: +//! +//! - **Encode the literal as a run of calls** (no pool). Killed by the +//! one-write gate, not by aesthetics: the call count would depend on the +//! literal's width, so editing `255` to `1000000` would shift every +//! subsequent call and rewrite the tail of the body. An operand edit must +//! produce ONE write. +//! - **Steal content slots from the body node.** Killed by the call-index +//! arithmetic: `BODY_BYTES` and the capacity asserts all assume 30 call +//! lanes, so the budget would become per-function, and "add one string" +//! could make a program that fit stop fitting — with the overflow blaming +//! the calls rather than the literals. +//! - **Hold the pool in the Inventory SoA.** Killed by ownership: Inventory is +//! shared by every function, so a per-function pool living there is a +//! shared-mutable sink with N writers. Inventory indexes *functions*, which +//! are shared by definition; constants are *per-function data*, which are +//! owned by definition. Same one-byte index shape, opposite ownership. +//! +//! # Index arithmetic +//! +//! ```text +//! idx ∈ 1..=255 (0 = zero-fallback: NO constant) +//! node_ordinal = (idx - 1) / 30 +//! slot_j = (idx - 1) % 30 +//! payload = slot_j * 16 + 4 (classid at slot_j * 16) +//! ``` +//! +//! `idx = 0` is the zero-fallback rung and is never reclaimed as a real index, +//! so a zeroed value byte reads as "no constant" rather than as constant zero. +//! +//! # Capacity, and where it can actually bind +//! +//! | shape | calls | value bytes/call | distinct indices a body can name | +//! |---|---|---|---| +//! | `Pairs` | 180 | 1 | 180 — under 255, pool can never overflow first | +//! | `Triples` | 120 | 2 | 240 — still under | +//! | `Quads` | 90 | 3 | 270 — **can** exhaust the pool | +//! +//! So [`PoolError::Full`] is reachable only under `Quads`, only with ≥256 +//! distinct constants, and only without dedup. It is implemented and tested +//! anyway, because "unreachable" is a measurement and not a guarantee. The +//! remedy at 255 is the same as at `BodyError::Overflow` — **split the +//! function**. A `u16` index is not the remedy; it would re-open field-widening +//! at the one place the ABI is least defended. +//! +//! # Repoint, never mutate +//! +//! Interning is content-addressed, so one `3.14` referenced by two calls is one +//! entry. Editing ONE of those call sites therefore must not move the other: +//! an edit **interns the new value and repoints that call's index byte**. It +//! never rewrites a pool entry in place. Mutating in place would be the +//! shared-mutable-sink defect one layer down, and would make a local edit +//! change a program elsewhere in the same function. +//! +//! Repointing leaves orphans. They are **not** compacted implicitly: +//! compaction renumbers, renumbering rewrites every referencing body byte, and +//! that turns a one-byte edit into a whole-program write. Reserve, don't +//! reclaim — an orphan holds its index until an explicit, versioned pass. +//! +//! # The classids are PARAMETERS, and that is deliberate +//! +//! `ConstantPool` never names a concept id. The caller supplies the facet +//! classid, because minting constant-type concepts is an operator decision +//! with a ledger entry, and this crate does not get to assume one. The +//! arithmetic and the dedup — which is where the defects live — are testable +//! today against [`placeholder`] classids; the mint changes two constants, +//! not the logic. + +use crate::{CLASSID_BYTES, CONTENT_SLOTS, PAYLOAD_BYTES_PER_SLOT, SLOT_STRIDE}; + +/// Payload bytes one constant facet carries. +pub const CONSTANT_BYTES: usize = PAYLOAD_BYTES_PER_SLOT; + +/// Constants per pool node — one per content slot. +pub const CONSTANTS_PER_NODE: usize = CONTENT_SLOTS; + +/// The largest index a value byte can name. `0` is the zero-fallback, so the +/// usable domain is `1..=255`. +pub const MAX_CONSTANTS: usize = 255; + +/// Placeholder facet classids, for use until the concepts are minted. +/// +/// These are **not** proposed ids and must not become them: they sit in a +/// deliberately invalid range so that a placeholder escaping into stored data +/// is loud rather than plausible. The mint proposal lives in the block-editor +/// plan's ledger; nothing here assumes it. +pub mod placeholder { + /// Placeholder for an `f64` constant. + pub const CONST_F64: u32 = 0xDEAD_0001; + /// Placeholder for an inline UTF-8 constant. + pub const CONST_UTF8_INLINE: u32 = 0xDEAD_0002; +} + +/// Why a constant could not be interned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PoolError { + /// All 255 indices are spoken for. The remedy is a function split, never a + /// wider index. + Full, + /// The value does not fit one facet's 12 payload bytes. Refused rather + /// than truncated — a truncated constant would look like success, which is + /// strictly worse than a refusal. + TooWide { + /// How many bytes the value needed. + needed: usize, + }, +} + +impl core::fmt::Display for PoolError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + PoolError::Full => write!( + f, + "the constant pool is full at {MAX_CONSTANTS} entries; split the function" + ), + PoolError::TooWide { needed } => write!( + f, + "constant needs {needed} bytes, more than the {CONSTANT_BYTES} a facet holds" + ), + } + } +} + +impl core::error::Error for PoolError {} + +/// One interned constant: its type classid and its 12 payload bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Constant { + /// The facet classid naming HOW the payload reads. + pub classid: u32, + /// The payload, zero-padded to the facet width. + pub bytes: [u8; CONSTANT_BYTES], +} + +/// A function's constant pool. +/// +/// Held beside the [`FunctionBody`](crate::FunctionBody), never inside it — +/// the body's 30 lanes stay 30 call lanes, and its capacity arithmetic is +/// untouched by how many constants a program uses. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConstantPool { + entries: Vec, +} + +impl ConstantPool { + /// An empty pool. + #[must_use] + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// How many constants are interned. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the pool holds nothing. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// How many pool NODES this pool currently occupies. + #[must_use] + pub fn node_count(&self) -> usize { + self.entries.len().div_ceil(CONSTANTS_PER_NODE) + } + + /// Intern a value, returning the index a call's value byte carries. + /// + /// **Content-addressed**: interning the same `(classid, bytes)` twice + /// returns the same index. That is what keeps one `3.14` used twice from + /// spending two slots — and it is what makes the repoint-don't-mutate rule + /// necessary, since an entry may have several referents. + /// + /// # Errors + /// + /// [`PoolError::TooWide`] if the value exceeds a facet; [`PoolError::Full`] + /// at 255 entries. + pub fn intern(&mut self, classid: u32, value: &[u8]) -> Result { + if value.len() > CONSTANT_BYTES { + return Err(PoolError::TooWide { + needed: value.len(), + }); + } + let mut bytes = [0u8; CONSTANT_BYTES]; + bytes[..value.len()].copy_from_slice(value); + let candidate = Constant { classid, bytes }; + + if let Some(pos) = self.entries.iter().position(|c| *c == candidate) { + // Position is < len <= MAX_CONSTANTS, so the +1 cannot overflow. + return Ok(u8::try_from(pos + 1).expect("interned index within u8")); + } + if self.entries.len() >= MAX_CONSTANTS { + return Err(PoolError::Full); + } + self.entries.push(candidate); + u8::try_from(self.entries.len()).map_err(|_| PoolError::Full) + } + + /// Intern an `f64` under the caller's classid. + /// + /// # Errors + /// + /// As [`intern`](Self::intern). + pub fn intern_f64(&mut self, classid: u32, value: f64) -> Result { + self.intern(classid, &value.to_le_bytes()) + } + + /// Intern a string under the caller's classid — inline only. + /// + /// A string longer than one facet is **refused**, not chained: the + /// continuation encoding is a named follow-up, and shipping a chaining + /// rule with no corpus behind it would be a guess wearing a pool costume. + /// + /// # Errors + /// + /// As [`intern`](Self::intern). + pub fn intern_str(&mut self, classid: u32, value: &str) -> Result { + self.intern(classid, value.as_bytes()) + } + + /// Resolve an index back to its constant. + /// + /// `0` is the zero-fallback and yields `None` — a zeroed value byte means + /// "no constant", never "constant zero". + #[must_use] + pub fn resolve(&self, idx: u8) -> Option<&Constant> { + if idx == 0 { + return None; + } + self.entries.get(usize::from(idx) - 1) + } + + /// Which pool node an index lives in, and which slot within it. + /// + /// Returns `None` for the zero-fallback. + #[must_use] + pub fn locate(idx: u8) -> Option<(usize, usize)> { + if idx == 0 { + return None; + } + let zero_based = usize::from(idx) - 1; + Some(( + zero_based / CONSTANTS_PER_NODE, + zero_based % CONSTANTS_PER_NODE, + )) + } + + /// The byte offset of a slot's PAYLOAD inside a pool node's value slab. + /// + /// The facet's classid sits at `slot_j * SLOT_STRIDE`, immediately before. + #[must_use] + pub const fn slot_payload_offset(slot_j: usize) -> usize { + slot_j * SLOT_STRIDE + CLASSID_BYTES + } + + /// Write one pool node's value slab: classid + payload per occupied slot, + /// zeroes elsewhere. + /// + /// Unoccupied slots are **zeroed, not skipped** — reserve, don't reclaim. + #[must_use] + pub fn write_node(&self, node_ordinal: usize) -> [u8; CONTENT_SLOTS * SLOT_STRIDE] { + let mut slab = [0u8; CONTENT_SLOTS * SLOT_STRIDE]; + let base = node_ordinal * CONSTANTS_PER_NODE; + for slot_j in 0..CONSTANTS_PER_NODE { + let Some(c) = self.entries.get(base + slot_j) else { + break; + }; + let at = slot_j * SLOT_STRIDE; + slab[at..at + CLASSID_BYTES].copy_from_slice(&c.classid.to_le_bytes()); + let p = Self::slot_payload_offset(slot_j); + slab[p..p + CONSTANT_BYTES].copy_from_slice(&c.bytes); + } + slab + } +} + +#[cfg(test)] +mod tests { + use super::*; + use placeholder::{CONST_F64, CONST_UTF8_INLINE}; + + #[test] + fn interning_two_distinct_values_yields_two_distinct_indices() { + // Part A of the falsifier: it must FIRE, and carry information. A pool + // that returned a fixed index for every literal would pass "lowered + // ok" and fail right here. + let mut pool = ConstantPool::new(); + let small = pool.intern_f64(CONST_F64, 7.25).unwrap(); + let big = pool.intern_f64(CONST_F64, 1_000_000.0).unwrap(); + assert_ne!( + small, big, + "two distinct constants collapsed onto one index" + ); + assert_eq!(pool.len(), 2); + + // …and the values read back BIT-EXACT. Asserting only that interning + // succeeded would be truncation wearing a pool costume, which is the + // exact defect the whole design exists to prevent. + let back = |idx: u8| { + let c = pool.resolve(idx).unwrap(); + assert_eq!(c.classid, CONST_F64); + f64::from_le_bytes(c.bytes[..8].try_into().unwrap()) + }; + assert_eq!(back(small), 7.25); + assert_eq!(back(big), 1_000_000.0); + } + + #[test] + fn the_same_value_interns_once_and_two_values_interns_twice() { + // Two-sided. The first half alone passes a pool that returns index 1 + // for everything; the second alone passes a pool that never dedups. + // Both are needed. + let mut pool = ConstantPool::new(); + let a = pool.intern_f64(CONST_F64, 1.5).unwrap(); + let b = pool.intern_f64(CONST_F64, 1.5).unwrap(); + assert_eq!(a, b, "the same value must intern once"); + assert_eq!(pool.len(), 1); + + let c = pool.intern_f64(CONST_F64, 1.25).unwrap(); + assert_ne!(a, c, "different values must not share an index"); + assert_eq!(pool.len(), 2); + } + + #[test] + fn the_classid_participates_in_identity() { + // The same bytes under two different readings are two constants — + // "your classid defines the schema, period". Deduping on bytes alone + // would make an f64 and a string alias. + let mut pool = ConstantPool::new(); + let a = pool.intern(CONST_F64, &[1, 2, 3]).unwrap(); + let b = pool.intern(CONST_UTF8_INLINE, &[1, 2, 3]).unwrap(); + assert_ne!(a, b); + assert_eq!(pool.len(), 2); + } + + #[test] + fn index_zero_is_the_fallback_and_never_a_constant() { + let mut pool = ConstantPool::new(); + assert_eq!(pool.resolve(0), None, "empty pool"); + let first = pool.intern_f64(CONST_F64, 1.0).unwrap(); + // The first real constant is index 1, NOT 0 — otherwise a zeroed value + // byte would read as a live constant reference. + assert_eq!(first, 1); + assert_eq!(pool.resolve(0), None, "populated pool"); + assert!(pool.resolve(1).is_some()); + assert_eq!(ConstantPool::locate(0), None); + } + + #[test] + fn the_node_and_slot_arithmetic_matches_the_documented_boundaries() { + // The four boundaries the module doc names, asserted rather than + // narrated. A `<=` vs `<` slip in the divisor shows up here. + assert_eq!(ConstantPool::locate(1), Some((0, 0))); + assert_eq!(ConstantPool::locate(30), Some((0, 29))); + assert_eq!(ConstantPool::locate(31), Some((1, 0))); + assert_eq!(ConstantPool::locate(255), Some((8, 14))); + + assert_eq!(ConstantPool::slot_payload_offset(0), 4); + assert_eq!(ConstantPool::slot_payload_offset(29), 468); + // The last payload must END inside the slab, not past it. + assert_eq!( + ConstantPool::slot_payload_offset(29) + CONSTANT_BYTES, + CONTENT_SLOTS * SLOT_STRIDE + ); + } + + #[test] + fn a_value_wider_than_a_facet_is_refused_not_truncated() { + let mut pool = ConstantPool::new(); + // Twelve bytes fit exactly; thirteen do not. Two-sided, so a guard + // that refused everything would fail the first half. + assert!(pool.intern_str(CONST_UTF8_INLINE, "abcdefghijkl").is_ok()); + assert_eq!( + pool.intern_str(CONST_UTF8_INLINE, "abcdefghijklm"), + Err(PoolError::TooWide { needed: 13 }) + ); + assert_eq!(pool.len(), 1, "the refused value must not have landed"); + } + + #[test] + fn the_pool_fills_at_255_and_refuses_the_256th() { + // Reachable only under Quads with 256 distinct constants and no dedup + // — but implemented and tested, because "unreachable" is a + // measurement, not a guarantee. + let mut pool = ConstantPool::new(); + for i in 0..MAX_CONSTANTS { + let v = u32::try_from(i).unwrap(); + let idx = pool.intern(CONST_F64, &v.to_le_bytes()).unwrap(); + assert_eq!(usize::from(idx), i + 1); + } + assert_eq!(pool.len(), MAX_CONSTANTS); + assert_eq!(pool.node_count(), 9); + assert_eq!( + pool.intern(CONST_F64, &999_u32.to_le_bytes()), + Err(PoolError::Full) + ); + // Silence twin: a FULL pool still resolves an already-interned value + // rather than erroring — dedup must not be collateral damage. + assert_eq!(pool.intern(CONST_F64, &7_u32.to_le_bytes()), Ok(8)); + } + + #[test] + fn a_written_node_places_classid_then_payload_at_the_documented_offsets() { + let mut pool = ConstantPool::new(); + pool.intern_f64(CONST_F64, 1.0).unwrap(); + pool.intern_str(CONST_UTF8_INLINE, "hi").unwrap(); + let slab = pool.write_node(0); + + assert_eq!(&slab[0..4], &CONST_F64.to_le_bytes()); + assert_eq!( + f64::from_le_bytes(slab[4..12].try_into().unwrap()), + 1.0, + "payload must sit at slot*16 + 4" + ); + assert_eq!(&slab[16..20], &CONST_UTF8_INLINE.to_le_bytes()); + assert_eq!(&slab[20..22], b"hi"); + // Unoccupied slots are zeroed, not skipped — reserve, don't reclaim. + assert!(slab[32..].iter().all(|b| *b == 0)); + // And the two facets did NOT overlap: slot 1's classid begins exactly + // where slot 0's 12-byte payload ends. + assert_eq!( + ConstantPool::slot_payload_offset(0) + CONSTANT_BYTES, + SLOT_STRIDE + ); + } + + #[test] + fn the_second_node_holds_the_31st_constant_and_not_the_30th() { + let mut pool = ConstantPool::new(); + for i in 0..31u32 { + pool.intern(CONST_F64, &i.to_le_bytes()).unwrap(); + } + assert_eq!(pool.node_count(), 2); + let node1 = pool.write_node(1); + // Constant #31 (index 31) is node 1, slot 0. + assert_eq!(&node1[4..8], &30_u32.to_le_bytes()); + // …and node 1's slot 1 onward is still zero. + assert!(node1[16..].iter().all(|b| *b == 0)); + // Node 0's LAST slot holds #30, proving the boundary is not off by one. + let node0 = pool.write_node(0); + let last = ConstantPool::slot_payload_offset(29); + assert_eq!(&node0[last..last + 4], &29_u32.to_le_bytes()); + } +} diff --git a/crates/ogar-loco/src/program.rs b/crates/ogar-loco/src/program.rs new file mode 100644 index 0000000..7212641 --- /dev/null +++ b/crates/ogar-loco/src/program.rs @@ -0,0 +1,239 @@ +//! A **program** — the several functions a script with control flow becomes. +//! +//! # Why one script is not one function +//! +//! Nesting is **by reference**: a loop body is another function's node, named +//! by index. No `END` marker, no jump offset. So `repeat 10 [ … ]` is **two** +//! functions — the caller and the body — and the caller spends one value byte +//! naming the second. +//! +//! That is what keeps a node fixed-size: a body of any length costs its parent +//! exactly one byte. Splicing the body inline with a terminator would make a +//! call's width depend on its contents, so editing inside a loop would shift +//! every later call in the enclosing function — the same defect that ruled out +//! literal-as-call-run for the constant pool. +//! +//! # Function 0 is the entry, and indices are stable +//! +//! [`Program::functions`] is indexed by the byte a caller stores. Function `0` +//! is the script's own body. An index, once assigned, does not move — which is +//! what makes it safe to store. +//! +//! Index `0` is therefore a **real** function rather than a zero-fallback, and +//! that is deliberate: nothing references function 0 (the entry is entered, +//! not branched to), so `0` in a body-reference byte would be a bug rather +//! than a sentinel — [`Program::references_are_resolvable`] is what says so. +//! +//! # The forward-reference invariant (for lowerers) +//! +//! A lowerer should reserve a body's index BEFORE lowering its contents, so a +//! parent's index is always LOWER than its children's and every stored +//! reference points **forward**. (Both orders are bijective; the reservation +//! buys the readable invariant, not collision-freedom — a lesson recorded in +//! the first lowerer's history.) Lowering itself lives in the vocabulary +//! crates: casting a frontend's records into calls needs the frontend's +//! shapes, and this crate deliberately has none. +//! +//! # What this does not do +//! +//! It does not mint keys. A stored program is N +//! [`FunctionNode`](crate::FunctionNode)s and each needs a GUID; minting is +//! the substrate's, per vocabulary. A `Program` carries bodies and the caller +//! supplies keys — the same boundary [`crate::node`] draws. + +use crate::{Call, FunctionBody, Vocabulary}; + +/// One script's functions. Index `0` is the entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Program { + /// The bodies, indexed by the byte a body-reference stores. + pub functions: Vec, +} + +impl Program { + /// The entry body. + #[must_use] + pub fn entry(&self) -> &FunctionBody { + &self.functions[0] + } + + /// How many functions the script became. + #[must_use] + pub fn len(&self) -> usize { + self.functions.len() + } + + /// Whether the program holds no functions. Never true for a lowered + /// script — the entry always exists. + #[must_use] + pub fn is_empty(&self) -> bool { + self.functions.is_empty() + } + + /// Every body-reference names a function that exists, and none names the + /// entry. + /// + /// Both halves matter. An out-of-range index is a dangling branch; a + /// reference to function `0` is a loop back into the entry, which a + /// lowerer never emits and which would be an unbounded recursion if + /// honoured. + /// + /// The vocabulary is a parameter because *which value bytes are + /// references* is a per-function fact ([`Vocabulary::body_refs`]) — the + /// same bytes under a different vocabulary could be plain immediates. + #[must_use] + pub fn references_are_resolvable(&self, v: &V) -> bool { + for body in &self.functions { + for call in body.calls() { + let n = v.body_refs(call.function); + for slot in 0..usize::from(n) { + let idx = usize::from(call.values[slot]); + if idx == 0 || idx >= self.functions.len() { + return false; + } + } + } + } + true + } +} + +/// Read a body's calls back as `(index, call, targets)` triples, resolving +/// which value bytes are branches. +/// +/// Useful to a consumer walking a program: it says *which* bytes are function +/// indices without re-deriving [`Vocabulary::body_refs`] call by call. +#[must_use] +pub fn branches_of(v: &V, body: &FunctionBody) -> Vec<(usize, Call, Vec)> { + body.calls() + .enumerate() + .filter_map(|(i, c)| { + let n = usize::from(v.body_refs(c.function)); + (n > 0).then(|| (i, c, c.values[..n].to_vec())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FnIndex, LaneShape}; + + const S: LaneShape = LaneShape::Pairs; + + /// The smallest conforming vocabulary — everything below the floor is the + /// shared core, nothing above it. + struct EmptyVocab; + impl Vocabulary for EmptyVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + } + + /// `repeat 10 [ print 1 ]` — hand-lowered: entry = (NUMBER:10)(REPEAT:1), + /// function 1 = (NUMBER:1)(PRINT). + fn repeat_ten() -> Program { + Program { + functions: vec![ + FunctionBody::from_calls( + S, + &[ + Call::with_value(FnIndex::NUMBER, 10), + Call::with_value(FnIndex::REPEAT, 1), + ], + ) + .unwrap(), + FunctionBody::from_calls( + S, + &[ + Call::with_value(FnIndex::NUMBER, 1), + Call::new(FnIndex::PRINT), + ], + ) + .unwrap(), + ], + } + } + + #[test] + fn every_reference_resolves_and_none_points_at_the_entry() { + let v = EmptyVocab; + let prog = repeat_ten(); + assert!(prog.references_are_resolvable(&v)); + assert_eq!(prog.len(), 2); + assert_eq!(prog.entry().len(), 2); + + // Two-sided, and both halves are real failure modes. A dangling index: + let mut dangling = prog.clone(); + dangling.functions[0] = FunctionBody::from_calls( + S, + &[ + Call::with_value(FnIndex::NUMBER, 10), + Call::with_value(FnIndex::REPEAT, 9), + ], + ) + .unwrap(); + assert!( + !dangling.references_are_resolvable(&v), + "a dangling branch must be caught" + ); + + // …and a branch back into the entry, which would be unbounded. + dangling.functions[0] = FunctionBody::from_calls( + S, + &[ + Call::with_value(FnIndex::NUMBER, 10), + Call::with_value(FnIndex::REPEAT, 0), + ], + ) + .unwrap(); + assert!( + !dangling.references_are_resolvable(&v), + "a branch to the entry must be caught" + ); + } + + #[test] + fn resolvability_consults_the_vocabulary_not_a_baked_table() { + // The SAME bytes must flip from resolvable to dangling when the + // vocabulary declares the function branching — which is the whole + // reason `references_are_resolvable` takes a vocabulary parameter. + struct BranchyDomain; + impl Vocabulary for BranchyDomain { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + Some(0) + } + fn domain_body_refs(&self, f: FnIndex) -> u8 { + u8::from(f.0 == 0x90) + } + } + + let prog = Program { + functions: vec![ + FunctionBody::from_calls(S, &[Call::with_value(FnIndex(0x90), 7)]).unwrap(), + ], + }; + // Under the empty vocabulary 0x90's value byte is a plain immediate. + assert!(prog.references_are_resolvable(&EmptyVocab)); + // Under a vocabulary where 0x90 branches, 7 is a dangling reference. + assert!(!prog.references_are_resolvable(&BranchyDomain)); + } + + #[test] + fn branches_of_reports_which_bytes_are_function_indices() { + let v = EmptyVocab; + let prog = repeat_ten(); + let b = branches_of(&v, prog.entry()); + assert_eq!(b.len(), 1, "one branching call in the entry"); + let (idx, call, targets) = &b[0]; + assert_eq!(*idx, 1, "it is the second call"); + assert_eq!(call.function, FnIndex::REPEAT); + assert_eq!(targets, &vec![1u8]); + // Silence twin: a straight-line body reports NO branches, so this is + // not a function that always finds something. + assert!(branches_of(&v, &prog.functions[1]).is_empty()); + } +} diff --git a/crates/ogar-loco/src/vocabulary.rs b/crates/ogar-loco/src/vocabulary.rs new file mode 100644 index 0000000..b8d36ed --- /dev/null +++ b/crates/ogar-loco/src/vocabulary.rs @@ -0,0 +1,486 @@ +//! The vocabulary seam — how a sibling codebook plugs into the shared surface. +//! +//! # The sharing discipline +//! +//! One call-ABI, sibling vocabularies selected by classid, *"not much +//! different than blockly, just different vocabulary"* (the operator frame). +//! The discipline that keeps that from decaying into N dialects: +//! +//! - Bytes **below [`DOMAIN_FLOOR`]** are the **shared computational core**. +//! Their stack arities and body-reference counts live ONCE, here, in +//! [`shared_core`] — so `IF` cannot quietly mean two things in two domains, +//! and no sibling can drift on `ADD`'s arity. +//! - Bytes **at/above the floor** belong to the vocabulary. A [`Vocabulary`] +//! implementation answers for exactly that range via its `domain_*` hooks; +//! the composed methods route each byte to the right table. +//! - A shared-core byte the core does not cover (e.g. `WAIT` today) is +//! **refused everywhere** — a vocabulary does not get to guess for it. +//! Coverage grows in the core, once, for everyone. +//! +//! [`conformance::check`] is the mechanical enforcement: run it in every +//! vocabulary crate's tests. It catches a composed-method override that +//! drifts from the core (the JVM-verifier / Wasm-validator posture: validate +//! before trusting, refuse loudly). +//! +//! # The two-quantity split (why there are TWO tables) +//! +//! A call has two independent numbers, and conflating them is the bug this +//! module exists to prevent: +//! +//! | | what it is | where it lives | +//! |---|---|---| +//! | [`stack_arity`](Vocabulary::stack_arity) | operands evaluated before the call | the stack | +//! | [`body_refs`](Vocabulary::body_refs) | function indices this call branches to | the call's **value bytes** | +//! +//! `repeat 10 [ … ]` consumes **one** stack operand (the count) and +//! **references** a body. The body is not an operand: it is not on the stack, +//! it was not evaluated before the call, and popping it would silently +//! reattribute whatever *was* on the stack. `forever` proves the quantities +//! are independent: zero operands, one body. A single conflated number cannot +//! express it. + +use crate::{DOMAIN_FLOOR, FnIndex, LaneShape}; + +/// The shared computational core's tables — authoritative for every byte +/// below [`DOMAIN_FLOOR`], in every vocabulary. +pub mod shared_core { + use super::{FnIndex, LaneShape}; + + /// How many operands a shared-core call pops from the stack. + /// + /// `None` means the core does not cover this function — refused rather + /// than guessed, because a wrong arity does not produce a slightly-wrong + /// result: it desynchronizes the stack and reattributes every later + /// operand. (`WAIT`, `STOP`, `RETURN`, `TERNARY` and others are real + /// palette entries deliberately not yet covered.) + /// + /// For control flow this counts **only** the evaluated operands. A loop + /// body is not among them; see [`body_refs`]. + #[must_use] + pub fn stack_arity(f: FnIndex) -> Option { + Some(match f { + // ── control: bodies only — nothing evaluated first. + FnIndex::FOREVER => 0, + // One condition or count, then a body. + FnIndex::IF + | FnIndex::IF_ELSE + | FnIndex::REPEAT + | FnIndex::WHILE + | FnIndex::REPEAT_UNTIL + | FnIndex::FOR_EACH => 1, + // from, to, by — then a body. + FnIndex::FOR_RANGE => 3, + // Leave the enclosing loop / iteration. No operand, no body. + FnIndex::BREAK | FnIndex::CONTINUE => 0, + // ── leaves — they push, they do not consume. + FnIndex::NUMBER + | FnIndex::TEXT + | FnIndex::TRUE + | FnIndex::FALSE + | FnIndex::NULL + | FnIndex::CONSTANT + | FnIndex::VAR_GET => 0, + // ── unary. + FnIndex::NOT + | FnIndex::NEG + | FnIndex::ABS + | FnIndex::SQRT + | FnIndex::LN + | FnIndex::LOG10 + | FnIndex::EXP_E + | FnIndex::EXP_10 + | FnIndex::SIN + | FnIndex::COS + | FnIndex::TAN + | FnIndex::ASIN + | FnIndex::ACOS + | FnIndex::ATAN + | FnIndex::ROUND + | FnIndex::FLOOR + | FnIndex::CEIL + | FnIndex::LENGTH => 1, + // ── binary. + FnIndex::ADD + | FnIndex::SUB + | FnIndex::MUL + | FnIndex::DIV + | FnIndex::POW + | FnIndex::MOD + | FnIndex::EQ + | FnIndex::NEQ + | FnIndex::LT + | FnIndex::LTE + | FnIndex::GT + | FnIndex::GTE + | FnIndex::AND + | FnIndex::OR + | FnIndex::JOIN => 2, + _ => return None, + }) + } + + /// How many of a shared-core call's value bytes are **function indices** + /// it branches to. + /// + /// Zero for every expression call. `IF_ELSE` is the only two, which is + /// why it needs a shape wider than [`LaneShape::Pairs`] — see + /// [`min_shape`]. + #[must_use] + pub fn body_refs(f: FnIndex) -> u8 { + match f { + FnIndex::IF + | FnIndex::REPEAT + | FnIndex::WHILE + | FnIndex::REPEAT_UNTIL + | FnIndex::FOREVER + | FnIndex::FOR_EACH + | FnIndex::FOR_RANGE => 1, + FnIndex::IF_ELSE => 2, + _ => 0, + } + } + + /// Whether this function is control flow at all — i.e. it references a + /// body. + /// + /// `BREAK` and `CONTINUE` are control flow in the language sense but + /// reference nothing, so they are deliberately **not** included: this + /// predicate answers "does lowering this call require emitting another + /// function?", which is the only question a cast asks. + #[must_use] + pub fn branches(f: FnIndex) -> bool { + body_refs(f) > 0 + } + + /// The narrowest [`LaneShape`] that can hold this call's value bytes. + /// + /// `IF_ELSE` carries two body references, so it cannot be stored under + /// `Pairs` — a one-byte immediate would truncate the else arm into + /// nothing, and the program would run its then-branch and silently skip + /// the else. A cast refuses rather than narrowing; this is what it + /// consults. + #[must_use] + pub fn min_shape(f: FnIndex) -> LaneShape { + match body_refs(f) { + 0 | 1 => LaneShape::Pairs, + _ => LaneShape::Triples, + } + } +} + +/// A sibling codebook over the shared surface. +/// +/// Implementations answer for the **domain range** (bytes at/above +/// [`DOMAIN_FLOOR`]) through the `domain_*` hooks; the composed methods +/// route shared-core bytes to [`shared_core`]'s tables unconditionally. +/// +/// **Do not override the composed methods.** Rust cannot seal a default +/// method, so the guarantee is enforced socially AND mechanically: every +/// vocabulary crate runs [`conformance::check`] in its tests, and an +/// override that drifts a shared-core answer fails it. +pub trait Vocabulary { + /// Stack arity for a domain-range function. `None` = not covered + /// (refused). Never consulted for shared-core bytes. + fn domain_stack_arity(&self, f: FnIndex) -> Option; + + /// Body-reference count for a domain-range function. Never consulted for + /// shared-core bytes. + fn domain_body_refs(&self, f: FnIndex) -> u8; + + /// How many operands `f` pops — shared core first, domain hooks above + /// the floor. + fn stack_arity(&self, f: FnIndex) -> Option { + if f.0 < DOMAIN_FLOOR { + shared_core::stack_arity(f) + } else { + self.domain_stack_arity(f) + } + } + + /// How many of `f`'s value bytes are function indices — shared core + /// first, domain hooks above the floor. + fn body_refs(&self, f: FnIndex) -> u8 { + if f.0 < DOMAIN_FLOOR { + shared_core::body_refs(f) + } else { + self.domain_body_refs(f) + } + } + + /// Does lowering `f` require emitting another function? + fn branches(&self, f: FnIndex) -> bool { + self.body_refs(f) > 0 + } + + /// The narrowest shape that can hold `f`'s body references. + fn min_shape(&self, f: FnIndex) -> LaneShape { + match self.body_refs(f) { + 0 | 1 => LaneShape::Pairs, + _ => LaneShape::Triples, + } + } +} + +/// Mechanical conformance: what every vocabulary crate's tests must run. +pub mod conformance { + use super::{DOMAIN_FLOOR, FnIndex, Vocabulary, shared_core}; + + /// A way a vocabulary violates the sharing discipline. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ConformanceError { + /// A shared-core byte answers differently through the vocabulary than + /// through [`shared_core`] — a composed-method override drifted. + SharedCoreDrift { + /// The byte that drifted. + f: FnIndex, + /// Which table drifted: `"stack_arity"` or `"body_refs"`. + what: &'static str, + }, + /// `min_shape` reports a shape too narrow to hold the function's own + /// body references — a truncation waiting to happen. + ShapeTooNarrowForRefs { + /// The offending byte. + f: FnIndex, + }, + } + + impl core::fmt::Display for ConformanceError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ConformanceError::SharedCoreDrift { f, what } => write!( + fmt, + "vocabulary drifts from the shared core on {what} for {f:?} — \ + shared-core bytes are answered by the core, never the vocabulary" + ), + ConformanceError::ShapeTooNarrowForRefs { f } => write!( + fmt, + "{f:?}: min_shape cannot hold the call's own body references" + ), + } + } + } + + impl core::error::Error for ConformanceError {} + + /// Check a vocabulary against the sharing discipline, over the full + /// 256-byte codebook. + /// + /// # Errors + /// + /// The first [`ConformanceError`] found, naming the byte and the defect. + pub fn check(v: &V) -> Result<(), ConformanceError> { + for b in 0..=255u8 { + let f = FnIndex(b); + if b < DOMAIN_FLOOR { + // Below the floor the vocabulary must be transparent: its + // composed answers ARE the core's answers. This includes NOP + // (0x00), which the core refuses. + if v.stack_arity(f) != shared_core::stack_arity(f) { + return Err(ConformanceError::SharedCoreDrift { + f, + what: "stack_arity", + }); + } + if v.body_refs(f) != shared_core::body_refs(f) { + return Err(ConformanceError::SharedCoreDrift { + f, + what: "body_refs", + }); + } + } + // Everywhere: the reported minimum shape must actually hold the + // call's own body references. + if v.min_shape(f).values_per_call() < usize::from(v.body_refs(f)) { + return Err(ConformanceError::ShapeTooNarrowForRefs { f }); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::conformance::{ConformanceError, check}; + use super::*; + + /// A vocabulary with an empty domain range — the smallest conforming + /// implementation (and exactly what a palette that is all shared-core + /// looks like). + struct EmptyVocab; + impl Vocabulary for EmptyVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + } + + /// A well-behaved domain vocabulary: two functions above the floor, one + /// of which branches. + struct DomainVocab; + impl Vocabulary for DomainVocab { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + match f.0 { + 0x90 => Some(1), + 0x91 => Some(0), + _ => None, + } + } + fn domain_body_refs(&self, f: FnIndex) -> u8 { + u8::from(f.0 == 0x91) + } + } + + /// The drift the conformance check exists to catch: an override of the + /// COMPOSED method that changes a shared-core answer. + struct DriftingVocab; + impl Vocabulary for DriftingVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn stack_arity(&self, f: FnIndex) -> Option { + // ADD as a unary operator — the classic silent stack + // desynchronization. + if f == FnIndex::ADD { + Some(1) + } else if f.0 < DOMAIN_FLOOR { + shared_core::stack_arity(f) + } else { + self.domain_stack_arity(f) + } + } + } + + /// A min_shape override that would truncate IF_ELSE's else arm. + struct NarrowShapeVocab; + impl Vocabulary for NarrowShapeVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn min_shape(&self, _f: FnIndex) -> LaneShape { + LaneShape::Pairs + } + } + + #[test] + fn a_body_reference_is_not_a_stack_operand() { + // THE distinction. `repeat` pops the COUNT (one operand) and + // references a body. If the body were counted as an operand, nesting + // would pop something that was never pushed and every earlier operand + // would shift by one. + assert_eq!(shared_core::stack_arity(FnIndex::REPEAT), Some(1)); + assert_eq!(shared_core::body_refs(FnIndex::REPEAT), 1); + // `forever` proves the two are genuinely independent: zero operands, + // one body. A single conflated number cannot express it. + assert_eq!(shared_core::stack_arity(FnIndex::FOREVER), Some(0)); + assert_eq!(shared_core::body_refs(FnIndex::FOREVER), 1); + // …and the mirror: an expression pops operands and references nothing. + assert_eq!(shared_core::stack_arity(FnIndex::ADD), Some(2)); + assert_eq!(shared_core::body_refs(FnIndex::ADD), 0); + assert_eq!(shared_core::body_refs(FnIndex::SQRT), 0); + } + + #[test] + fn if_else_carries_two_bodies_and_therefore_needs_a_wider_shape() { + assert_eq!(shared_core::body_refs(FnIndex::IF_ELSE), 2); + assert_eq!(shared_core::stack_arity(FnIndex::IF_ELSE), Some(1)); + // Under Pairs the else arm would be truncated away and the program + // would run the then-branch and silently skip the else — the exact + // "looks complete and is not" failure the ABI refuses elsewhere. + assert_eq!(shared_core::min_shape(FnIndex::IF_ELSE), LaneShape::Triples); + // Two-sided: one-body forms fit Pairs, so the requirement is specific + // to IF_ELSE rather than a blanket widening of all control flow. + assert_eq!(shared_core::min_shape(FnIndex::IF), LaneShape::Pairs); + assert_eq!(shared_core::min_shape(FnIndex::REPEAT), LaneShape::Pairs); + assert_eq!(shared_core::min_shape(FnIndex::ADD), LaneShape::Pairs); + } + + #[test] + fn the_two_tables_agree_on_what_is_covered() { + // A function with a stack arity but no body-ref entry (or vice versa) + // would lower half-correctly. Every control-flow opcode the shared + // core names must appear consistently in both. + for f in [ + FnIndex::IF, + FnIndex::IF_ELSE, + FnIndex::REPEAT, + FnIndex::WHILE, + FnIndex::REPEAT_UNTIL, + FnIndex::FOREVER, + FnIndex::FOR_EACH, + FnIndex::FOR_RANGE, + ] { + assert!( + shared_core::stack_arity(f).is_some(), + "{f:?} has no stack arity" + ); + assert!(shared_core::branches(f), "{f:?} should reference a body"); + } + // Silence twin: uncovered control flow stays uncovered rather than + // being quietly assigned a plausible shape. WAIT/STOP/RETURN are real + // palette entries the core does not yet model. + for f in [ + FnIndex::WAIT, + FnIndex::WAIT_UNTIL, + FnIndex::STOP, + FnIndex::RETURN, + ] { + assert_eq!(shared_core::stack_arity(f), None, "{f:?} must stay refused"); + } + } + + #[test] + fn the_composed_methods_route_by_the_floor() { + let v = DomainVocab; + // Below the floor: the core answers, and the domain hook is never the + // source (its answer for ADD would be None — the composed method must + // NOT return that). + assert_eq!(v.stack_arity(FnIndex::ADD), Some(2)); + assert_eq!(v.domain_stack_arity(FnIndex::ADD), None); + // Above the floor: the domain answers. + assert_eq!(v.stack_arity(FnIndex(0x90)), Some(1)); + assert_eq!(v.body_refs(FnIndex(0x91)), 1); + assert!(v.branches(FnIndex(0x91))); + // …and an unclaimed domain byte is refused, not guessed. + assert_eq!(v.stack_arity(FnIndex(0xF0)), None); + } + + #[test] + fn conformance_stays_silent_for_conforming_vocabularies() { + // The silence half — over NON-trivial inputs: DomainVocab genuinely + // claims bytes above the floor and still passes. + assert_eq!(check(&EmptyVocab), Ok(())); + assert_eq!(check(&DomainVocab), Ok(())); + } + + #[test] + fn conformance_fires_on_a_shared_core_drift() { + // The can-fire half: a vocabulary that overrides the composed method + // and re-answers ADD is caught, and the error names the byte. + assert_eq!( + check(&DriftingVocab), + Err(ConformanceError::SharedCoreDrift { + f: FnIndex::ADD, + what: "stack_arity", + }) + ); + } + + #[test] + fn conformance_fires_on_a_shape_too_narrow_for_the_refs() { + // IF_ELSE under a Pairs-only min_shape would truncate its else arm. + assert_eq!( + check(&NarrowShapeVocab), + Err(ConformanceError::ShapeTooNarrowForRefs { + f: FnIndex::IF_ELSE + }) + ); + } +} diff --git a/docs/BLOCK-EDITOR-PLAN.md b/docs/BLOCK-EDITOR-PLAN.md index ec9a451..ad4fefe 100644 --- a/docs/BLOCK-EDITOR-PLAN.md +++ b/docs/BLOCK-EDITOR-PLAN.md @@ -491,3 +491,67 @@ opt-in. what it retires (see `D-BLOCKS-PALETTE` corrections 1 and 2). - **The charter traps hold** (a2ui-rs T1/T2/T3): no second vocabulary, behavior by address only, no serialization in the hot path. + +## W6 — the reusable surface hoist: `ogar-loco` (2026-08-05) + +**Operator direction** (verbatim intent): *"Elixir should become just a +rails-shaped semantic over classid index, 256:256. Not much different than +blockly, just different vocabulary. It should be a reusable surface for any +other purposes."* Named next consumers: lance-graph's elixir-shaped compiled +templates, then Power-Automate-style flows; end state, connecting +rs-graph-llm (with Rig) + OGAR + lance-graph so execution is replayable. + +**What shipped.** The vocabulary-agnostic half of `ogar-blockly` moved one +level down into a new zero-dep crate **`crates/ogar-loco`**: + +- The call ABI: `FnIndex` / `Call` / `LaneShape` / `FunctionBody` / + `call_in_slab`, the layout constants, and the budgets — moved verbatim + (bytes, semantics, and tests unchanged). +- `DEVICE_FAMILY_FLOOR` is generalized to **`DOMAIN_FLOOR`**: below = + shared computational core (byte-stable across every vocabulary), at/above + = the classid-selected vocabulary's own range. `ogar-blockly` re-exports + the constant under its historical name. +- **`vocabulary::shared_core`** — the shared core's `stack_arity` / + `body_refs` / `branches` / `min_shape` tables, defined ONCE (transcribed + from the proven `blockly-rs` tables: the two-quantity split and the + expression arities). Uncovered shared-core bytes (WAIT, STOP, RETURN, …) + refuse everywhere; coverage grows here, for everyone. +- **`trait Vocabulary`** — the seam a sibling codebook implements + (`domain_stack_arity` / `domain_body_refs`); composed methods route by the + floor. **`vocabulary::conformance::check`** is the mechanical no-drift + gate every vocabulary crate must run (verified able to fire: a drifted + ADD arity and a truncating `min_shape` are both caught by name). +- **`node`** (512-B `FunctionNode` round-trip, reserved slot pinned zero), + **`pool`** (constant pool, classids still caller-supplied — M3 unchanged), + **`program`** (`Program`, `references_are_resolvable` and `branches_of` + now vocabulary-parameterized) — hoisted from `blockly-abi`, tests ported. +- `ogar-blockly` is now the **Blockly/Scratch vocabulary crate over the + core**: re-exports the old surface unchanged (census test doubles as the + re-export completeness proof; `blockly-rs` compiles with zero changes), + keeps `BlockConcept`/`SoaSplit`/`BLOCKS_DOMAIN`, and adds + **`BlocklyVocabulary`** (domain hooks empty-and-refusing until device + families mint — honest: the device range is reserved, not allocated). + +Gates: fmt / clippy `-D warnings` / tests (36 loco + 7 blockly) / rustdoc +`-D warnings` / density example — all green. + +**What the other side wires (deliberately NOT taken here):** + +1. The **template vocabulary crate** (sibling of `ogar-blockly`) — gated on + the lance-graph rung-2 144-verb unification (its O7 finding: the two + shipped 144-vocabularies diverge; an ordinal-pinned codebook forces the + ruling) and on its concept-domain mint. +2. The **flow vocabulary crate** (Power Automate) — plus the structured + PARALLEL/JOIN + try/catch-shaped control mints its `runAfter` semantics + need. Sequential-subset-first with loud refusal is the recorded stance. +3. The **`blockly-rs` flip**: `blockly-abi` still carries its own local + `node`/`flow`/`program`/`pool` copies (typed on the palette). The float + is transient BY OBLIGATION — the flip PR deletes them in favour of the + hoisted core + `BlocklyVocabulary`, or the no-second-vocabulary trap + stands violated. Until the flip, `ogar-loco`'s copies are the canonical + ones (they are the ones a second vocabulary may consume). +4. How **lance-graph consumes** the core: direct dep on this crate vs the + precedented mirror-with-drift-test — an operator-level dependency- + direction call. +5. All mints stay operator-gated: M1–M3 above, plus the template/flow + concept domains.