|
| 1 | +//! §2.4 — the key-only, neo4j-grade graph render. |
| 2 | +//! |
| 3 | +//! Operator superpower (`unified-soa-rubikon-integration-v1.md` §2.4): read all |
| 4 | +//! boards touching ONLY the **32-byte head** of each `NodeRow` — the 128-bit |
| 5 | +//! [`NodeGuid`] (`key`, bytes 0..16) + the 128-bit [`EdgeBlock`] (`edges`, bytes |
| 6 | +//! 16..32) — and NEVER the 480-byte value slab (bytes 32..512). That is a Neo4j- |
| 7 | +//! like node+edge view at memory-scan speed: `E-GUID-IS-THE-GRAPH` — GUID-key = |
| 8 | +//! node, EdgeBlock-slot = edge, traversal = prefix-route + slot-deref, all |
| 9 | +//! **zero value decode**. |
| 10 | +//! |
| 11 | +//! "Zero value decode" is not a slogan here — it is a falsifiable probe |
| 12 | +//! ([`tests::render_ignores_value_slab`]): poison every row's value slab with |
| 13 | +//! `0xFF`, render, and assert the output is byte-identical to the render over |
| 14 | +//! zeroed slabs. If the render ever touched the value region the two would |
| 15 | +//! differ. The render reads exactly two fields, `row.key` and `row.edges`. |
| 16 | +//! |
| 17 | +//! This is the read side of the same head the SoA owner materialises through the |
| 18 | +//! contract's zero-decode key facets `MailboxSoaView::{hhtl_path_at, |
| 19 | +//! edge_block_at}` (overridden on `SymbiontBoard`, kanban_loop.rs) — the trait |
| 20 | +//! declares them "zero value decode"; this module is the consumer that proves it. |
| 21 | +
|
| 22 | +use lance_graph_contract::canonical_node::NodeRow; |
| 23 | +use lance_graph_contract::hhtl::NiblePath; |
| 24 | +use lance_graph_contract::NodeGuid; |
| 25 | + |
| 26 | +/// Lower a GUID's 3×4 HHT cascade — `HEEL·HIP·TWIG`, 3 tiers × 4 nibbles = 12 |
| 27 | +/// nibbles, root-first — to a [`NiblePath`] (the radix-trie / CLAM cluster |
| 28 | +/// address). `classid` is the routing PREFIX (codebook selector, resolved |
| 29 | +/// separately by longest-prefix); the HHTL *path* proper is the 12 HHT nibbles |
| 30 | +/// (OGAR canon "3×4 PATH — uniform"). Pure key arithmetic, zero value decode. |
| 31 | +#[inline] |
| 32 | +pub fn hhtl_path_of(guid: &NodeGuid) -> NiblePath { |
| 33 | + let tiers = [guid.heel(), guid.hip(), guid.twig()]; |
| 34 | + let mut p = NiblePath::EMPTY; |
| 35 | + let mut first = true; |
| 36 | + for tier in tiers { |
| 37 | + // 4 nibbles per u16 tier, most-significant first (root-first). |
| 38 | + for shift in [12u32, 8, 4, 0] { |
| 39 | + let nib = ((tier >> shift) & 0xF) as u8; |
| 40 | + p = if first { |
| 41 | + first = false; |
| 42 | + NiblePath::root(nib) |
| 43 | + } else { |
| 44 | + p.child(nib) |
| 45 | + }; |
| 46 | + } |
| 47 | + } |
| 48 | + p |
| 49 | +} |
| 50 | + |
| 51 | +/// One rendered node, derived from the 32-byte head ONLY. |
| 52 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 53 | +pub struct RenderedNode { |
| 54 | + /// The node identity = the 128-bit canonical GUID (`key`, bytes 0..16). |
| 55 | + pub guid: NodeGuid, |
| 56 | + /// The HHTL routing address of the GUID (the 3×4 HHT nibble path). |
| 57 | + pub hhtl: NiblePath, |
| 58 | + /// Live in-family edge slots: `(slot_index 0..12, neighbour_byte)` for each |
| 59 | + /// non-zero of the 12 basin-local slots. |
| 60 | + pub in_family: Vec<(u8, u8)>, |
| 61 | + /// Live out-of-family edge slots: `(slot_index 0..4, neighbour_byte)` for |
| 62 | + /// each non-zero of the 4 inherited-adapter slots. |
| 63 | + pub out_family: Vec<(u8, u8)>, |
| 64 | +} |
| 65 | + |
| 66 | +/// The whole key-only graph view: one [`RenderedNode`] per board + the total |
| 67 | +/// live-edge count. Built touching only the head of every row. |
| 68 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 69 | +pub struct KeyGraph { |
| 70 | + pub nodes: Vec<RenderedNode>, |
| 71 | + pub edge_count: usize, |
| 72 | +} |
| 73 | + |
| 74 | +/// Render the key-only graph over a board-set, touching ONLY `row.key` (the |
| 75 | +/// GUID) and `row.edges` (the EdgeBlock) — never `row.value`. This is the |
| 76 | +/// memory-scan-speed Neo4j view: at 16384 boards it walks 16384 × 32 B = 512 KiB |
| 77 | +/// of heads, leaving the 8 MiB of value slabs cold. |
| 78 | +pub fn render_key_only(rows: &[NodeRow]) -> KeyGraph { |
| 79 | + let mut nodes = Vec::with_capacity(rows.len()); |
| 80 | + let mut edge_count = 0usize; |
| 81 | + for row in rows { |
| 82 | + // ── the ONLY two field reads: the 32-byte head ── |
| 83 | + let guid = row.key; |
| 84 | + let eb = row.edges; |
| 85 | + let in_family: Vec<(u8, u8)> = eb |
| 86 | + .in_family |
| 87 | + .iter() |
| 88 | + .enumerate() |
| 89 | + .filter(|(_, &b)| b != 0) |
| 90 | + .map(|(i, &b)| (i as u8, b)) |
| 91 | + .collect(); |
| 92 | + let out_family: Vec<(u8, u8)> = eb |
| 93 | + .out_family |
| 94 | + .iter() |
| 95 | + .enumerate() |
| 96 | + .filter(|(_, &b)| b != 0) |
| 97 | + .map(|(i, &b)| (i as u8, b)) |
| 98 | + .collect(); |
| 99 | + edge_count += in_family.len() + out_family.len(); |
| 100 | + nodes.push(RenderedNode { |
| 101 | + guid, |
| 102 | + hhtl: hhtl_path_of(&guid), |
| 103 | + in_family, |
| 104 | + out_family, |
| 105 | + }); |
| 106 | + } |
| 107 | + KeyGraph { nodes, edge_count } |
| 108 | +} |
| 109 | + |
| 110 | +/// The §2.4 demo: render the 16k-board SoA key-only and report node/edge counts |
| 111 | +/// + a self-describing sample, proving the Neo4j-grade view costs only the |
| 112 | +/// 32-byte heads (8 MiB of value slabs untouched). |
| 113 | +pub fn run_demo() { |
| 114 | + let rows = crate::domino::seed_boards(crate::bridge::MAX_BOARDS); |
| 115 | + let g = render_key_only(&rows); |
| 116 | + let head_bytes = g.nodes.len() * 32; |
| 117 | + let slab_bytes = g.nodes.len() * 480; |
| 118 | + let sample = &g.nodes[3]; |
| 119 | + println!( |
| 120 | + "§2.4 key-only render: {} nodes / {} edges from {} KiB of 32-byte heads \ |
| 121 | + ({} KiB of value slabs left COLD, zero decode); sample node[3] = {} \ |
| 122 | + hhtl_depth={} in_family={:?} out_family={:?}", |
| 123 | + g.nodes.len(), |
| 124 | + g.edge_count, |
| 125 | + head_bytes / 1024, |
| 126 | + slab_bytes / 1024, |
| 127 | + sample.guid, |
| 128 | + sample.hhtl.depth(), |
| 129 | + sample.in_family, |
| 130 | + sample.out_family, |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +#[cfg(test)] |
| 135 | +mod tests { |
| 136 | + use super::*; |
| 137 | + use crate::domino::seed_boards; |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn render_reads_head_only_and_finds_edges() { |
| 141 | + let rows = seed_boards(16); |
| 142 | + let g = render_key_only(&rows); |
| 143 | + assert_eq!(g.nodes.len(), 16); |
| 144 | + // each seeded board has in_family[0] (ring) + out_family[0] (adapter). |
| 145 | + assert_eq!(g.edge_count, 32); |
| 146 | + let n = &g.nodes[3]; |
| 147 | + // identity = idx (bootstrap address: classid + family default). |
| 148 | + assert_eq!(n.guid.identity(), 3); |
| 149 | + assert!(n.guid.is_bootstrap_address()); |
| 150 | + assert_eq!(n.in_family, vec![(0u8, 4u8)]); // (3 % 255) + 1 |
| 151 | + assert_eq!(n.out_family, vec![(0u8, 4u8)]); // 1 + (3 % 4) |
| 152 | + } |
| 153 | + |
| 154 | + #[test] |
| 155 | + fn render_ignores_value_slab() { |
| 156 | + // The falsifiable zero-value-decode probe: poisoning the value slab with |
| 157 | + // 0xFF must not change the key-only render by a single byte. If the |
| 158 | + // render touched bytes 32..512, the two KeyGraphs would differ. |
| 159 | + let clean = seed_boards(64); |
| 160 | + let mut poisoned = clean.clone(); |
| 161 | + for row in &mut poisoned { |
| 162 | + row.value = [0xFFu8; 480]; |
| 163 | + } |
| 164 | + assert_eq!(render_key_only(&clean), render_key_only(&poisoned)); |
| 165 | + } |
| 166 | + |
| 167 | + #[test] |
| 168 | + fn hhtl_path_of_bootstrap_is_depth_12_all_zero() { |
| 169 | + // A bootstrap GUID (HEEL=HIP=TWIG=0) lowers to a 12-nibble all-zero path |
| 170 | + // (root basin 0, descending 0 each level) — every HHT tier consulted, |
| 171 | + // none discriminating yet (the zero-fallback ladder, in the path axis). |
| 172 | + let g = NodeGuid::local(42); |
| 173 | + let p = hhtl_path_of(&g); |
| 174 | + assert_eq!(p.depth(), 12); |
| 175 | + } |
| 176 | + |
| 177 | + #[test] |
| 178 | + fn hhtl_path_of_uses_hht_tiers_not_classid_or_identity() { |
| 179 | + // classid is the routing prefix (codebook selector), identity is the |
| 180 | + // leaf — neither is part of the HHT path. Two GUIDs differing ONLY in |
| 181 | + // classid + identity share the same 12-nibble HHTL path; differing in a |
| 182 | + // HHT tier changes it. |
| 183 | + let a = NodeGuid::new(0xAAAA_AAAA, 0x1234, 0x5678, 0x9ABC, 0, 0x00_0001); |
| 184 | + let b = NodeGuid::new(0xBBBB_BBBB, 0x1234, 0x5678, 0x9ABC, 0, 0x00_0002); |
| 185 | + let c = NodeGuid::new(0xAAAA_AAAA, 0x0234, 0x5678, 0x9ABC, 0, 0x00_0001); |
| 186 | + assert_eq!(hhtl_path_of(&a), hhtl_path_of(&b)); |
| 187 | + assert_ne!(hhtl_path_of(&a), hhtl_path_of(&c)); |
| 188 | + } |
| 189 | +} |
0 commit comments