Skip to content

Commit 04f2d42

Browse files
authored
Merge pull request #239 from AdaWorldAPI/claude/rust-scratch-abi-soa-gsamge
ogar-loco: hoist the vocabulary-agnostic call ABI out of ogar-blockly
2 parents f35cd6b + 89d0d3a commit 04f2d42

10 files changed

Lines changed: 2899 additions & 1147 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ members = [
3131
"crates/ogar-from-docv1",
3232
"crates/ogar-render-typst",
3333
"crates/ogar-blockly",
34+
"crates/ogar-loco",
3435
]
3536

3637
[workspace.package]

‎crates/ogar-blockly/Cargo.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ license.workspace = true
66
repository.workspace = true
77
authors.workspace = true
88
rust-version.workspace = true
9-
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."
9+
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."
1010

1111
[features]
1212
default = []
13-
serde = ["dep:serde", "ogar-vocab/serde"]
13+
serde = ["dep:serde", "ogar-vocab/serde", "ogar-loco/serde"]
1414

1515
[dependencies]
16+
ogar-loco = { path = "../ogar-loco" }
1617
ogar-vocab = { path = "../ogar-vocab" }
1718
serde = { workspace = true, optional = true }

‎crates/ogar-blockly/src/lib.rs‎

Lines changed: 111 additions & 1145 deletions
Large diffs are not rendered by default.

‎crates/ogar-loco/Cargo.toml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "ogar-loco"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
authors.workspace = true
8+
rust-version.workspace = true
9+
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."
10+
11+
[features]
12+
default = []
13+
serde = ["dep:serde"]
14+
15+
[dependencies]
16+
serde = { workspace = true, optional = true }

‎crates/ogar-loco/src/lib.rs‎

Lines changed: 1208 additions & 0 deletions
Large diffs are not rendered by default.

‎crates/ogar-loco/src/node.rs‎

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
//! The **stored node** — where a function actually lives as bytes.
2+
//!
3+
//! # The claim this closes
4+
//!
5+
//! The surface rests on one sentence: *a program is V3 SoA rows, and the
6+
//! blocks/steps a user sees are a projection of those rows.* Every other
7+
//! layer — a block cast, a text projection, an editor address — is a
8+
//! projection **of** something. This module is the something.
9+
//!
10+
//! # The layout, and the slot that is deliberately empty
11+
//!
12+
//! ```text
13+
//! one function = one node = 512 bytes = 32 × 16-byte slots
14+
//! slot 0 key 16 B the canonical GUID
15+
//! slot 1 reserved 16 B ZEROED — the edge-block design is RETIRED
16+
//! slots 2..31 value slab 480 B 30 lanes × 12 B, carved by LaneShape
17+
//! ```
18+
//!
19+
//! Slot 1 is **reserved, not reclaimed**. The edge-block design (12 in-family +
20+
//! 4 out-of-family slots) was retired, and the temptation is to hand its 16
21+
//! bytes to the value slab and get two more calls. That is exactly the
22+
//! field-widening the substrate's canon forbids: capacity comes from the next
23+
//! cascade level, never from renegotiating this one. A zero tier means *not
24+
//! consulted*, never *compacted away* — so a later mint can wake slot 1 with no
25+
//! layout-version change, and [`FunctionNode::reserved_is_zeroed`] asserts it
26+
//! stayed empty.
27+
//!
28+
//! # The key is opaque here, on purpose
29+
//!
30+
//! [`FunctionNode::key`] is a caller-supplied `[u8; 16]`. This crate does
31+
//! **not** mint GUIDs: the canonical layout (classid · path tiers · tail) is
32+
//! the substrate's, each vocabulary's app prefix is an operator decision, and
33+
//! inventing either here would bake a guess into stored data. So the node
34+
//! round-trips a key it does not interpret, and what this module actually
35+
//! proves is the part it owns: **the value slab survives the trip
36+
//! byte-for-byte**.
37+
//!
38+
//! # Interleave, not concatenation
39+
//!
40+
//! The slab is **not** 360 body bytes followed by 120 spare. Each 16-byte lane
41+
//! is `classid(4) + payload(12)`, so a call's bytes sit at
42+
//! `(i / calls_per_lane) * 16 + 4 + (i % calls_per_lane) * bytes_per_call`.
43+
//! Writing the body as one contiguous run would produce a slab that looks
44+
//! plausible, reads back correctly through the same wrong function, and is
45+
//! wrong on the wire. [`FunctionBody::write_into_value_slab`] owns that
46+
//! arithmetic; this module composes it and tests the composition against the
47+
//! layout constants rather than against its own idea of them.
48+
49+
use crate::{FunctionBody, LaneShape, SLOT_STRIDE, VALUE_SLAB_LEN};
50+
51+
/// Bytes in one stored node.
52+
pub const NODE_BYTES: usize = 512;
53+
54+
/// Byte offset of the key slot.
55+
pub const KEY_OFFSET: usize = 0;
56+
/// Bytes the key occupies (slot 0).
57+
pub const KEY_BYTES: usize = SLOT_STRIDE;
58+
/// Byte offset of the reserved slot (slot 1) — zeroed, never reclaimed.
59+
pub const RESERVED_OFFSET: usize = SLOT_STRIDE;
60+
/// Byte offset at which the value slab begins (slot 2).
61+
pub const VALUE_OFFSET: usize = 2 * SLOT_STRIDE;
62+
63+
// The layout is derived, not asserted twice: if a stride or a slot count ever
64+
// changes, this fails to compile rather than silently storing a different
65+
// shape.
66+
const _: () = assert!(VALUE_OFFSET + VALUE_SLAB_LEN == NODE_BYTES);
67+
const _: () = assert!(KEY_BYTES == SLOT_STRIDE);
68+
69+
/// One function, as it is stored.
70+
#[derive(Debug, Clone, PartialEq, Eq)]
71+
pub struct FunctionNode {
72+
/// The canonical GUID. **Opaque here** — this crate neither mints nor
73+
/// interprets it; see the module docs.
74+
pub key: [u8; 16],
75+
/// The calls.
76+
pub body: FunctionBody,
77+
}
78+
79+
impl FunctionNode {
80+
/// A node holding `body` under `key`.
81+
#[must_use]
82+
pub fn new(key: [u8; 16], body: FunctionBody) -> Self {
83+
Self { key, body }
84+
}
85+
86+
/// Serialize to the 512-byte block.
87+
///
88+
/// `to_le_bytes` IS the wire format — no serde, no intermediate DTO (the
89+
/// no-serialization-in-the-hot-path rule). Slot 1 is written as zeroes.
90+
#[must_use]
91+
pub fn to_le_bytes(&self) -> [u8; NODE_BYTES] {
92+
let mut out = [0u8; NODE_BYTES];
93+
out[KEY_OFFSET..KEY_OFFSET + KEY_BYTES].copy_from_slice(&self.key);
94+
// slot 1 stays zero — reserve, don't reclaim.
95+
let mut slab = [0u8; VALUE_SLAB_LEN];
96+
self.body.write_into_value_slab(&mut slab);
97+
out[VALUE_OFFSET..].copy_from_slice(&slab);
98+
out
99+
}
100+
101+
/// Read a node back.
102+
///
103+
/// `shape` is not stored in the node: the lane carving is a property of the
104+
/// **class**, resolved through the key's classid, and duplicating it inside
105+
/// the value slab would be a second source of truth that could disagree
106+
/// with the first. The caller supplies what the ClassView says.
107+
#[must_use]
108+
pub fn from_le_bytes(bytes: &[u8; NODE_BYTES], shape: LaneShape) -> Self {
109+
let mut key = [0u8; 16];
110+
key.copy_from_slice(&bytes[KEY_OFFSET..KEY_OFFSET + KEY_BYTES]);
111+
let mut slab = [0u8; VALUE_SLAB_LEN];
112+
slab.copy_from_slice(&bytes[VALUE_OFFSET..]);
113+
Self {
114+
key,
115+
body: FunctionBody::read_from_value_slab(shape, &slab),
116+
}
117+
}
118+
119+
/// Whether the reserved slot is still empty in a serialized node.
120+
///
121+
/// The retired edge block's 16 bytes must stay zeroed so a later mint can
122+
/// wake them with no layout-version change. A caller that finds this false
123+
/// is looking at a node from a layout this code does not describe.
124+
#[must_use]
125+
pub fn reserved_is_zeroed(bytes: &[u8; NODE_BYTES]) -> bool {
126+
bytes[RESERVED_OFFSET..RESERVED_OFFSET + SLOT_STRIDE]
127+
.iter()
128+
.all(|b| *b == 0)
129+
}
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
use crate::{CLASSID_BYTES, Call, FnIndex};
136+
137+
fn key() -> [u8; 16] {
138+
// An arbitrary opaque key — this crate does not mint, so the test must
139+
// not model a minting scheme either.
140+
let mut k = [0u8; 16];
141+
k[0..4].copy_from_slice(&0x1701_FF00_u32.to_le_bytes());
142+
k[10..16].copy_from_slice(&[1, 2, 3, 4, 5, 6]);
143+
k
144+
}
145+
146+
/// `1 + 2 * 3` under the stack discipline.
147+
fn expr_body(shape: LaneShape) -> FunctionBody {
148+
FunctionBody::from_calls(
149+
shape,
150+
&[
151+
Call::with_value(FnIndex::NUMBER, 1),
152+
Call::with_value(FnIndex::NUMBER, 2),
153+
Call::with_value(FnIndex::NUMBER, 3),
154+
Call::new(FnIndex::MUL),
155+
Call::new(FnIndex::ADD),
156+
],
157+
)
158+
.unwrap()
159+
}
160+
161+
#[test]
162+
fn a_stored_node_round_trips_byte_for_byte() {
163+
// THE claim: a program written to a row and read back is the same
164+
// program. Not "reads without error" — the same CALLS.
165+
let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs));
166+
let bytes = node.to_le_bytes();
167+
let back = FunctionNode::from_le_bytes(&bytes, LaneShape::Pairs);
168+
169+
assert_eq!(back.key, node.key);
170+
assert_eq!(back.body.len(), node.body.len());
171+
assert_eq!(back.body.as_body_bytes(), node.body.as_body_bytes());
172+
// …and serializing the recovered node reproduces the same 512 bytes,
173+
// which a lossy read would fail even if the calls happened to match.
174+
assert_eq!(back.to_le_bytes(), bytes);
175+
}
176+
177+
#[test]
178+
fn a_different_program_stores_to_different_bytes() {
179+
// Anti-vacuity for the round-trip: a `to_le_bytes` that returned a
180+
// constant would round-trip perfectly and store nothing.
181+
let one_plus_two = FunctionBody::from_calls(
182+
LaneShape::Pairs,
183+
&[
184+
Call::with_value(FnIndex::NUMBER, 1),
185+
Call::with_value(FnIndex::NUMBER, 2),
186+
Call::new(FnIndex::ADD),
187+
],
188+
)
189+
.unwrap();
190+
let one_plus_three = FunctionBody::from_calls(
191+
LaneShape::Pairs,
192+
&[
193+
Call::with_value(FnIndex::NUMBER, 1),
194+
Call::with_value(FnIndex::NUMBER, 3),
195+
Call::new(FnIndex::ADD),
196+
],
197+
)
198+
.unwrap();
199+
let a = FunctionNode::new(key(), one_plus_two);
200+
let b = FunctionNode::new(key(), one_plus_three);
201+
assert_ne!(a.to_le_bytes(), b.to_le_bytes());
202+
// …and the same program under a different KEY differs too, so the key
203+
// is genuinely stored rather than dropped.
204+
let mut k2 = key();
205+
k2[15] = 99;
206+
let c = FunctionNode::new(k2, one_plus_two);
207+
assert_ne!(a.to_le_bytes(), c.to_le_bytes());
208+
assert_eq!(
209+
FunctionNode::from_le_bytes(&c.to_le_bytes(), LaneShape::Pairs).key,
210+
k2
211+
);
212+
}
213+
214+
#[test]
215+
fn the_reserved_slot_stays_zeroed() {
216+
// Reserve, don't reclaim. The retired edge block's 16 bytes are the
217+
// obvious place to steal two more calls from, and stealing them is the
218+
// field-widening the canon forbids.
219+
let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs));
220+
let bytes = node.to_le_bytes();
221+
assert!(FunctionNode::reserved_is_zeroed(&bytes));
222+
assert!(bytes[RESERVED_OFFSET..VALUE_OFFSET].iter().all(|b| *b == 0));
223+
224+
// Two-sided: the guard must be able to say NO, or "stayed zeroed" is
225+
// a function that returns true.
226+
let mut tampered = bytes;
227+
tampered[RESERVED_OFFSET + 7] = 1;
228+
assert!(!FunctionNode::reserved_is_zeroed(&tampered));
229+
}
230+
231+
#[test]
232+
fn the_body_is_interleaved_across_lanes_not_written_contiguously() {
233+
// The failure this catches is nasty precisely because it is
234+
// self-consistent: a contiguous write reads back fine through the same
235+
// wrong function, and is wrong on the wire. So the assertion is against
236+
// the LAYOUT — a call's bytes must land at its lane offset, past the
237+
// lane's 4 classid bytes.
238+
let node = FunctionNode::new(key(), expr_body(LaneShape::Pairs));
239+
let bytes = node.to_le_bytes();
240+
241+
// First call (NUMBER:1) sits at lane 0, just past the classid.
242+
let first = VALUE_OFFSET + CLASSID_BYTES;
243+
assert_eq!(bytes[first], 0x46, "first call's function byte");
244+
assert_eq!(bytes[first + 1], 1, "first call's value byte");
245+
246+
// Pairs packs 6 calls per 12-byte lane, so call 6 begins the SECOND
247+
// lane — 16 bytes on, not 12. A contiguous writer puts it at 12.
248+
let lane1 = VALUE_OFFSET + SLOT_STRIDE + CLASSID_BYTES;
249+
assert_eq!(lane1 - first, SLOT_STRIDE, "lanes are strided by 16");
250+
// The classid gap really is a gap: those 4 bytes are untouched by the
251+
// body writer.
252+
let gap = VALUE_OFFSET + SLOT_STRIDE;
253+
assert!(
254+
bytes[gap..gap + CLASSID_BYTES].iter().all(|b| *b == 0),
255+
"the body must not write into a lane's classid bytes"
256+
);
257+
}
258+
259+
#[test]
260+
fn every_shape_round_trips_and_the_shape_is_not_stored() {
261+
// The shape is a class property resolved through the key, deliberately
262+
// not duplicated in the slab. So the same bytes read under a different
263+
// shape yield a DIFFERENT program — which is correct, and is why the
264+
// caller must supply what the ClassView says rather than guessing.
265+
for shape in [LaneShape::Pairs, LaneShape::Triples, LaneShape::Quads] {
266+
let node = FunctionNode::new(key(), expr_body(shape));
267+
let bytes = node.to_le_bytes();
268+
let back = FunctionNode::from_le_bytes(&bytes, shape);
269+
let a: Vec<Call> = back.body.calls().collect();
270+
let b: Vec<Call> = node.body.calls().collect();
271+
assert_eq!(a, b, "{shape:?} did not round-trip");
272+
}
273+
// Two-sided: reading Pairs bytes as Quads must NOT silently agree, or
274+
// "the shape matters" is untested.
275+
let pairs = FunctionNode::new(key(), expr_body(LaneShape::Pairs));
276+
let bytes = pairs.to_le_bytes();
277+
let misread = FunctionNode::from_le_bytes(&bytes, LaneShape::Quads);
278+
let a: Vec<Call> = misread.body.calls().collect();
279+
let b: Vec<Call> = pairs.body.calls().collect();
280+
assert_ne!(a, b);
281+
}
282+
283+
#[test]
284+
fn a_full_body_fills_the_slab_without_overrunning_the_node() {
285+
// The boundary: a program at exactly the shape's budget must still fit
286+
// in 512 bytes, and must not disturb the key or the reserved slot.
287+
let cap = LaneShape::Pairs.calls_per_function();
288+
let calls: Vec<Call> = (0..cap)
289+
.map(|i| Call::with_value(FnIndex::NUMBER, (i % 250) as u8 + 1))
290+
.collect();
291+
let body = FunctionBody::from_calls(LaneShape::Pairs, &calls).unwrap();
292+
assert_eq!(body.len(), cap);
293+
294+
let node = FunctionNode::new(key(), body);
295+
let bytes = node.to_le_bytes();
296+
assert_eq!(&bytes[..16], &key());
297+
assert!(FunctionNode::reserved_is_zeroed(&bytes));
298+
let back = FunctionNode::from_le_bytes(&bytes, LaneShape::Pairs);
299+
assert_eq!(back.body.len(), cap);
300+
assert_eq!(back.to_le_bytes(), bytes);
301+
}
302+
}

0 commit comments

Comments
 (0)