|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | +// SPDX-FileCopyrightText: Copyright The Lance Authors |
| 3 | + |
| 4 | +//! MailboxSoA scan — `Backend::MailboxSoa` (`cypher-kanban-ast-unification-v1` Inc 0). |
| 5 | +//! |
| 6 | +//! A Cypher `MATCH` routed over the canonical GUID-keyed substrate via the |
| 7 | +//! zero-dep [`MailboxSoaView`] contract, instead of the index-built |
| 8 | +//! [`TypedGraph`](crate::graph::blasgraph::typed_graph::TypedGraph) the other |
| 9 | +//! backends use. The thesis (`E-GUID-IS-THE-GRAPH`): the substrate **is** the |
| 10 | +//! graph — a node is its GUID key, and `MATCH (n:Label)` is a **classid |
| 11 | +//! prefix-route**, resolved off the key/class column with **zero value decode** |
| 12 | +//! (it never touches the 480 B value slab: `energy` / `meta` / fingerprints). |
| 13 | +//! |
| 14 | +//! ## Scope of this increment (the verified-safe half) |
| 15 | +//! |
| 16 | +//! This lands the **node-match** half — `MATCH (n:Label)` → the set of rows whose |
| 17 | +//! class discriminator equals the queried class. That is the half that is correct |
| 18 | +//! *without* the boundary the 5+3 council said to pin first. |
| 19 | +//! |
| 20 | +//! The **edge-traversal** half (`(a)-[r]->(b)`) is deliberately **deferred**, for |
| 21 | +//! two grounded reasons (verdict §4b): |
| 22 | +//! |
| 23 | +//! 1. **Edge-representation is not yet pinned.** `EdgeBlock` (12+4 one-byte |
| 24 | +//! *adjacency* slots → neighbor `local_key`) and `CausalEdge64` (an **SPO |
| 25 | +//! triple** of s/p/o palette indices, the `edges_raw` column) are NOT |
| 26 | +//! interchangeable. A relationship-type must bind to one via the class's |
| 27 | +//! `EdgeCodecFlavor` — the router must not guess by availability. |
| 28 | +//! 2. **The View exposes only `edges_raw` (`CausalEdge64`/SPO), not `EdgeBlock` |
| 29 | +//! adjacency.** `CausalEdge64` carries s/p/o palette indices, not a row→row |
| 30 | +//! pointer, so it cannot be dereferenced to a neighbor row without the |
| 31 | +//! adjacency accessor (a follow-on contract addition). |
| 32 | +//! |
| 33 | +//! So this module does the classid prefix-route and the `local_key`→row point |
| 34 | +//! lookup (via [`MailboxSoaView::row_for_local_key`]); edge dispatch lands once |
| 35 | +//! the representation boundary is resolved. |
| 36 | +
|
| 37 | +use lance_graph_contract::soa_view::MailboxSoaView; |
| 38 | + |
| 39 | +use crate::graph::graph_router::Backend; |
| 40 | + |
| 41 | +/// A node matched by a MailboxSoA scan: the row index plus the backend tag. |
| 42 | +/// |
| 43 | +/// Distinct from `GraphHit` (an *edge* with source/target) — a node match has no |
| 44 | +/// target until the edge-traversal half lands. Kept minimal and honest. |
| 45 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 46 | +pub struct NodeMatch { |
| 47 | + /// The matched row index within the mailbox. |
| 48 | + pub row: usize, |
| 49 | + /// Always [`Backend::MailboxSoa`] — names the route that produced this match. |
| 50 | + pub backend: Backend, |
| 51 | +} |
| 52 | + |
| 53 | +/// `MATCH (n:Label)` → the rows whose class discriminator equals `class_id`. |
| 54 | +/// |
| 55 | +/// The classid prefix-route over the `MailboxSoaView`. **Zero value decode by |
| 56 | +/// construction:** the only column read is `class_id()` (which aliases the |
| 57 | +/// `entity_type` u16 slot — the Cognitive-RISC N1 class hook); the 480 B value |
| 58 | +/// slab (`energy` / `meta` / fingerprints) is never touched. This is the |
| 59 | +/// substrate-is-the-graph node-selection half of `Backend::MailboxSoa`. |
| 60 | +pub fn match_nodes_by_class<V: MailboxSoaView>(view: &V, class_id: u16) -> Vec<NodeMatch> { |
| 61 | + let classes = view.class_id(); |
| 62 | + classes |
| 63 | + .iter() |
| 64 | + .enumerate() |
| 65 | + .filter_map(|(row, &c)| { |
| 66 | + (c == class_id).then_some(NodeMatch { |
| 67 | + row, |
| 68 | + backend: Backend::MailboxSoa, |
| 69 | + }) |
| 70 | + }) |
| 71 | + .collect() |
| 72 | +} |
| 73 | + |
| 74 | +/// Point lookup: resolve a canonical [`NodeGuid::local_key`] to a single row, |
| 75 | +/// the GUID-keyed address half of `Backend::MailboxSoa`. |
| 76 | +/// |
| 77 | +/// [`NodeGuid::local_key`]: lance_graph_contract::canonical_node::NodeGuid::local_key |
| 78 | +/// |
| 79 | +/// Returns `None` when the view has not materialized a key index (the |
| 80 | +/// deferred-binding default of [`MailboxSoaView::row_for_local_key`]) — the |
| 81 | +/// caller then falls back to the positional `(mailbox_id, row)` address, never a |
| 82 | +/// wrong row. |
| 83 | +pub fn match_node_by_local_key<V: MailboxSoaView>(view: &V, local_key: u64) -> Option<NodeMatch> { |
| 84 | + view.row_for_local_key(local_key).map(|row| NodeMatch { |
| 85 | + row, |
| 86 | + backend: Backend::MailboxSoa, |
| 87 | + }) |
| 88 | +} |
| 89 | + |
| 90 | +#[cfg(test)] |
| 91 | +mod tests { |
| 92 | + use super::*; |
| 93 | + use lance_graph_contract::kanban::KanbanColumn; |
| 94 | + |
| 95 | + /// A minimal view over fixed columns. The value-side columns |
| 96 | + /// (`energy`/`meta`/fingerprints) PANIC on access so the zero-value-decode |
| 97 | + /// gate (F2) is proven structurally: if the scan ever touches them, the test |
| 98 | + /// fails loudly. |
| 99 | + struct GuardedSoa { |
| 100 | + class_ids: Vec<u16>, |
| 101 | + edges: Vec<u64>, |
| 102 | + keyed_rows: Vec<(u64, usize)>, |
| 103 | + } |
| 104 | + |
| 105 | + impl MailboxSoaView for GuardedSoa { |
| 106 | + fn mailbox_id(&self) -> u32 { |
| 107 | + 0 |
| 108 | + } |
| 109 | + fn n_rows(&self) -> usize { |
| 110 | + self.class_ids.len() |
| 111 | + } |
| 112 | + fn w_slot(&self) -> u8 { |
| 113 | + 0 |
| 114 | + } |
| 115 | + fn current_cycle(&self) -> u32 { |
| 116 | + 0 |
| 117 | + } |
| 118 | + fn phase(&self) -> KanbanColumn { |
| 119 | + KanbanColumn::Planning |
| 120 | + } |
| 121 | + // ── value slab — must NEVER be touched by a classid route (F2 guard) ── |
| 122 | + fn energy(&self) -> &[f32] { |
| 123 | + panic!("F2 violated: classid node-match touched the energy value column"); |
| 124 | + } |
| 125 | + fn edges_raw(&self) -> &[u64] { |
| 126 | + // edges are key/causal side, not value slab — allowed, but the |
| 127 | + // node-match half does not use them; returned for trait completeness. |
| 128 | + &self.edges |
| 129 | + } |
| 130 | + fn meta_raw(&self) -> &[u32] { |
| 131 | + // meta is value-slab adjacent; the node-match must not read it. |
| 132 | + panic!("F2 violated: classid node-match touched the meta value column"); |
| 133 | + } |
| 134 | + fn entity_type(&self) -> &[u16] { |
| 135 | + // class_id() aliases entity_type — this IS the class hook, allowed. |
| 136 | + &self.class_ids |
| 137 | + } |
| 138 | + fn row_for_local_key(&self, local_key: u64) -> Option<usize> { |
| 139 | + self.keyed_rows |
| 140 | + .iter() |
| 141 | + .find(|(k, _)| *k == local_key) |
| 142 | + .map(|(_, r)| *r) |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + fn sample() -> GuardedSoa { |
| 147 | + GuardedSoa { |
| 148 | + // rows: 0=A(7) 1=B(9) 2=C(7) 3=D(9) 4=E(7) |
| 149 | + class_ids: vec![7, 9, 7, 9, 7], |
| 150 | + edges: vec![0; 5], |
| 151 | + keyed_rows: vec![(0xABCD, 3), (0x1234, 0)], |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + #[test] |
| 156 | + fn match_nodes_by_class_routes_on_classid_only() { |
| 157 | + let soa = sample(); |
| 158 | + let hits = match_nodes_by_class(&soa, 7); |
| 159 | + let rows: Vec<usize> = hits.iter().map(|h| h.row).collect(); |
| 160 | + assert_eq!(rows, vec![0, 2, 4], "all class-7 rows, in order"); |
| 161 | + assert!(hits.iter().all(|h| h.backend == Backend::MailboxSoa)); |
| 162 | + // parity: the matched set equals the reference classid filter. |
| 163 | + let reference: Vec<usize> = soa |
| 164 | + .class_ids |
| 165 | + .iter() |
| 166 | + .enumerate() |
| 167 | + .filter(|(_, &c)| c == 7) |
| 168 | + .map(|(i, _)| i) |
| 169 | + .collect(); |
| 170 | + assert_eq!(rows, reference); |
| 171 | + } |
| 172 | + |
| 173 | + #[test] |
| 174 | + fn match_nodes_by_class_empty_when_no_match() { |
| 175 | + let soa = sample(); |
| 176 | + assert!(match_nodes_by_class(&soa, 42).is_empty()); |
| 177 | + } |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn match_node_by_local_key_resolves_via_key_index() { |
| 181 | + let soa = sample(); |
| 182 | + assert_eq!( |
| 183 | + match_node_by_local_key(&soa, 0xABCD), |
| 184 | + Some(NodeMatch { |
| 185 | + row: 3, |
| 186 | + backend: Backend::MailboxSoa |
| 187 | + }) |
| 188 | + ); |
| 189 | + // unknown key → None (caller falls back to positional address). |
| 190 | + assert_eq!(match_node_by_local_key(&soa, 0xDEAD), None); |
| 191 | + } |
| 192 | + |
| 193 | + #[test] |
| 194 | + fn f2_zero_value_decode_the_scan_never_panics_on_value_columns() { |
| 195 | + // The GuardedSoa panics if energy()/meta_raw() are read. If this test |
| 196 | + // completes, the classid node-match touched ONLY the class column. |
| 197 | + let soa = sample(); |
| 198 | + let _ = match_nodes_by_class(&soa, 7); |
| 199 | + let _ = match_node_by_local_key(&soa, 0x1234); |
| 200 | + } |
| 201 | +} |
0 commit comments