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(¤t) {
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