|
| 1 | +//! W2 — sink a [`CompiledClass`] onto the lance-graph **V3 SoA** substrate. |
| 2 | +//! |
| 3 | +//! The pull-in half of the transpiler mints a [`CompiledClass`] (THINK arm |
| 4 | +//! [`Class`](ogar_vocab::Class) + DO arm [`ActionDef`](ogar_vocab::ActionDef) + |
| 5 | +//! the 16-byte rail [`Facet`](ruff_spo_address::Facet)); nothing *stored* it. |
| 6 | +//! This module is the storage seam: `CompiledClass → V3 byte surface`, mirroring |
| 7 | +//! the two already-shipped sinks and inventing **no `*Bridge`**: |
| 8 | +//! |
| 9 | +//! - `lance_graph_contract::network` (harvest → `FacetCascade`, content-blind, |
| 10 | +//! one concept mint, deferred row embedding) — the *shape* borrowed here. |
| 11 | +//! - `symbiont::bridge` (`NodeRow` + `NodeRowPacket::as_le_bytes` zero-copy Lance |
| 12 | +//! byte path) — the *embedding idiom* borrowed here. |
| 13 | +//! |
| 14 | +//! # What this does (and deliberately does NOT) |
| 15 | +//! |
| 16 | +//! `CompiledClass.facet` is a [`ruff_spo_address::Facet`] whose 16 LE bytes are |
| 17 | +//! **byte-identical** to [`lance_graph_contract::facet::FacetCascade`] |
| 18 | +//! (`facet_classid(4) | 6×(is_a:lo, part_of:hi)`, the L1 **rails** plane, |
| 19 | +//! `CascadeShape::G6D2`). So [`compiled_class_to_facet`] is a reinterpret no-op. |
| 20 | +//! [`compiled_class_to_noderow`] embeds one class as ONE 512-byte CANON |
| 21 | +//! [`NodeRow`] — the render classid in the key `[0..4)`, a **bootstrap** tail |
| 22 | +//! (family=0, HHTL=0, identity=caller-supplied), `ValueSchema::Bootstrap` |
| 23 | +//! (all-zero value slab). [`compiled_classes_to_le_bytes`] packs a slice onto the |
| 24 | +//! zero-copy storage boundary. |
| 25 | +//! |
| 26 | +//! Out of scope (named, not hidden), per the W2 plan: |
| 27 | +//! - The actual Lance `Dataset::write` I/O — stops at `as_le_bytes()`, exactly |
| 28 | +//! as `network::to_facet` stops at the facet and symbiont's tombstone write is |
| 29 | +//! BLOCKED. That column write needs the lance engine / ractor runtime. |
| 30 | +//! - The 12-byte rail payload → a NodeRow **value tenant** (`[H2]`: needs a new |
| 31 | +//! append-only `ValueTenant`, `v3-envelope-auditor`-gated) — mirror network, |
| 32 | +//! which added no lane. The rails ride the `FacetCascade` surface only. |
| 33 | +//! - The key **tail from the rail tiers** (`[H1]`: le-contract §3 open item — |
| 34 | +//! the `6×(8:8)` rail chain vs the key's contiguous `u24++u24` tail are |
| 35 | +//! structurally different bit-groupings, "do not unify silently in code"). |
| 36 | +//! The tail is bootstrapped; [`compiled_class_to_noderow`] never derives it |
| 37 | +//! from the rails (test-enforced). |
| 38 | +//! - `CompiledClass.actions` (behavior) — out-of-line, not in this byte payload |
| 39 | +//! ("neither half carries behavior"). |
| 40 | +//! - Ownership: this is an OFFLINE **bake** (source → artifact, single-writer) = |
| 41 | +//! BOOTSTRAP-OK; the envelope owner is 0. NOT an online write — do not route |
| 42 | +//! through any batch writer. If it ever goes online, W5 stamp routing applies. |
| 43 | +
|
| 44 | +use lance_graph_contract::canonical_node::{NodeRowPacket, ValueSchema}; |
| 45 | +use lance_graph_contract::facet::FacetCascade; |
| 46 | +use lance_graph_contract::soa_envelope::SoaEnvelope; |
| 47 | +use lance_graph_contract::{EdgeBlock, NodeGuid, NodeRow}; |
| 48 | + |
| 49 | +use crate::mint::CompiledClass; |
| 50 | + |
| 51 | +/// The compiled class's 16-byte rail facet as a [`FacetCascade`] — a reinterpret |
| 52 | +/// no-op (`Facet::to_bytes` → `FacetCascade::from_bytes`, byte-identical layout). |
| 53 | +/// |
| 54 | +/// The result is the L1 **rails** reading (`CascadeShape::G6D2`): `hi_chain` = |
| 55 | +/// `part_of`, `lo_chain` = `is_a`. `facet_classid` is taken verbatim from the |
| 56 | +/// mint (the sanctioned composer) — never recomposed, never `as u16`/`>> 16`. |
| 57 | +#[must_use] |
| 58 | +pub fn compiled_class_to_facet(cc: &CompiledClass) -> FacetCascade { |
| 59 | + FacetCascade::from_bytes(&cc.facet.to_bytes()) |
| 60 | +} |
| 61 | + |
| 62 | +/// Embed one [`CompiledClass`] as ONE 512-byte CANON [`NodeRow`]: the render |
| 63 | +/// classid in the key `[0..4)`, a **bootstrap** tail (`family = 0`, HHTL = 0, |
| 64 | +/// `identity` = caller-supplied), `ValueSchema::Bootstrap` (all-zero 480-byte |
| 65 | +/// value slab), empty edge block. Mirrors `symbiont::bridge::board`. |
| 66 | +/// |
| 67 | +/// The key tail is bootstrapped, NOT derived from the rail tiers (`[H1]` |
| 68 | +/// freeze — the rail chain vs the key `u24++u24` tail reconciliation is an open |
| 69 | +/// operator/envelope ruling). `identity` must fit in 24 bits (`NodeGuid::new` |
| 70 | +/// asserts this). |
| 71 | +#[must_use] |
| 72 | +pub fn compiled_class_to_noderow(cc: &CompiledClass, identity: u32) -> NodeRow { |
| 73 | + NodeRow { |
| 74 | + // classid verbatim from the mint; family/HHTL bootstrap-zero, identity |
| 75 | + // caller-supplied → the zero-fallback ladder's bootstrap address while |
| 76 | + // the class is unbasined. |
| 77 | + key: NodeGuid::new(cc.facet.facet_classid(), 0, 0, 0, 0, identity), |
| 78 | + edges: EdgeBlock::default(), |
| 79 | + // ValueSchema::Bootstrap == FieldMask::EMPTY == all-zero slab; asserted |
| 80 | + // by the constant below so a reader never mistakes it for a live schema. |
| 81 | + value: [0u8; 480], |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/// `ValueSchema` this sink stamps into every row it builds — the all-zero |
| 86 | +/// bootstrap slab (no tenant lane written; the rails ride the facet surface). |
| 87 | +pub const SINK_VALUE_SCHEMA: ValueSchema = ValueSchema::Bootstrap; |
| 88 | + |
| 89 | +/// Pack a slice of CANON [`NodeRow`]s onto the zero-copy Lance storage boundary |
| 90 | +/// (`NodeRowPacket::as_le_bytes`, the `SoaEnvelope` byte view). `cycle` stamps |
| 91 | +/// the packet's write cycle. This is the last step in scope — the actual |
| 92 | +/// `Dataset::write` is a separate, engine-side deliverable. |
| 93 | +#[must_use] |
| 94 | +pub fn compiled_classes_to_le_bytes(rows: &[NodeRow], cycle: u32) -> Vec<u8> { |
| 95 | + NodeRowPacket::new(rows, cycle).as_le_bytes().to_vec() |
| 96 | +} |
| 97 | + |
| 98 | +#[cfg(test)] |
| 99 | +mod tests { |
| 100 | + use super::*; |
| 101 | + use lance_graph_contract::canonical_node::node_rows_from_le_bytes; |
| 102 | + use lance_graph_contract::facet::CascadeShape; |
| 103 | + use lance_graph_contract::soa_envelope::ENVELOPE_LAYOUT_VERSION; |
| 104 | + use ogar_vocab::ports::OdooPort; |
| 105 | + use ruff_spo_triplet::{Field, Model, ModelGraph}; |
| 106 | + |
| 107 | + /// A representative `account.move` graph, built directly (the |
| 108 | + /// source→ModelGraph parse is `ruff_python_spo`'s job) — the aliased model |
| 109 | + /// the mint resolves to the worked-example classid `0x0202_0002`. |
| 110 | + fn account_move_graph() -> ModelGraph { |
| 111 | + let mut m = Model::new("account_move"); |
| 112 | + m.fields.push(Field { |
| 113 | + name: "partner_id".to_string(), |
| 114 | + target: Some("res.partner".to_string()), |
| 115 | + relation_kind: Some("many2one".to_string()), |
| 116 | + ..Default::default() |
| 117 | + }); |
| 118 | + let mut g = ModelGraph::new("odoo"); |
| 119 | + g.models.push(m); |
| 120 | + g |
| 121 | + } |
| 122 | + |
| 123 | + /// The canonical `account.move` fixture, minted through the sanctioned |
| 124 | + /// composer so its classid is real (`0x0202_0002`), never hand-rolled hex. |
| 125 | + fn account_move_compiled() -> CompiledClass { |
| 126 | + let graph = account_move_graph(); |
| 127 | + let compiled = crate::mint::compile_graph_python::<OdooPort>(&graph); |
| 128 | + compiled |
| 129 | + .into_iter() |
| 130 | + .find(|c| c.class.name == "account_move") |
| 131 | + .expect("account_move compiles") |
| 132 | + } |
| 133 | + |
| 134 | + // ── T-A: facet byte-parity (the primary gate) ── |
| 135 | + #[test] |
| 136 | + fn facet_sink_is_byte_identical_reinterpret() { |
| 137 | + let cc = account_move_compiled(); |
| 138 | + let fc = compiled_class_to_facet(&cc); |
| 139 | + assert_eq!( |
| 140 | + fc.to_bytes(), |
| 141 | + cc.facet.to_bytes(), |
| 142 | + "FacetCascade round-trips the rail facet's 16 bytes verbatim" |
| 143 | + ); |
| 144 | + assert_eq!( |
| 145 | + fc.facet_classid, |
| 146 | + cc.facet.facet_classid(), |
| 147 | + "classid taken verbatim from the mint (0x0202_0002 for account.move)" |
| 148 | + ); |
| 149 | + assert_eq!( |
| 150 | + cc.facet.facet_classid(), |
| 151 | + 0x0202_0002, |
| 152 | + "the worked-example classid" |
| 153 | + ); |
| 154 | + } |
| 155 | + |
| 156 | + // ── T-B: the facet is L1 rails (G6D2), NOT an L6 quad ── |
| 157 | + #[test] |
| 158 | + fn facet_is_rails_plane_not_quads() { |
| 159 | + let cc = account_move_compiled(); |
| 160 | + let fc = compiled_class_to_facet(&cc); |
| 161 | + // part_of / is_a chains survive the reinterpret in the operator's |
| 162 | + // 6×(8:8) rails reading. hi = part_of, lo = is_a (le-contract L1). |
| 163 | + assert_eq!( |
| 164 | + fc.hi_chain(), |
| 165 | + cc.facet.part_of_chain(), |
| 166 | + "part_of == FacetCascade hi" |
| 167 | + ); |
| 168 | + assert_eq!( |
| 169 | + fc.lo_chain(), |
| 170 | + cc.facet.is_a_chain(), |
| 171 | + "is_a == FacetCascade lo" |
| 172 | + ); |
| 173 | + // Explicitly the rails shape (6 groups × 2 levels), NOT the L6 quad |
| 174 | + // shape (whose odoo-? semantics are unruled — we never emit it here). |
| 175 | + assert_eq!(CascadeShape::G6D2.groups(), 6); |
| 176 | + } |
| 177 | + |
| 178 | + // ── T-C: the render classid lands in the key ── |
| 179 | + #[test] |
| 180 | + fn classid_lands_in_key() { |
| 181 | + let cc = account_move_compiled(); |
| 182 | + let row = compiled_class_to_noderow(&cc, 7); |
| 183 | + assert_eq!( |
| 184 | + row.key.classid(), |
| 185 | + cc.facet.facet_classid(), |
| 186 | + "key[0..4) decodes the render classid" |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + // ── T-D: bootstrap tail / [H1] guard — rails did NOT leak into the key tail ── |
| 191 | + #[test] |
| 192 | + fn key_tail_is_bootstrap_not_derived_from_rails() { |
| 193 | + let cc = account_move_compiled(); |
| 194 | + let row = compiled_class_to_noderow(&cc, 7); |
| 195 | + assert_eq!(row.key.family(), 0, "family bootstrap-zero (unbasined)"); |
| 196 | + assert_eq!( |
| 197 | + row.key.identity(), |
| 198 | + 7, |
| 199 | + "identity is the caller value, not a rail byte" |
| 200 | + ); |
| 201 | + assert!( |
| 202 | + row.key.is_unbasined(), |
| 203 | + "family==0 → unbasined; the rail tiers must not populate the tail ([H1])" |
| 204 | + ); |
| 205 | + } |
| 206 | + |
| 207 | + // ── T-E: round-trip through the storage byte boundary ── |
| 208 | + #[test] |
| 209 | + fn le_bytes_round_trip_preserves_classid() { |
| 210 | + let cc = account_move_compiled(); |
| 211 | + let rows = vec![ |
| 212 | + compiled_class_to_noderow(&cc, 1), |
| 213 | + compiled_class_to_noderow(&cc, 2), |
| 214 | + ]; |
| 215 | + |
| 216 | + // The true zero-copy decode reads the packet's OWN bytes (a borrow of the |
| 217 | + // 64-aligned `&[NodeRow]`, per canonical_node's align(64) contract) — |
| 218 | + // mirrors symbiont::bridge's round-trip. |
| 219 | + let packet = NodeRowPacket::new(&rows, 0); |
| 220 | + let aligned = packet.as_le_bytes(); |
| 221 | + assert_eq!(aligned.len(), 2 * 512, "two 512-byte CANON rows"); |
| 222 | + let decoded = node_rows_from_le_bytes(aligned).expect("aligned zero-copy decode"); |
| 223 | + assert_eq!(decoded.len(), 2); |
| 224 | + assert_eq!(decoded[0].key.classid(), cc.facet.facet_classid()); |
| 225 | + assert_eq!(decoded[1].key.identity(), 2); |
| 226 | + |
| 227 | + // The owned-`Vec` helper carries the identical bytes (a copy off the |
| 228 | + // storage boundary — Lance owns re-alignment on read-back). |
| 229 | + assert_eq!(compiled_classes_to_le_bytes(&rows, 0), aligned); |
| 230 | + } |
| 231 | + |
| 232 | + // ── T-F: field isolation (I-LEGACY-API-FEATURE-GATED matrix) ── |
| 233 | + #[test] |
| 234 | + fn field_isolation_classid_and_identity_are_independent() { |
| 235 | + let cc = account_move_compiled(); |
| 236 | + // Same class, two identities → classid identical, identity distinct, |
| 237 | + // value slab + edges untouched (all zero) in both. |
| 238 | + let a = compiled_class_to_noderow(&cc, 10); |
| 239 | + let b = compiled_class_to_noderow(&cc, 20); |
| 240 | + assert_eq!( |
| 241 | + a.key.classid(), |
| 242 | + b.key.classid(), |
| 243 | + "identity write left classid unchanged" |
| 244 | + ); |
| 245 | + assert_ne!(a.key.identity(), b.key.identity()); |
| 246 | + assert_eq!( |
| 247 | + a.value, [0u8; 480], |
| 248 | + "no value lane written (bootstrap schema)" |
| 249 | + ); |
| 250 | + assert_eq!(b.value, [0u8; 480]); |
| 251 | + assert_eq!(a.edges, EdgeBlock::default(), "edge block reserved-zero"); |
| 252 | + } |
| 253 | + |
| 254 | + // ── T-G: this sink moves ZERO bytes at rest — no layout-version bump ── |
| 255 | + #[test] |
| 256 | + fn sink_does_not_move_the_layout_version() { |
| 257 | + assert_eq!( |
| 258 | + ENVELOPE_LAYOUT_VERSION, 2, |
| 259 | + "W2 adds no tenant lane; the envelope layout is untouched at v2" |
| 260 | + ); |
| 261 | + assert_eq!(SINK_VALUE_SCHEMA, ValueSchema::Bootstrap); |
| 262 | + } |
| 263 | +} |
0 commit comments