Skip to content

Commit 2f9c3ca

Browse files
authored
Merge pull request #492 from AdaWorldAPI/claude/soa-envelope-for-node-row
feat(contract): SoaEnvelope binding for canonical NodeRow — NodeRowPacket wrapper
2 parents 2337e75 + 4ec2197 commit 2f9c3ca

2 files changed

Lines changed: 290 additions & 0 deletions

File tree

.claude/board/AGENT_LOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2026-06-13 — SoaEnvelope binding for canonical NodeRow (the canon-as-substrate keystone)
2+
3+
**bardioc cross-session.** Closes punchlist item §7.2 of the 2026-06-13 SoA migration diff resolution doc — the canonical row layout is now bound to the envelope ABI. New `NodeRowPacket<'a>` wrapper in `canonical_node.rs` zero-copy-views a `&[NodeRow]` (each row `#[repr(C, align(64))]` at 512 bytes) as a row-strided LE byte packet through `SoaEnvelope`. Three-column descriptor table (`NODE_ROW_COLUMNS`): key (16 × u8 at offset 0), edges (16 × u8 at offset 16), value (480 × u8 at offset 32) — sums to `NODE_ROW_STRIDE = 512`. Internal structure within each slot stays canon-described (`NodeGuid` for the key, `EdgeBlock` for the edges, registry `ClassView` for the value carve-out) — the envelope contract is at the row-stride level, not the field-decomposition level. `NodeRowColumn` enum exports the column ordinals as `pub enum { Key=0, Edges=1, Value=2 }` for type-safe `column_le` access. `as_le_bytes()` is unsafe-free at the API but uses `core::slice::from_raw_parts` internally with a documented SAFETY note (NodeRow `#[repr(C)]` + locked size + canon-LE field accessors). +9 tests covering column-table layout, empty-packet verification, single-row zero-copy (pointer equality), multi-row byte length, `row_le`/`column_le` LE byte ranges, canon-LE key end-to-end, and `LAYOUT_VERSION` parity. `cargo test -p lance-graph-contract --lib`: **603/603 green** (+9); `cargo clippy -p lance-graph-contract --all-targets -- -D warnings`: clean. **No public-API drift in existing code** — `NodeRowPacket`, `NodeRowColumn`, `NODE_ROW_COLUMNS`, `NODE_ROW_STRIDE` are pure additions. This is the keystone the BindSpace dissolution sequence S1-S4 has been blocked behind: Lance's columnar I/O can now read the canonical row packet directly. Next step: MailboxSoA migrating from its column-major `[T; N]` layout to a row-strided `[NodeRow; N]` backing store that impls `SoaEnvelope` through this wrapper.
4+
15
## 2026-06-13 — SoA migration diff resolution doc (catch-up audit + post-#490 supersession map)
26

37
**bardioc cross-session.** Operator directive: *"the biggest goal would be to catch up on all SoA bindspace migration plans and resolve the diff."* Surveyed the SoA / BindSpace / identity plan family (9 plans + 4 board files + 5 code files + canon doc-locks) and produced a single resolution doc at `.claude/plans/soa-migration-diff-resolution-2026-06-13.md` that names every plan-vs-shipped diff post-#487/#489/#490. **Headline drifts named:** (1) `identity-architecture-exists-vs-needs-v1.md §N1`'s UUIDv8 layout fully superseded by OGAR/CLAUDE.md P0 canon — the namespace/entity_type/kind/niblepath_prefix/shape_hash/RFC ceremony framing did NOT ship; canon's classid·HEEL·HIP·TWIG·family·identity (no ceremony) won (PR #489/#490). (2) `bindspace-singleton-to-mailbox-soa-v1.md`'s `CollapseGateEmission` / `MailboxSoA::emit()` / Baton-as-type was retired in PR #487 tombstone; `last_emission_cycle → last_active_cycle` rename per #477 supersession. (3) `unified-soa-convergence-v1.md §4.2` stack pins drifted (`lance =7.0.0` / `lancedb =0.30.0`); 2026-05-29 addendum partially addressed. (4) `polyglot-container-query-membrane-v1.md` ratified research-only — self-describing-key convergence dissolved the membrane question. **D-MBX-A2 status:** still queued, still the gating gap; MailboxSoA<N> has no Hamming columns. **S2-S4 status:** unshipped; `driver.rs:56` still has `pub(crate) bindspace: Arc<BindSpace>`, both `bin/serve.rs:29` + `bin/grpc.rs:29` still call `BindSpace::zeros(4096)`. **SoaEnvelope status:** trait shipped (#477), zero real implementors — only `TestEnvelope` in tests; MailboxSoA does NOT impl it. **Staunen/Wisdom-as-entropy×energy-substrate-state correction with §6.1 alternative framings (Bugwelle / aerodynamics / event horizon / Friston FEP)** — added per operator relay 2026-06-13: (a) Staunen as the entropy *Bugwelle* (bow wave) of thinking-in-progress — Staunen is not static, it's the leading-edge entropy disturbance GENERATED by cognitive motion; (b) Aerodynamic / shock-wave analogy — as cognitive velocity through the substrate increases, the Bugwelle steepens, "sonic boom" = the *aha* breakthrough where entropy collapses abruptly; (c) Event-horizon / inertia — Wisdom's (low entropy × high energy) corner has gravitational properties, novelty needs escape velocity to break out of the well, the canon's "reserve don't reclaim" at classid==0/family==0 keeps the bootstrap basin always-escapable; (d) Friston Free Energy Principle as the scientific anchor — high FE = Staunen, FE-minimisation in progress = Confusion/Chaos quadrant, minimised FE = Wisdom; `consume_firing(row)` IS active inference (energy ≥ threshold ⇒ fire, in-place mark, integrate prediction error). The four framings stack: Bugwelle = shape; aerodynamics = velocity scaling; event-horizon = inertia/why Wisdom resists; FEP = drive function. Same underlying substrate dynamics, four vocabularies for reach. (handover §8 + operator image relays + operator framing relay 2026-06-13). Canonical DIKW = Data → Information → Knowledge → Wisdom, bridged by Processing → Cognition → Judgment; Wisdom IS the canonical DIKW apex rung. The operator's precise framing: **Staunen = high entropy × low energy** = "needs entropy work" marker = cognitive pressure + emerging insight (not yet crystallised). **Wisdom = low entropy × high energy** = crystalline knowledge with supporting plasticity + integrated insights, the substrate has invested heavily and locked it in. Diagonal opposites on the entropy×energy plane, NOT two ends of one axis. The other two quadrants: **Confusion / Chaos** (high entropy × high energy = in-progress climb state, substrate has invested energy but entropy hasn't yet collapsed) and **Boredom / Inert** (low entropy × low energy = ordered but not energised). Substrate column map: Energy = `MailboxSoA.energy: [f32; N]` (signed spatio-temporal accumulator); Plasticity = `MailboxSoA.plasticity_counter: [u8; N]` (saturating Hebbian counter = long-term investment); Entropy proxy = classid-prefix-resolved codebook hit-rate × local edge-neighbourhood density. Two-algebra rule maps onto the plane: entropy axis = signed side (`vsa_bind`), energy axis = magnitude side (`vsa_bundle`). The canon's 3×4 uniform cascade (HEEL · HIP · TWIG = three u16 tiers) shape-matches DIKW's three transitions + four layers — not coincidentally. NOT YET corrected in lance-graph CLAUDE.md (line ~120 still says "Magnitude = Contradiction depth from Staunen × Wisdom qualia") — flagged as `TD-CLAUDE-MD-STAUNEN-MISNAME` for a separate maintenance pass with three specific edits identified (line ~120 rewrite citing entropy×energy markers, §11.5 rephrasing, new DIKW-anchor sub-section under "The Click" mapping cascade tiers onto DIKW transitions + the entropy×energy quadrant diagram). **LE-contract violations still on the books:** `engine_bridge.rs` f32→i4 qualia re-encode, `Vsa16kF32` persisted as cross-boundary in singleton, DTO-as-owned-Vec sites — all dissolve at S2/S4. Errata stubs prepended to 4 affected plans (bindspace-singleton-to-mailbox-soa, identity-architecture-exists-vs-needs, unified-soa-convergence, polyglot-container-query-membrane) pointing at the resolution doc. Resolved punchlist §7 lists 9 follow-up PRs in priority order. Docs-only PR; no code touched.

crates/lance-graph-contract/src/canonical_node.rs

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,129 @@ const _: () = assert!(core::mem::size_of::<NodeGuid>() == 16);
189189
const _: () = assert!(core::mem::size_of::<EdgeBlock>() == 16);
190190
const _: () = assert!(core::mem::size_of::<NodeRow>() == 512);
191191

192+
// ── SoaEnvelope binding for [NodeRow] ────────────────────────────────────────
193+
194+
use crate::soa_envelope::{ColumnDescriptor, ColumnKind, SoaEnvelope};
195+
196+
/// Stable column-id ordinals for [`NodeRow`]'s three top-level slots.
197+
/// `name_id` in the [`ColumnDescriptor`] table; the registry-resolved value
198+
/// carve-out (per `classid → ClassView`) lives *inside* `Value` and is not
199+
/// surfaced as its own envelope column — the canon contract is at this level.
200+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201+
#[repr(u16)]
202+
pub enum NodeRowColumn {
203+
Key = 0,
204+
Edges = 1,
205+
Value = 2,
206+
}
207+
208+
/// Canonical [`ColumnDescriptor`] table for [`NodeRow`].
209+
///
210+
/// Three columns, all `ColumnKind::U8` byte-arrays (their internal structure
211+
/// is canon-described elsewhere — `NodeGuid` decomposes the key, `EdgeBlock`
212+
/// the edges, registry `ClassView` carves the value side). The envelope
213+
/// contract is at the row-stride level: bytes 0..16 are the key, 16..32 are
214+
/// the edges, 32..512 are the class-resolved value slab. Sum = 512 = stride.
215+
pub const NODE_ROW_COLUMNS: &[ColumnDescriptor] = &[
216+
ColumnDescriptor {
217+
name_id: NodeRowColumn::Key as u16,
218+
kind: ColumnKind::U8,
219+
elems_per_row: 16,
220+
row_offset: 0,
221+
},
222+
ColumnDescriptor {
223+
name_id: NodeRowColumn::Edges as u16,
224+
kind: ColumnKind::U8,
225+
elems_per_row: 16,
226+
row_offset: 16,
227+
},
228+
ColumnDescriptor {
229+
name_id: NodeRowColumn::Value as u16,
230+
kind: ColumnKind::U8,
231+
elems_per_row: 480,
232+
row_offset: 32,
233+
},
234+
];
235+
236+
/// Row stride for [`NodeRow`] in bytes — equal to `size_of::<NodeRow>()`.
237+
pub const NODE_ROW_STRIDE: usize = 512;
238+
239+
/// Zero-copy [`SoaEnvelope`] wrapper over a contiguous slice of [`NodeRow`].
240+
///
241+
/// `NodeRow` is `#[repr(C, align(64))]` with the locked 16/16/480 byte
242+
/// layout, so a `&[NodeRow]` IS already a row-strided LE packet at stride
243+
/// 512 — no allocation, no copy. This wrapper just attaches the cycle stamp
244+
/// and exposes the slice through the [`SoaEnvelope`] trait so Lance's
245+
/// columnar I/O reads it directly.
246+
///
247+
/// The envelope's column table ([`NODE_ROW_COLUMNS`]) names the three
248+
/// top-level slots (key / edges / value). Internal structure within each
249+
/// slot is the canon's concern (`NodeGuid` for the key, `EdgeBlock` for the
250+
/// edges, registry `ClassView` for the value carve-out).
251+
#[derive(Clone, Copy)]
252+
pub struct NodeRowPacket<'a> {
253+
rows: &'a [NodeRow],
254+
cycle: u32,
255+
}
256+
257+
impl<'a> core::fmt::Debug for NodeRowPacket<'a> {
258+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
259+
f.debug_struct("NodeRowPacket")
260+
.field("n_rows", &self.rows.len())
261+
.field("cycle", &self.cycle)
262+
.field("row_stride", &NODE_ROW_STRIDE)
263+
.finish()
264+
}
265+
}
266+
267+
impl<'a> NodeRowPacket<'a> {
268+
/// Wrap a contiguous slice of [`NodeRow`] with a cycle stamp.
269+
#[inline]
270+
pub const fn new(rows: &'a [NodeRow], cycle: u32) -> Self {
271+
Self { rows, cycle }
272+
}
273+
274+
/// The underlying rows.
275+
#[inline]
276+
pub const fn rows(&self) -> &'a [NodeRow] {
277+
self.rows
278+
}
279+
}
280+
281+
impl<'a> SoaEnvelope for NodeRowPacket<'a> {
282+
fn columns(&self) -> &[ColumnDescriptor] {
283+
NODE_ROW_COLUMNS
284+
}
285+
fn row_stride(&self) -> usize {
286+
NODE_ROW_STRIDE
287+
}
288+
fn n_rows(&self) -> usize {
289+
self.rows.len()
290+
}
291+
fn cycle(&self) -> u32 {
292+
self.cycle
293+
}
294+
fn as_le_bytes(&self) -> &[u8] {
295+
// SAFETY: NodeRow is #[repr(C, align(64))] with size_of::<NodeRow>() ==
296+
// 512 (checked by the const _: () asserts above). A &[NodeRow] is a
297+
// contiguous array of #[repr(C)] structs; viewing it as &[u8] of
298+
// length len * 512 is a standard column-store packing operation, and
299+
// every byte position is valid for reads (no padding past size_of,
300+
// alignment of NodeRow (64) ⊇ alignment of u8 (1)).
301+
//
302+
// The NodeGuid and EdgeBlock fields hold their bytes in canon-LE
303+
// order (NodeGuid::new uses to_le_bytes; EdgeBlock is plain [u8;_]),
304+
// so the resulting byte slice IS the envelope's LE packet — no
305+
// translation needed at the boundary.
306+
unsafe {
307+
core::slice::from_raw_parts(
308+
self.rows.as_ptr().cast::<u8>(),
309+
self.rows.len() * NODE_ROW_STRIDE,
310+
)
311+
}
312+
}
313+
}
314+
192315
#[cfg(test)]
193316
mod tests {
194317
use super::*;
@@ -278,4 +401,167 @@ mod tests {
278401
let g = NodeGuid::local(0x00_00CD);
279402
assert_eq!(g.to_string(), "00000000-0000-0000-0000-0000000000cd");
280403
}
404+
405+
// ── SoaEnvelope binding for NodeRowPacket ────────────────────────────────
406+
407+
fn sample_row(classid: u32, identity: u32) -> NodeRow {
408+
NodeRow {
409+
key: NodeGuid::new(classid, 0x1111, 0x2222, 0x3333, 0x00_00AB, identity),
410+
edges: EdgeBlock::default(),
411+
value: [0u8; 480],
412+
}
413+
}
414+
415+
#[test]
416+
fn node_row_column_table_sums_to_row_stride() {
417+
let total: usize = NODE_ROW_COLUMNS
418+
.iter()
419+
.map(|c| c.col_bytes_per_row())
420+
.sum();
421+
assert_eq!(total, NODE_ROW_STRIDE);
422+
assert_eq!(NODE_ROW_STRIDE, core::mem::size_of::<NodeRow>());
423+
}
424+
425+
#[test]
426+
fn node_row_column_table_is_in_offset_order_without_gaps() {
427+
// The contract: columns are contiguous (key 0..16, edges 16..32,
428+
// value 32..512) — no gaps, no overlap, in offset order.
429+
let mut prev_end = 0usize;
430+
for c in NODE_ROW_COLUMNS {
431+
assert_eq!(c.row_offset as usize, prev_end, "no gap before {c:?}");
432+
prev_end = c.row_offset as usize + c.col_bytes_per_row();
433+
}
434+
assert_eq!(prev_end, NODE_ROW_STRIDE);
435+
}
436+
437+
#[test]
438+
fn empty_packet_verifies() {
439+
let rows: &[NodeRow] = &[];
440+
let pkt = NodeRowPacket::new(rows, 0);
441+
assert_eq!(pkt.n_rows(), 0);
442+
assert_eq!(pkt.as_le_bytes().len(), 0);
443+
assert!(pkt.verify_layout().is_ok(), "empty packet must verify");
444+
}
445+
446+
#[test]
447+
fn single_row_packet_verifies_and_byte_view_is_zero_copy() {
448+
let rows = [sample_row(0xDEAD_BEEF, 0x00_00CD)];
449+
let pkt = NodeRowPacket::new(&rows, 7);
450+
assert_eq!(pkt.n_rows(), 1);
451+
assert_eq!(pkt.cycle(), 7);
452+
assert_eq!(pkt.row_stride(), 512);
453+
assert_eq!(pkt.as_le_bytes().len(), 512);
454+
// Zero-copy: the byte view's pointer is the slice's pointer.
455+
assert_eq!(
456+
pkt.as_le_bytes().as_ptr() as usize,
457+
rows.as_ptr() as usize,
458+
"as_le_bytes must be zero-copy"
459+
);
460+
assert!(pkt.verify_layout().is_ok());
461+
}
462+
463+
#[test]
464+
fn multi_row_packet_byte_length_is_stride_times_rows() {
465+
let rows = [
466+
sample_row(0xDEAD_BEEF, 0x00_00CD),
467+
sample_row(0xCAFE_BABE, 0x00_0001),
468+
sample_row(0x0000_0000, 0x00_0042),
469+
];
470+
let pkt = NodeRowPacket::new(&rows, 42);
471+
assert_eq!(pkt.n_rows(), 3);
472+
assert_eq!(pkt.as_le_bytes().len(), 3 * 512);
473+
assert!(pkt.verify_layout().is_ok());
474+
}
475+
476+
#[test]
477+
fn row_le_view_returns_one_full_row() {
478+
let rows = [sample_row(1, 2), sample_row(3, 4), sample_row(5, 6)];
479+
let pkt = NodeRowPacket::new(&rows, 0);
480+
for (i, row) in rows.iter().enumerate() {
481+
let row_bytes = pkt.row_le(i).expect("row in range");
482+
assert_eq!(row_bytes.len(), 512);
483+
// First 4 bytes are the classid in canon-LE order.
484+
assert_eq!(
485+
u32::from_le_bytes(row_bytes[..4].try_into().unwrap()),
486+
row.key.classid()
487+
);
488+
}
489+
assert!(pkt.row_le(3).is_none(), "out of range");
490+
}
491+
492+
#[test]
493+
fn column_le_view_returns_the_named_slot() {
494+
// Place a recognisable byte pattern in the value side; verify the
495+
// value column-view picks it up at the right offset.
496+
let mut row = sample_row(0xDEAD_BEEF, 0x00_00CD);
497+
row.value[0] = 0xAB;
498+
row.value[479] = 0xCD;
499+
let rows = [row];
500+
let pkt = NodeRowPacket::new(&rows, 0);
501+
let value_col = pkt
502+
.column_le(0, &NODE_ROW_COLUMNS[NodeRowColumn::Value as usize])
503+
.expect("value column in range");
504+
assert_eq!(value_col.len(), 480);
505+
assert_eq!(value_col[0], 0xAB);
506+
assert_eq!(value_col[479], 0xCD);
507+
// Key column is at offset 0, length 16 — first byte = LE byte 0 of
508+
// classid = 0xEF (low byte of 0xDEAD_BEEF).
509+
let key_col = pkt
510+
.column_le(0, &NODE_ROW_COLUMNS[NodeRowColumn::Key as usize])
511+
.expect("key column in range");
512+
assert_eq!(key_col.len(), 16);
513+
assert_eq!(key_col[0], 0xEF);
514+
assert_eq!(key_col[3], 0xDE);
515+
}
516+
517+
#[test]
518+
fn key_bytes_in_canon_le_order() {
519+
// Round-trip: pack a NodeRow with known fields, read the bytes back
520+
// through the envelope, parse each canon group by its LE byte range,
521+
// confirm values match. Proves the SoA envelope view stays canon-LE
522+
// end-to-end without any field-accessor intermediation.
523+
let row = sample_row(0xDEAD_BEEF, 0x00_00CD);
524+
let rows = [row];
525+
let pkt = NodeRowPacket::new(&rows, 0);
526+
let bytes = pkt.as_le_bytes();
527+
// Per OGAR/CLAUDE.md P0: classid · HEEL · HIP · TWIG · family · identity.
528+
assert_eq!(
529+
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
530+
0xDEAD_BEEF,
531+
"classid at [0..4]"
532+
);
533+
assert_eq!(
534+
u16::from_le_bytes([bytes[4], bytes[5]]),
535+
0x1111,
536+
"HEEL at [4..6]"
537+
);
538+
assert_eq!(
539+
u16::from_le_bytes([bytes[6], bytes[7]]),
540+
0x2222,
541+
"HIP at [6..8]"
542+
);
543+
assert_eq!(
544+
u16::from_le_bytes([bytes[8], bytes[9]]),
545+
0x3333,
546+
"TWIG at [8..10]"
547+
);
548+
// family is u24 LE in bytes [10..13]: 0xAB, 0x00, 0x00.
549+
assert_eq!(&bytes[10..13], &[0xAB, 0x00, 0x00], "family at [10..13]");
550+
// identity is u24 LE in bytes [13..16]: 0xCD, 0x00, 0x00.
551+
assert_eq!(&bytes[13..16], &[0xCD, 0x00, 0x00], "identity at [13..16]");
552+
}
553+
554+
#[test]
555+
fn envelope_layout_version_matches_envelope_default() {
556+
// The wrapper does not override LAYOUT_VERSION, so verify_layout
557+
// checks against the envelope-crate default (ENVELOPE_LAYOUT_VERSION).
558+
let rows = [sample_row(0, 1)];
559+
let pkt = NodeRowPacket::new(&rows, 0);
560+
assert_eq!(
561+
<NodeRowPacket<'_> as SoaEnvelope>::LAYOUT_VERSION,
562+
crate::soa_envelope::ENVELOPE_LAYOUT_VERSION
563+
);
564+
// verify_layout exercises that gate.
565+
assert!(pkt.verify_layout().is_ok());
566+
}
281567
}

0 commit comments

Comments
 (0)