Skip to main content

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}