Skip to main content

velesdb_core/collection/graph/
traversal.rs

1//! Graph traversal algorithms for multi-hop queries.
2//!
3//! This module provides BFS-based traversal for variable-length path patterns
4//! like `(a)-[*1..3]->(b)` in MATCH clauses.
5//!
6//! # Streaming Mode (EPIC-019 US-005)
7//!
8//! For large graphs, the module provides streaming iterators that yield results
9//! lazily without loading all visited nodes into memory at once.
10
11use super::EdgeStore;
12use rustc_hash::{FxHashMap, FxHashSet};
13use smallvec::SmallVec;
14use std::collections::VecDeque;
15use std::time::Instant;
16
17/// Number of queue pops between wall-clock deadline checks.
18///
19/// Gating `Instant::now()` behind this counter keeps the per-node clock cost
20/// negligible while still aborting a runaway dense-graph traversal within a
21/// bounded number of expansions.
22pub(crate) const DEADLINE_CHECK_INTERVAL: u32 = 1024;
23
24/// Returns `true` when a wall-clock `deadline` is set and has been reached.
25///
26/// Callers gate this behind a per-node counter (every
27/// [`DEADLINE_CHECK_INTERVAL`] pops) so `Instant::now()` is not called per
28/// node; the `None` case is a single branch with no clock read.
29#[inline]
30pub(super) fn deadline_exceeded(deadline: Option<Instant>) -> bool {
31    deadline.is_some_and(|d| Instant::now() >= d)
32}
33
34/// Periodic wall-clock deadline gate for a BFS/DFS loop head.
35///
36/// Increments `nodes_since_check`; only every [`DEADLINE_CHECK_INTERVAL`] pops
37/// does it read the clock via [`deadline_exceeded`]. Returns `true` when the
38/// deadline has been reached (caller should `break`). When `deadline` is
39/// `None` this is a single branch with no clock read or counter cost.
40#[inline]
41pub(crate) fn deadline_reached(deadline: Option<Instant>, nodes_since_check: &mut u32) -> bool {
42    if deadline.is_none() {
43        return false;
44    }
45    *nodes_since_check += 1;
46    if *nodes_since_check < DEADLINE_CHECK_INTERVAL {
47        return false;
48    }
49    *nodes_since_check = 0;
50    deadline_exceeded(deadline)
51}
52
53/// Default maximum depth for unbounded traversals.
54pub const DEFAULT_MAX_DEPTH: u32 = 3;
55
56/// Safety cap for maximum depth to prevent runaway traversals.
57/// Only applied when user requests unbounded traversal (*).
58///
59/// Note: Neo4j and ArangoDB do NOT impose hard limits.
60/// 100 is chosen to cover most real-world use cases:
61/// - Social networks (6 degrees of separation)
62/// - Dependency graphs (deep npm/cargo trees)
63/// - Organizational hierarchies
64/// - Knowledge graphs
65pub const SAFETY_MAX_DEPTH: u32 = 100;
66
67/// Stack-allocated path type used internally by BFS/DFS traversal.
68///
69/// Most graph queries are depth 1-3 (social networks, knowledge graphs).
70/// `SmallVec<[u64; 4]>` stores up to 4 edge IDs inline (32 bytes on stack)
71/// and only heap-allocates for deeper traversals, eliminating per-path
72/// allocation overhead in the common case.
73///
74/// Note: [`TraversalResult::path`] uses `Vec<u64>` at the public API
75/// boundary. This type is exposed for advanced internal use only.
76pub type TraversalPath = SmallVec<[u64; 4]>;
77
78/// Result of a graph traversal operation.
79///
80/// The `path` field is `Vec<u64>` at the public API boundary. Internally,
81/// BFS state uses `SmallVec<[u64; 4]>` to avoid per-path heap allocation
82/// for typical depth 1-3 traversals, but this is converted to `Vec` when
83/// building the result.
84#[derive(Debug, Clone)]
85pub struct TraversalResult {
86    /// The target node ID reached.
87    pub target_id: u64,
88    /// The path taken (list of edge IDs).
89    pub path: Vec<u64>,
90    /// Depth of the traversal (number of hops).
91    pub depth: u32,
92}
93
94impl TraversalResult {
95    /// Creates a new traversal result.
96    #[must_use]
97    pub fn new(target_id: u64, path: Vec<u64>, depth: u32) -> Self {
98        Self {
99            target_id,
100            path,
101            depth,
102        }
103    }
104
105    /// Creates a traversal result from an internal `SmallVec` path.
106    ///
107    /// Converts the stack-allocated path to a heap-allocated `Vec` at the
108    /// public API boundary.
109    #[must_use]
110    #[allow(clippy::needless_pass_by_value)]
111    #[allow(dead_code)] // Reason: Constructor used by MATCH clause traversal (Wave 6 wiring)
112    pub(crate) fn from_smallvec(target_id: u64, path: TraversalPath, depth: u32) -> Self {
113        Self {
114            target_id,
115            path: path.to_vec(),
116            depth,
117        }
118    }
119}
120
121/// Configuration for graph traversal.
122#[derive(Debug, Clone)]
123pub struct TraversalConfig {
124    /// Minimum number of hops (inclusive).
125    pub min_depth: u32,
126    /// Maximum number of hops (inclusive).
127    pub max_depth: u32,
128    /// Maximum number of results to return.
129    pub limit: usize,
130    /// Filter by relationship types (empty = all types).
131    pub rel_types: Vec<String>,
132    /// Optional wall-clock deadline. When set and reached, traversal stops
133    /// expanding and returns the partial result accumulated so far (same
134    /// convention as the `limit` / `MAX_VISITED_SIZE` guards). `None` (the
135    /// default) disables the time bound entirely.
136    pub deadline: Option<Instant>,
137}
138
139impl Default for TraversalConfig {
140    fn default() -> Self {
141        Self {
142            min_depth: 1,
143            max_depth: DEFAULT_MAX_DEPTH,
144            limit: 100,
145            rel_types: Vec::new(),
146            deadline: None,
147        }
148    }
149}
150
151impl TraversalConfig {
152    /// Creates a config for a specific range (e.g., *1..3).
153    ///
154    /// Respects the caller's max_depth without artificial capping.
155    /// For unbounded traversals, use `with_unbounded_range()` instead.
156    #[must_use]
157    pub fn with_range(min: u32, max: u32) -> Self {
158        Self {
159            min_depth: min,
160            max_depth: max,
161            ..Self::default()
162        }
163    }
164
165    /// Creates a config for unbounded traversal (e.g., *1..).
166    ///
167    /// Applies SAFETY_MAX_DEPTH cap to prevent runaway traversals.
168    #[must_use]
169    pub fn with_unbounded_range(min: u32) -> Self {
170        Self {
171            min_depth: min,
172            max_depth: SAFETY_MAX_DEPTH,
173            ..Self::default()
174        }
175    }
176
177    /// Sets the result limit.
178    #[must_use]
179    pub fn with_limit(mut self, limit: usize) -> Self {
180        self.limit = limit;
181        self
182    }
183
184    /// Filters by relationship types.
185    #[must_use]
186    pub fn with_rel_types(mut self, types: Vec<String>) -> Self {
187        self.rel_types = types;
188        self
189    }
190
191    /// Sets a custom max depth (for advanced use cases).
192    #[must_use]
193    pub fn with_max_depth(mut self, max_depth: u32) -> Self {
194        self.max_depth = max_depth;
195        self
196    }
197
198    /// Sets a wall-clock deadline after which traversal returns its partial
199    /// result instead of continuing to expand.
200    #[must_use]
201    pub fn with_deadline(mut self, deadline: Instant) -> Self {
202        self.deadline = Some(deadline);
203        self
204    }
205}
206
207/// BFS state for traversal (parent-pointer variant).
208///
209/// Path reconstruction is deferred to result collection via a shared
210/// `FxHashMap<u64, (u64, u64)>` (target -> (parent, edge_id)).
211/// This eliminates per-edge `SmallVec` clones during frontier expansion.
212#[derive(Debug)]
213pub(super) struct BfsState {
214    /// Current node ID.
215    pub(super) node_id: u64,
216    /// Current depth.
217    pub(super) depth: u32,
218}
219
220/// Reconstructs the edge-ID path from `target` back to `source` using parent pointers.
221///
222/// Walks the parent chain `target -> parent -> ... -> source` collecting edge IDs,
223/// then reverses to produce source-to-target order.  Returns an empty `Vec` if
224/// `target == source` (the traversal root has no parent entry).
225#[must_use]
226pub(super) fn reconstruct_path(
227    target: u64,
228    source: u64,
229    parent_map: &FxHashMap<u64, (u64, u64)>,
230) -> Vec<u64> {
231    let mut path = Vec::new();
232    let mut current = target;
233    while current != source {
234        if let Some(&(parent, edge_id)) = parent_map.get(&current) {
235            path.push(edge_id);
236            current = parent;
237        } else {
238            // Unreachable in a correctly-built parent_map; defensive break.
239            break;
240        }
241    }
242    path.reverse();
243    path
244}
245
246/// Direction of edge traversal used by the shared BFS helper.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248enum BfsDirection {
249    /// Follow outgoing edges (source -> target).
250    Forward,
251    /// Follow incoming edges (target -> source).
252    Reverse,
253}
254
255/// Core BFS loop shared by forward and reverse traversal.
256///
257/// Uses parent-pointer map instead of per-state path cloning.
258/// Paths are reconstructed lazily only for nodes that qualify as results
259/// (depth >= min_depth), eliminating O(visited * avg_depth) clone overhead.
260///
261/// For forward direction, uses CSR zero-copy path when snapshot exists,
262/// avoiding `GraphEdge` cloning. Reverse direction always uses legacy path
263/// since the CSR snapshot only covers outgoing edges.
264#[must_use]
265fn bfs_traverse_directed(
266    edge_store: &EdgeStore,
267    source_id: u64,
268    config: &TraversalConfig,
269    direction: BfsDirection,
270) -> Vec<TraversalResult> {
271    let mut results = Vec::new();
272    let mut visited = FxHashSet::default();
273    let mut queue = VecDeque::new();
274    // Parent-pointer map: target_node -> (parent_node, edge_id).
275    // O(visited_nodes) memory vs O(visited_nodes * avg_depth) for path cloning.
276    let mut parent_map: FxHashMap<u64, (u64, u64)> = FxHashMap::default();
277
278    // Pre-build a FxHashSet<&str> once for the entire traversal, not per-node.
279    let rel_filter: FxHashSet<&str> = config.rel_types.iter().map(String::as_str).collect();
280
281    // CRITICAL FIX: Mark source node as visited before traversal
282    // to prevent cycles back to source causing duplicate work
283    visited.insert(source_id);
284
285    queue.push_back(BfsState {
286        node_id: source_id,
287        depth: 0,
288    });
289
290    // Use CSR zero-copy path for forward traversal when snapshot exists.
291    let use_csr = direction == BfsDirection::Forward && edge_store.has_csr_snapshot();
292
293    // Start at the threshold so an already-expired deadline aborts on the
294    // first pop; otherwise the clock is only read every N pops.
295    let mut nodes_since_check = DEADLINE_CHECK_INTERVAL;
296    while let Some(state) = queue.pop_front() {
297        if results.len() >= config.limit {
298            break;
299        }
300        if deadline_reached(config.deadline, &mut nodes_since_check) {
301            break;
302        }
303        if use_csr {
304            process_bfs_csr(
305                edge_store,
306                &state,
307                config,
308                source_id,
309                &rel_filter,
310                &mut results,
311                &mut visited,
312                &mut queue,
313                &mut parent_map,
314            );
315        } else {
316            let edges = match direction {
317                BfsDirection::Forward => edge_store.get_outgoing(state.node_id),
318                BfsDirection::Reverse => edge_store.get_incoming(state.node_id),
319            };
320            process_bfs_neighbors(
321                &edges,
322                &state,
323                config,
324                source_id,
325                &rel_filter,
326                direction,
327                &mut results,
328                &mut visited,
329                &mut queue,
330                &mut parent_map,
331            );
332        }
333    }
334
335    results
336}
337
338/// CSR zero-copy BFS expansion for forward traversal.
339///
340/// Reads target IDs, edge IDs, and interned labels from contiguous memory
341/// instead of cloning full `GraphEdge` objects (96+ bytes each).
342/// Uses parent-pointer insertion instead of path cloning.
343/// Only emits results for newly-discovered nodes (no duplicates).
344#[inline]
345#[allow(clippy::too_many_arguments)] // Reason: BFS helper passes parent_map alongside traversal state; private fn
346fn process_bfs_csr(
347    edge_store: &EdgeStore,
348    state: &BfsState,
349    config: &TraversalConfig,
350    source_id: u64,
351    rel_filter: &FxHashSet<&str>,
352    results: &mut Vec<TraversalResult>,
353    visited: &mut FxHashSet<u64>,
354    queue: &mut VecDeque<BfsState>,
355    parent_map: &mut FxHashMap<u64, (u64, u64)>,
356) {
357    let Some(snapshot) = edge_store.csr_snapshot() else {
358        return;
359    };
360    let targets = snapshot.neighbors(state.node_id);
361    let edge_ids = snapshot.edge_ids(state.node_id);
362
363    for (i, (&target, &eid)) in targets.iter().zip(edge_ids.iter()).enumerate() {
364        if results.len() >= config.limit {
365            break;
366        }
367        if let Some(label) = snapshot.label_at(state.node_id, i) {
368            if !rel_filter.is_empty() && !rel_filter.contains(label) {
369                continue;
370            }
371        } else if !rel_filter.is_empty() {
372            // Edge with unresolvable label excluded when filter is active
373            continue;
374        }
375        process_bfs_candidate(
376            target,
377            eid,
378            state.node_id,
379            state.depth,
380            config,
381            source_id,
382            results,
383            visited,
384            queue,
385            parent_map,
386        );
387    }
388}
389
390/// Processes neighbors for a single BFS level: filters edges, records results,
391/// and enqueues unvisited nodes for the next hop.
392/// Uses parent-pointer insertion instead of path cloning.
393/// Only emits results for newly-discovered nodes (no duplicates).
394#[inline]
395#[allow(clippy::too_many_arguments)] // Reason: BFS helper passes parent_map alongside traversal state; private fn
396fn process_bfs_neighbors(
397    edges: &[&super::GraphEdge],
398    state: &BfsState,
399    config: &TraversalConfig,
400    source_id: u64,
401    rel_filter: &FxHashSet<&str>,
402    direction: BfsDirection,
403    results: &mut Vec<TraversalResult>,
404    visited: &mut FxHashSet<u64>,
405    queue: &mut VecDeque<BfsState>,
406    parent_map: &mut FxHashMap<u64, (u64, u64)>,
407) {
408    for edge in edges {
409        if results.len() >= config.limit {
410            break;
411        }
412        if !rel_filter.is_empty() && !rel_filter.contains(edge.label()) {
413            continue;
414        }
415        let next_node = match direction {
416            BfsDirection::Forward => edge.target(),
417            BfsDirection::Reverse => edge.source(),
418        };
419        process_bfs_candidate(
420            next_node,
421            edge.id(),
422            state.node_id,
423            state.depth,
424            config,
425            source_id,
426            results,
427            visited,
428            queue,
429            parent_map,
430        );
431    }
432}
433
434/// Processes a single BFS candidate: checks depth, visited status, records
435/// parent pointer, emits result if within depth range, and enqueues for
436/// further expansion.
437#[inline]
438#[allow(clippy::too_many_arguments)]
439fn process_bfs_candidate(
440    target: u64,
441    edge_id: u64,
442    parent_node: u64,
443    current_depth: u32,
444    config: &TraversalConfig,
445    source_id: u64,
446    results: &mut Vec<TraversalResult>,
447    visited: &mut FxHashSet<u64>,
448    queue: &mut VecDeque<BfsState>,
449    parent_map: &mut FxHashMap<u64, (u64, u64)>,
450) {
451    let new_depth = current_depth + 1;
452    if new_depth > config.max_depth {
453        return;
454    }
455    let is_new = visited.insert(target);
456    if is_new {
457        parent_map.insert(target, (parent_node, edge_id));
458
459        if new_depth >= config.min_depth {
460            let path = reconstruct_path(target, source_id, parent_map);
461            results.push(TraversalResult::new(target, path, new_depth));
462        }
463        if new_depth < config.max_depth {
464            queue.push_back(BfsState {
465                node_id: target,
466                depth: new_depth,
467            });
468        }
469    }
470}
471
472/// Performs BFS traversal from a source node.
473///
474/// Finds all paths from `source_id` within the configured depth range.
475/// Uses iterative BFS with `VecDeque` for better cache locality.
476///
477/// # Arguments
478///
479/// * `edge_store` - The edge storage to traverse.
480/// * `source_id` - Starting node ID.
481/// * `config` - Traversal configuration.
482///
483/// # Returns
484///
485/// Vector of traversal results, limited by `config.limit`.
486#[must_use]
487pub fn bfs_traverse(
488    edge_store: &EdgeStore,
489    source_id: u64,
490    config: &TraversalConfig,
491) -> Vec<TraversalResult> {
492    bfs_traverse_directed(edge_store, source_id, config, BfsDirection::Forward)
493}
494
495/// Performs BFS traversal in the reverse direction (following incoming edges).
496#[must_use]
497pub fn bfs_traverse_reverse(
498    edge_store: &EdgeStore,
499    source_id: u64,
500    config: &TraversalConfig,
501) -> Vec<TraversalResult> {
502    bfs_traverse_directed(edge_store, source_id, config, BfsDirection::Reverse)
503}
504
505// Tests moved to traversal_tests.rs per project rules