velesdb_core/collection/graph/csr_snapshot.rs
1//! Zero-copy CSR (Compressed Sparse Row) snapshot for cache-friendly BFS traversal.
2//!
3//! Extracted from `edge.rs` to reduce NLOC. Contains:
4//! - `CsrSnapshot`: Immutable CSR snapshot of the graph
5//! - `SnapshotBuilder`: Builds a `CsrSnapshot` from an `EdgeStore`
6//! - `EdgePredicate` trait + `LabelFilter` / `NoFilter` implementations
7//! - `AdjacencySource` trait for generic traversal
8
9use super::edge::EdgeStore;
10use super::label_table::{LabelId, LabelTable};
11use rustc_hash::{FxHashMap, FxHashSet};
12
13// ---------------------------------------------------------------------------
14// EdgePredicate trait and filters (Task 7: predicate pushdown)
15// ---------------------------------------------------------------------------
16
17/// Trait for predicate pushdown filtering in [`CsrSnapshot`].
18///
19/// Implementations evaluate whether an edge should be included in traversal
20/// results directly at the CSR level, avoiding materialisation of non-matching
21/// edges.
22pub trait EdgePredicate: Send + Sync {
23 /// Returns `true` if the edge `(target, edge_id, label_id)` should be
24 /// included in the result set.
25 fn matches(&self, target: u64, edge_id: u64, label_id: LabelId) -> bool;
26}
27
28/// Filters edges by a set of allowed [`LabelId`]s.
29///
30/// Only edges whose label is in the `allowed` set pass the predicate.
31pub struct LabelFilter {
32 allowed: FxHashSet<LabelId>,
33}
34
35impl LabelFilter {
36 /// Creates a new `LabelFilter` accepting only the given label IDs.
37 #[must_use]
38 pub fn new(allowed: FxHashSet<LabelId>) -> Self {
39 Self { allowed }
40 }
41}
42
43impl EdgePredicate for LabelFilter {
44 #[inline]
45 fn matches(&self, _target: u64, _edge_id: u64, label_id: LabelId) -> bool {
46 self.allowed.contains(&label_id)
47 }
48}
49
50/// Accepts all edges (no-op predicate).
51///
52/// Optimised away by monomorphisation — the compiler inlines the constant
53/// `true` return, producing zero overhead compared to an unfiltered path.
54pub struct NoFilter;
55
56impl EdgePredicate for NoFilter {
57 #[inline]
58 fn matches(&self, _target: u64, _edge_id: u64, _label_id: LabelId) -> bool {
59 true
60 }
61}
62
63// ---------------------------------------------------------------------------
64// AdjacencySource trait (Task 9: generic BFS)
65// ---------------------------------------------------------------------------
66
67/// Source of adjacency data for traversal algorithms.
68///
69/// Abstracts neighbor access so that BFS/DFS algorithms can work with
70/// either [`CsrSnapshot`] (zero-copy) or [`EdgeStore`] (legacy) without
71/// code duplication.
72pub trait AdjacencySource {
73 /// Returns the target node IDs reachable from `node_id`.
74 fn neighbors(&self, node_id: u64) -> Vec<u64>;
75}
76
77impl AdjacencySource for CsrSnapshot {
78 /// Returns neighbors from the CSR contiguous array (copies to Vec).
79 #[inline]
80 fn neighbors(&self, node_id: u64) -> Vec<u64> {
81 self.neighbors(node_id).to_vec()
82 }
83}
84
85impl AdjacencySource for EdgeStore {
86 /// Returns outgoing neighbor target IDs from the edge index.
87 #[inline]
88 fn neighbors(&self, node_id: u64) -> Vec<u64> {
89 self.get_outgoing(node_id)
90 .iter()
91 .map(|e| e.target())
92 .collect()
93 }
94}
95
96// ---------------------------------------------------------------------------
97// CsrSnapshot
98// ---------------------------------------------------------------------------
99
100/// Immutable CSR (Compressed Sparse Row) snapshot of the graph for zero-copy traversals.
101///
102/// All arrays are contiguous in memory for optimal cache locality during BFS/DFS.
103///
104/// # Memory layout
105///
106/// ```text
107/// offsets[i]..offsets[i+1] = range of neighbors for node at index i
108/// targets[offset] = target node_id
109/// edge_ids[offset] = edge ID
110/// label_ids[offset] = interned LabelId
111/// ```
112///
113/// `offsets` has length `node_count + 1`, where `offsets[node_count] == targets.len()`.
114#[derive(Debug, Clone)]
115pub struct CsrSnapshot {
116 /// Offset array: `offsets[i]..offsets[i+1]` = neighbor range for node at index `i`.
117 /// Length = `node_count + 1`. `offsets[node_count] == targets.len()`.
118 offsets: Vec<usize>,
119 /// Contiguous storage of target node IDs for all outgoing edges.
120 targets: Vec<u64>,
121 /// Contiguous storage of edge IDs, parallel to `targets`.
122 edge_ids: Vec<u64>,
123 /// Contiguous storage of interned label IDs, parallel to `targets`.
124 label_ids: Vec<LabelId>,
125 /// Mapping `node_id → index` in the offsets array for O(1) lookup.
126 node_to_index: FxHashMap<u64, usize>,
127 /// Mapping `index → node_id` (inverse of `node_to_index`).
128 index_to_node: Vec<u64>,
129 /// Interned label strings for label-based filtering.
130 label_table: Vec<String>,
131 /// Reverse map: label string → label index for O(1) lookup.
132 label_to_idx: FxHashMap<String, u32>,
133}
134
135impl CsrSnapshot {
136 /// Returns the `(offset, len)` range for a node, or `None` if absent.
137 #[inline]
138 fn range_of(&self, node_id: u64) -> Option<(usize, usize)> {
139 let &idx = self.node_to_index.get(&node_id)?;
140 let start = self.offsets[idx];
141 let end = self.offsets[idx + 1];
142 Some((start, end))
143 }
144
145 /// Returns neighbor target IDs for a source node as a zero-copy slice.
146 #[must_use]
147 #[inline]
148 pub fn neighbors(&self, node_id: u64) -> &[u64] {
149 if let Some((start, end)) = self.range_of(node_id) {
150 &self.targets[start..end]
151 } else {
152 &[]
153 }
154 }
155
156 /// Returns edge IDs for a source node as a zero-copy slice.
157 ///
158 /// Parallel to `neighbors()`: `edge_ids[i]` is the edge connecting
159 /// `node_id` to `neighbors()[i]`.
160 #[must_use]
161 #[inline]
162 pub fn edge_ids(&self, node_id: u64) -> &[u64] {
163 if let Some((start, end)) = self.range_of(node_id) {
164 &self.edge_ids[start..end]
165 } else {
166 &[]
167 }
168 }
169
170 /// Returns interned label IDs for a source node as a zero-copy slice.
171 ///
172 /// Parallel to `neighbors()`: `label_ids[i]` is the label of the edge
173 /// connecting `node_id` to `neighbors()[i]`.
174 #[must_use]
175 #[inline]
176 pub fn label_ids(&self, node_id: u64) -> &[LabelId] {
177 if let Some((start, end)) = self.range_of(node_id) {
178 &self.label_ids[start..end]
179 } else {
180 &[]
181 }
182 }
183
184 /// Returns the label string for a neighbor at position `neighbor_idx`
185 /// relative to the node's offset.
186 ///
187 /// Returns `None` if `source_id` is absent or `neighbor_idx` is out of range.
188 #[must_use]
189 #[inline]
190 pub fn label_at(&self, source_id: u64, neighbor_idx: usize) -> Option<&str> {
191 let (start, end) = self.range_of(source_id)?;
192 if neighbor_idx >= end - start {
193 return None;
194 }
195 let label_id = self.label_ids[start + neighbor_idx];
196 self.label_table
197 .get(label_id.as_u32() as usize)
198 .map(String::as_str)
199 }
200
201 /// Returns the outgoing degree of a node.
202 #[must_use]
203 #[inline]
204 pub fn degree(&self, node_id: u64) -> usize {
205 if let Some((start, end)) = self.range_of(node_id) {
206 end - start
207 } else {
208 0
209 }
210 }
211
212 /// Returns `true` if the node exists in this snapshot.
213 #[must_use]
214 #[inline]
215 pub fn contains_node(&self, node_id: u64) -> bool {
216 self.node_to_index.contains_key(&node_id)
217 }
218
219 /// Returns the number of source nodes in this snapshot.
220 #[must_use]
221 #[inline]
222 pub fn node_count(&self) -> usize {
223 self.index_to_node.len()
224 }
225
226 /// Returns the total number of outgoing edges in this snapshot.
227 #[must_use]
228 #[inline]
229 pub fn edge_count(&self) -> usize {
230 self.targets.len()
231 }
232
233 /// Returns the number of distinct edge labels in this snapshot.
234 #[must_use]
235 #[inline]
236 pub fn distinct_label_count(&self) -> usize {
237 self.label_table.len()
238 }
239
240 /// Checks whether a label string exists in the interned table.
241 ///
242 /// Used for fast pre-filtering: if a rel-type filter contains labels
243 /// not present in the snapshot, those branches can be skipped entirely.
244 #[must_use]
245 #[inline]
246 pub fn has_label(&self, label: &str) -> bool {
247 self.label_to_idx.contains_key(label)
248 }
249
250 /// Returns an iterator over neighbors that match the given predicate.
251 ///
252 /// Only edges for which `predicate.matches(target, edge_id, label_id)`
253 /// returns `true` are yielded. Non-matching edges are skipped without
254 /// materialisation.
255 ///
256 /// Each yielded item is `(target_id, edge_id, label_id)`.
257 pub fn neighbors_filtered<'a, P: EdgePredicate>(
258 &'a self,
259 node_id: u64,
260 predicate: &'a P,
261 ) -> impl Iterator<Item = (u64, u64, LabelId)> + 'a {
262 let (start, end) = self.range_of(node_id).unwrap_or((0, 0));
263 (start..end).filter_map(move |i| {
264 let target = self.targets[i];
265 let eid = self.edge_ids[i];
266 let lid = self.label_ids[i];
267 if predicate.matches(target, eid, lid) {
268 Some((target, eid, lid))
269 } else {
270 None
271 }
272 })
273 }
274
275 /// Returns a reference to the internal offsets array (for testing/validation).
276 #[cfg(test)]
277 pub(crate) fn offsets(&self) -> &[usize] {
278 &self.offsets
279 }
280}
281
282// ---------------------------------------------------------------------------
283// SnapshotBuilder
284// ---------------------------------------------------------------------------
285
286/// Builds a [`CsrSnapshot`] from an [`EdgeStore`] and [`LabelTable`].
287///
288/// This is a stateless namespace — no persistent state is held.
289/// Construction complexity is O(N + E) where N = nodes, E = edges.
290pub(crate) struct SnapshotBuilder;
291
292impl SnapshotBuilder {
293 /// Builds a `CsrSnapshot` from the given `EdgeStore` and `LabelTable`.
294 ///
295 /// # Algorithm
296 ///
297 /// 1. Collect all unique source `node_id`s from `edge_store.outgoing`.
298 /// 2. Sort for deterministic layout.
299 /// 3. Build `node_to_index` / `index_to_node`.
300 /// 4. For each node in order, iterate outgoing edges and fill
301 /// `targets`, `edge_ids`, `label_ids`.
302 /// 5. Accumulate `offsets`.
303 pub fn build(edge_store: &EdgeStore, _label_table: &LabelTable) -> CsrSnapshot {
304 // 1. Collect unique source node_ids
305 let mut node_ids: Vec<u64> = edge_store.outgoing_keys();
306
307 // 2. Sort for deterministic layout
308 node_ids.sort_unstable();
309
310 let node_count = node_ids.len();
311 let total_edges: usize = edge_store.total_outgoing_edges();
312
313 // 3. Build node_to_index and index_to_node
314 let mut node_to_index =
315 FxHashMap::with_capacity_and_hasher(node_count, rustc_hash::FxBuildHasher);
316 for (idx, &nid) in node_ids.iter().enumerate() {
317 node_to_index.insert(nid, idx);
318 }
319
320 // 4 & 5. Fill arrays
321 let mut offsets = Vec::with_capacity(node_count + 1);
322 let mut targets = Vec::with_capacity(total_edges);
323 let mut edge_ids_buf = Vec::with_capacity(total_edges);
324 let mut label_ids_buf: Vec<LabelId> = Vec::with_capacity(total_edges);
325 let mut label_table_vec: Vec<String> = Vec::new();
326 let mut label_to_idx: FxHashMap<String, u32> =
327 FxHashMap::with_capacity_and_hasher(16, rustc_hash::FxBuildHasher);
328
329 for &nid in &node_ids {
330 offsets.push(targets.len());
331 edge_store.for_each_outgoing_edge(nid, |edge| {
332 targets.push(edge.target());
333 edge_ids_buf.push(edge.id());
334
335 // Always use local interning for label_ids stored in CSR.
336 // label_at() resolves against the local label_table vec.
337 let label_str = edge.label();
338 let local_idx = *label_to_idx
339 .entry(label_str.to_string())
340 .or_insert_with(|| {
341 let idx = label_table_vec.len();
342 label_table_vec.push(label_str.to_string());
343 #[allow(clippy::cast_possible_truncation)]
344 // Reason: label count bounded by schema size
345 {
346 idx as u32
347 }
348 });
349 label_ids_buf.push(LabelId::from_u32(local_idx));
350 });
351 }
352 // Final offset sentinel
353 offsets.push(targets.len());
354
355 CsrSnapshot {
356 offsets,
357 targets,
358 edge_ids: edge_ids_buf,
359 label_ids: label_ids_buf,
360 node_to_index,
361 index_to_node: node_ids,
362 label_table: label_table_vec,
363 label_to_idx,
364 }
365 }
366
367 /// Creates an empty `CsrSnapshot` (no nodes, no edges).
368 #[must_use]
369 #[allow(dead_code)] // Used by tests and ConcurrentEdgeStore (Task 5)
370 pub fn empty() -> CsrSnapshot {
371 CsrSnapshot {
372 offsets: vec![0],
373 targets: Vec::new(),
374 edge_ids: Vec::new(),
375 label_ids: Vec::new(),
376 node_to_index: FxHashMap::default(),
377 index_to_node: Vec::new(),
378 label_table: Vec::new(),
379 label_to_idx: FxHashMap::default(),
380 }
381 }
382}