88//!
99//! - `lance_graph_contract::network` (harvest → `FacetCascade`, content-blind,
1010//! 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 .
11+ //! - `NodeRow` + `NodeRowPacket::as_le_bytes`, the zero-copy Lance byte path
12+ //! ( the embedding idiom first used by the since-deprecated `symbiont` crate) .
1313//!
1414//! # What this does (and deliberately does NOT)
1515//!
1818//! (`facet_classid(4) | 6×(is_a:lo, part_of:hi)`, the L1 **rails** plane,
1919//! `CascadeShape::G6D2`). So [`compiled_class_to_facet`] is a reinterpret no-op.
2020//! [`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.
21+ //! [`NodeRow`] whose **key IS the minted facet**: the same 16 bytes, carried
22+ //! through lance-graph-contract's byte-identical `FacetCascade → NodeGuid`
23+ //! conversion — render classid in `[0..4)`, the `part_of:is_a` rails in
24+ //! `[4..16)`. `ValueSchema::Bootstrap` (all-zero value slab). The facet is the
25+ //! row's identity, so [`compiled_classes_to_noderows`] refuses a batch in which
26+ //! two classes mint the same facet. [`compiled_classes_to_le_bytes`] packs a
27+ //! slice onto the zero-copy storage boundary.
28+ //!
29+ //! The key used to be built with `NodeGuid::new(classid, 0, 0, 0, 0, identity)`
30+ //! — a V1 `family:identity` tail that dropped the rails. That was deliberate
31+ //! while the rail-chain vs key-tail reconciliation was open; lance-graph has
32+ //! since settled it (a V3 mint never degrades to a V1 tail; `NodeGuid` and
33+ //! `FacetCascade` are the same 16 bytes and convert without reinterpretation).
2534//!
2635//! Out of scope (named, not hidden), per the W2 plan:
2736//! - The actual Lance `Dataset::write` I/O — stops at `as_le_bytes()`, exactly
3039//! - The 12-byte rail payload → a NodeRow **value tenant** (`[H2]`: needs a new
3140//! append-only `ValueTenant`, `v3-envelope-auditor`-gated) — mirror network,
3241//! 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).
3842//! - `CompiledClass.actions` (behavior) — out-of-line, not in this byte payload
3943//! ("neither half carries behavior").
4044//! - Ownership: this is an OFFLINE **bake** (source → artifact, single-writer) =
@@ -59,29 +63,74 @@ pub fn compiled_class_to_facet(cc: &CompiledClass) -> FacetCascade {
5963 FacetCascade :: from_bytes ( & cc. facet . to_bytes ( ) )
6064}
6165
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+ /// Embed one [`CompiledClass`] as ONE 512-byte CANON [`NodeRow`]: the key is
67+ /// the minted facet itself (render classid + `part_of:is_a` rails, byte for
68+ /// byte), `ValueSchema::Bootstrap` (all-zero 480-byte value slab), empty edge
69+ /// block.
70+ ///
71+ /// No V1 `family:identity` tail is minted: the key comes from
72+ /// `NodeGuid::from(FacetCascade)`, never `NodeGuid::new`.
6673///
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).
74+ /// # Example
75+ ///
76+ /// ```ignore
77+ /// let row = compiled_class_to_noderow(&cc);
78+ /// assert_eq!(row.key.as_bytes(), &cc.facet.to_bytes());
79+ /// ```
7180#[ must_use]
72- pub fn compiled_class_to_noderow ( cc : & CompiledClass , identity : u32 ) -> NodeRow {
81+ pub fn compiled_class_to_noderow ( cc : & CompiledClass ) -> NodeRow {
7382 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) ,
83+ key : NodeGuid :: from ( compiled_class_to_facet ( cc) ) ,
7884 edges : EdgeBlock :: default ( ) ,
7985 // ValueSchema::Bootstrap == FieldMask::EMPTY == all-zero slab; asserted
8086 // by the constant below so a reader never mistakes it for a live schema.
8187 value : [ 0u8 ; 480 ] ,
8288 }
8389}
8490
91+ /// Two classes in one batch minted byte-identical facets, so they would share
92+ /// a row key. Indices are positions in the input slice.
93+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
94+ pub struct DuplicateFacetKey {
95+ /// The earlier class with this key.
96+ pub first : usize ,
97+ /// The later class that collides with it.
98+ pub second : usize ,
99+ }
100+
101+ impl core:: fmt:: Display for DuplicateFacetKey {
102+ fn fmt ( & self , f : & mut core:: fmt:: Formatter < ' _ > ) -> core:: fmt:: Result {
103+ write ! (
104+ f,
105+ "classes {} and {} mint the same 16-byte facet, so they would share a row key" ,
106+ self . first, self . second
107+ )
108+ }
109+ }
110+
111+ impl std:: error:: Error for DuplicateFacetKey { }
112+
113+ /// Embed a batch of classes, one [`NodeRow`] each, in input order.
114+ ///
115+ /// # Errors
116+ ///
117+ /// [`DuplicateFacetKey`] if two classes mint the same facet: the facet is the
118+ /// row key, so a duplicate would make two classes indistinguishable.
119+ pub fn compiled_classes_to_noderows (
120+ ccs : & [ CompiledClass ] ,
121+ ) -> Result < Vec < NodeRow > , DuplicateFacetKey > {
122+ let rows: Vec < NodeRow > = ccs. iter ( ) . map ( compiled_class_to_noderow) . collect ( ) ;
123+ let mut seen: std:: collections:: HashMap < [ u8 ; 16 ] , usize > =
124+ std:: collections:: HashMap :: with_capacity ( rows. len ( ) ) ;
125+ for ( i, row) in rows. iter ( ) . enumerate ( ) {
126+ if let Some ( & first) = seen. get ( row. key . as_bytes ( ) ) {
127+ return Err ( DuplicateFacetKey { first, second : i } ) ;
128+ }
129+ seen. insert ( * row. key . as_bytes ( ) , i) ;
130+ }
131+ Ok ( rows)
132+ }
133+
85134/// `ValueSchema` this sink stamps into every row it builds — the all-zero
86135/// bootstrap slab (no tenant lane written; the rails ride the facet surface).
87136pub const SINK_VALUE_SCHEMA : ValueSchema = ValueSchema :: Bootstrap ;
@@ -175,54 +224,69 @@ mod tests {
175224 assert_eq ! ( CascadeShape :: G6D2 . groups( ) , 6 ) ;
176225 }
177226
227+ /// A second, unrelated Odoo model, so two DISTINCT classes can be sunk
228+ /// (one class now yields exactly one key).
229+ fn res_partner_compiled ( ) -> CompiledClass {
230+ let mut g = ModelGraph :: new ( "odoo" ) ;
231+ g. models . push ( Model :: new ( "res_partner" ) ) ;
232+ crate :: mint:: compile_graph_python :: < OdooPort > ( & g)
233+ . into_iter ( )
234+ . find ( |c| c. class . name == "res_partner" )
235+ . expect ( "res_partner compiles" )
236+ }
237+
178238 // ── T-C: the render classid lands in the key ──
179239 #[ test]
180240 fn classid_lands_in_key ( ) {
181241 let cc = account_move_compiled ( ) ;
182- let row = compiled_class_to_noderow ( & cc, 7 ) ;
242+ let row = compiled_class_to_noderow ( & cc) ;
183243 assert_eq ! (
184244 row. key. classid( ) ,
185245 cc. facet. facet_classid( ) ,
186246 "key[0..4) decodes the render classid"
187247 ) ;
188248 }
189249
190- // ── T-D: bootstrap tail / [H1] guard — rails did NOT leak into the key tail ──
250+ // ── T-D: the key IS the minted facet — rails included, no V1 tail ──
191251 #[ test]
192- fn key_tail_is_bootstrap_not_derived_from_rails ( ) {
252+ fn key_is_the_minted_facet_rails_included ( ) {
193253 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"
254+ // Anti-vacuity: with all-zero rails a bootstrap V1 key and the facet
255+ // key would be indistinguishable in [4..16), and this test would pass
256+ // for the old code too.
257+ assert_ne ! (
258+ cc. facet. to_bytes( ) [ 4 ..] ,
259+ [ 0u8 ; 12 ] ,
260+ "fixture must carry non-zero rails"
200261 ) ;
201- assert ! (
202- row. key. is_unbasined( ) ,
203- "family==0 → unbasined; the rail tiers must not populate the tail ([H1])"
262+ let row = compiled_class_to_noderow ( & cc) ;
263+ assert_eq ! ( row. key. as_bytes( ) , & cc. facet. to_bytes( ) , "byte for byte" ) ;
264+ let back = row. key . facet ( ) ;
265+ assert_eq ! (
266+ back. hi_chain( ) ,
267+ cc. facet. part_of_chain( ) ,
268+ "part_of in the key"
204269 ) ;
270+ assert_eq ! ( back. lo_chain( ) , cc. facet. is_a_chain( ) , "is_a in the key" ) ;
205271 }
206272
207273 // ── T-E: round-trip through the storage byte boundary ──
208274 #[ 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- ] ;
275+ fn le_bytes_round_trip_preserves_keys ( ) {
276+ let ccs = [ account_move_compiled ( ) , res_partner_compiled ( ) ] ;
277+ let rows = compiled_classes_to_noderows ( & ccs)
278+ . unwrap_or_else ( |e| panic ! ( "distinct classes refused: {e}" ) ) ;
215279
216280 // 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.
281+ // 64-aligned `&[NodeRow]`, per canonical_node's align(64) contract).
219282 let packet = NodeRowPacket :: new ( & rows, 0 ) ;
220283 let aligned = packet. as_le_bytes ( ) ;
221284 assert_eq ! ( aligned. len( ) , 2 * 512 , "two 512-byte CANON rows" ) ;
222285 let decoded = node_rows_from_le_bytes ( aligned) . expect ( "aligned zero-copy decode" ) ;
223286 assert_eq ! ( decoded. len( ) , 2 ) ;
224- assert_eq ! ( decoded[ 0 ] . key. classid( ) , cc. facet. facet_classid( ) ) ;
225- assert_eq ! ( decoded[ 1 ] . key. identity( ) , 2 ) ;
287+ for ( cc, row) in ccs. iter ( ) . zip ( decoded) {
288+ assert_eq ! ( row. key. as_bytes( ) , & cc. facet. to_bytes( ) ) ;
289+ }
226290
227291 // The owned-`Vec` helper carries the identical bytes (a copy off the
228292 // storage boundary — Lance owns re-alignment on read-back).
@@ -231,24 +295,37 @@ mod tests {
231295
232296 // ── T-F: field isolation (I-LEGACY-API-FEATURE-GATED matrix) ──
233297 #[ 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( ) ) ;
298+ fn field_isolation_key_value_and_edges_are_independent ( ) {
299+ let a = compiled_class_to_noderow ( & account_move_compiled ( ) ) ;
300+ let b = compiled_class_to_noderow ( & res_partner_compiled ( ) ) ;
301+ assert_ne ! ( a. key, b. key, "distinct classes, distinct keys" ) ;
246302 assert_eq ! (
247303 a. value, [ 0u8 ; 480 ] ,
248304 "no value lane written (bootstrap schema)"
249305 ) ;
250306 assert_eq ! ( b. value, [ 0u8 ; 480 ] ) ;
251307 assert_eq ! ( a. edges, EdgeBlock :: default ( ) , "edge block reserved-zero" ) ;
308+ assert_eq ! ( b. edges, EdgeBlock :: default ( ) ) ;
309+ }
310+
311+ // ── T-H: the batch refuses a duplicate key, and only a duplicate ──
312+ #[ test]
313+ fn batch_refuses_duplicate_facets_and_only_those ( ) {
314+ let am = account_move_compiled ( ) ;
315+ let rp = res_partner_compiled ( ) ;
316+ // can it fire: the same class twice is the same key
317+ assert_eq ! (
318+ compiled_classes_to_noderows( & [ am. clone( ) , rp. clone( ) , am. clone( ) ] ) . err( ) ,
319+ Some ( DuplicateFacetKey {
320+ first: 0 ,
321+ second: 2
322+ } )
323+ ) ;
324+ // can it stay silent: distinct classes pass, in input order
325+ let rows = compiled_classes_to_noderows ( & [ rp. clone ( ) , am. clone ( ) ] )
326+ . unwrap_or_else ( |e| panic ! ( "distinct classes refused: {e}" ) ) ;
327+ assert_eq ! ( rows[ 0 ] . key. as_bytes( ) , & rp. facet. to_bytes( ) ) ;
328+ assert_eq ! ( rows[ 1 ] . key. as_bytes( ) , & am. facet. to_bytes( ) ) ;
252329 }
253330
254331 // ── T-G: this sink moves ZERO bytes at rest — no layout-version bump ──
0 commit comments