Skip to main content

nodedb_graph/traversal/
bfs.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Breadth-first traversal over CSR adjacency.
4
5use std::collections::HashSet;
6
7#[cfg(test)]
8use super::DEFAULT_MAX_VISITED;
9use crate::bfs_params::BfsParams;
10use crate::csr::index::LabelFilter;
11use crate::csr::{CsrIndex, Direction};
12use crate::overlay_delta::GraphOverlayDelta;
13use crate::traversal_overlay::traverse_bfs_overlay;
14
15impl CsrIndex {
16    /// BFS traversal. Returns all reachable node IDs within max_depth hops.
17    ///
18    /// `max_visited` caps the number of nodes visited to prevent supernode fan-out
19    /// explosion. Pass [`crate::traversal::DEFAULT_MAX_VISITED`] for the standard limit.
20    ///
21    /// `frontier_bitmap`: when `Some`, only nodes whose surrogate is present in the
22    /// bitmap are eligible as traversal targets. Start nodes are not gated — only
23    /// newly discovered frontier nodes are checked.
24    ///
25    /// `overlay`: when `Some` and non-empty, the traversal observes the
26    /// transaction's staged edge writes/deletes (read-your-own-writes),
27    /// including through nodes reachable only via a staged edge. When `None`
28    /// or empty, the durable-only dense fast path runs unchanged.
29    pub fn traverse_bfs(
30        &self,
31        params: BfsParams<'_>,
32        overlay: Option<&GraphOverlayDelta>,
33    ) -> Vec<String> {
34        Self::traverse_bfs_on(Some(self), params, overlay)
35    }
36
37    /// [`Self::traverse_bfs`] over an optional partition. `None` is a tenant
38    /// with no CSR partition: the walk then follows only the staged edges of
39    /// `overlay`, and returns no node without them.
40    pub fn traverse_bfs_on(
41        partition: Option<&Self>,
42        params: BfsParams<'_>,
43        overlay: Option<&GraphOverlayDelta>,
44    ) -> Vec<String> {
45        match (partition, overlay) {
46            (_, Some(ov)) if !ov.is_empty() => traverse_bfs_overlay(partition, params, ov),
47            (Some(csr), _) => csr.traverse_bfs_dense(params),
48            (None, _) => Vec::new(),
49        }
50    }
51
52    /// Durable-only BFS over the dense u32 CSR ids.
53    ///
54    /// The walk runs level by level. Each level's new nodes are admitted in
55    /// node-name order until `max_visited` nodes are visited, so a capped walk
56    /// admits the same nodes however the edges are stored. A cluster
57    /// coordinator walking the same edges across partitions admits the same
58    /// nodes.
59    fn traverse_bfs_dense(&self, params: BfsParams<'_>) -> Vec<String> {
60        let BfsParams {
61            start_nodes,
62            label_filter,
63            direction,
64            max_depth,
65            max_visited,
66            frontier_bitmap,
67        } = params;
68        let labels = self.label_filter(label_filter);
69        let in_bitmap = |id: u32| {
70            frontier_bitmap.is_none_or(|bm| {
71                bm.contains(nodedb_types::Surrogate::new(self.node_surrogate_raw(id)))
72            })
73        };
74        let mut visited: HashSet<u32> = HashSet::new();
75        let mut frontier: Vec<u32> = Vec::new();
76        for &node in start_nodes {
77            if let Some(&id) = self.node_to_id.get(node)
78                && visited.insert(id)
79            {
80                frontier.push(id);
81            }
82        }
83
84        for _depth in 0..max_depth {
85            if frontier.is_empty() || visited.len() >= max_visited {
86                break;
87            }
88            let candidates =
89                self.level_candidates(&frontier, &labels, direction, &visited, in_bitmap);
90            frontier = self.admit_by_name(candidates, &mut visited, max_visited);
91        }
92
93        visited
94            .into_iter()
95            .map(|id| self.id_to_node[id as usize].clone())
96            .collect()
97    }
98
99    /// Collect the unvisited neighbors of `frontier` that pass `labels` and
100    /// `eligible`. Records an access on every frontier node.
101    fn level_candidates(
102        &self,
103        frontier: &[u32],
104        labels: &LabelFilter,
105        direction: Direction,
106        visited: &HashSet<u32>,
107        eligible: impl Fn(u32) -> bool,
108    ) -> Vec<u32> {
109        let mut candidates: Vec<u32> = Vec::new();
110        for &node_id in frontier {
111            // Track access for hot/cold partition decisions.
112            self.record_access(node_id);
113            if matches!(direction, Direction::Out | Direction::Both) {
114                for (lid, dst) in self.dense_iter_out(node_id) {
115                    if labels.keeps(lid) && !visited.contains(&dst) && eligible(dst) {
116                        candidates.push(dst);
117                    }
118                }
119            }
120            if matches!(direction, Direction::In | Direction::Both) {
121                for (lid, src) in self.dense_iter_in(node_id) {
122                    if labels.keeps(lid) && !visited.contains(&src) && eligible(src) {
123                        candidates.push(src);
124                    }
125                }
126            }
127        }
128        candidates
129    }
130
131    /// Admit `candidates` into `visited` in node-name order, until `visited`
132    /// holds `max_visited` nodes. Returns the nodes admitted, in name order.
133    pub(crate) fn admit_by_name(
134        &self,
135        mut candidates: Vec<u32>,
136        visited: &mut HashSet<u32>,
137        max_visited: usize,
138    ) -> Vec<u32> {
139        self.sort_by_name(&mut candidates);
140        candidates.dedup();
141        let mut admitted = Vec::with_capacity(candidates.len());
142        for id in candidates {
143            if visited.len() >= max_visited {
144                break;
145            }
146            if visited.insert(id) {
147                self.prefetch_node(id);
148                admitted.push(id);
149            }
150        }
151        admitted
152    }
153
154    /// BFS traversal returning nodes with their hop depth.
155    ///
156    /// An empty `label_filter` keeps every edge. Otherwise an edge whose label
157    /// is any listed label passes. The walk runs level by level and admits
158    /// each level's new nodes in node-name order until `max_visited` nodes are
159    /// visited, like [`Self::traverse_bfs`]. The depth tag saturates at
160    /// `u8::MAX`.
161    ///
162    /// `max_visited` caps the number of nodes visited to prevent supernode fan-out
163    /// explosion. Pass [`crate::traversal::DEFAULT_MAX_VISITED`] for the standard limit.
164    pub fn traverse_bfs_with_depth(
165        &self,
166        start_nodes: &[&str],
167        label_filter: &[&str],
168        direction: Direction,
169        max_depth: usize,
170        max_visited: usize,
171    ) -> Vec<(String, u8)> {
172        let labels = self.label_filter(label_filter);
173        let mut visited: HashSet<u32> = HashSet::new();
174        let mut depths: Vec<(u32, u8)> = Vec::new();
175        let mut frontier: Vec<u32> = Vec::new();
176        for &node in start_nodes {
177            if let Some(&id) = self.node_to_id.get(node)
178                && visited.insert(id)
179            {
180                frontier.push(id);
181                depths.push((id, 0));
182            }
183        }
184
185        for depth in 0..max_depth {
186            if frontier.is_empty() || visited.len() >= max_visited {
187                break;
188            }
189            let tag = u8::try_from(depth.saturating_add(1)).unwrap_or(u8::MAX);
190            let candidates =
191                self.level_candidates(&frontier, &labels, direction, &visited, |_| true);
192            frontier = self.admit_by_name(candidates, &mut visited, max_visited);
193            depths.extend(frontier.iter().map(|&id| (id, tag)));
194        }
195
196        depths
197            .into_iter()
198            .map(|(id, depth)| (self.id_to_node[id as usize].clone(), depth))
199            .collect()
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use std::collections::HashMap;
206
207    use super::*;
208    use crate::test_support::{chain_csr, long_chain_csr, test_memory};
209
210    fn bfs(csr: &CsrIndex, labels: &[&str], max_depth: usize, max_visited: usize) -> Vec<String> {
211        let mut result = csr.traverse_bfs(
212            BfsParams {
213                start_nodes: &["a"],
214                label_filter: labels,
215                direction: Direction::Out,
216                max_depth,
217                max_visited,
218                frontier_bitmap: None,
219            },
220            None,
221        );
222        result.sort();
223        result
224    }
225
226    #[test]
227    fn a_label_set_reaches_both_labels() {
228        let csr = chain_csr();
229        assert_eq!(
230            bfs(&csr, &["KNOWS", "WORKS"], 1, DEFAULT_MAX_VISITED),
231            vec!["a", "b", "e"]
232        );
233    }
234
235    #[test]
236    fn an_unknown_label_in_a_set_adds_nothing() {
237        let csr = chain_csr();
238        assert_eq!(
239            bfs(&csr, &["KNOWS", "NOPE"], 3, DEFAULT_MAX_VISITED),
240            bfs(&csr, &["KNOWS"], 3, DEFAULT_MAX_VISITED)
241        );
242    }
243
244    #[test]
245    fn a_set_of_unknown_labels_returns_the_start_only() {
246        let csr = chain_csr();
247        assert_eq!(bfs(&csr, &["NOPE"], 3, DEFAULT_MAX_VISITED), vec!["a"]);
248    }
249
250    /// The same edges, inserted in two orders, give the same capped walk.
251    #[test]
252    fn a_capped_walk_over_a_set_ignores_insertion_order() {
253        let edges = [
254            ("a", "K", "z"),
255            ("a", "W", "m"),
256            ("a", "K", "b"),
257            ("a", "W", "c"),
258            ("a", "X", "a0"),
259        ];
260        let mut forward = CsrIndex::new(test_memory());
261        for (src, label, dst) in edges {
262            forward.add_edge(src, label, dst).unwrap();
263        }
264        let mut reverse = CsrIndex::new(test_memory());
265        for (src, label, dst) in edges.iter().rev() {
266            reverse.add_edge(src, label, dst).unwrap();
267        }
268        let forward_walk = bfs(&forward, &["K", "W"], 2, 3);
269        assert_eq!(forward_walk, vec!["a", "b", "c"]);
270        assert_eq!(bfs(&reverse, &["K", "W"], 2, 3), forward_walk);
271    }
272
273    /// `a` points at `z`, `m` and `b`, stored in that order. A cap of 3 leaves
274    /// room for two of them, and name order picks `b` and `m`.
275    #[test]
276    fn a_capped_depth_walk_admits_in_name_order() {
277        let mut csr = CsrIndex::new(test_memory());
278        csr.add_edge("a", "L", "z").unwrap();
279        csr.add_edge("a", "L", "m").unwrap();
280        csr.add_edge("a", "L", "b").unwrap();
281        let map: HashMap<String, u8> = csr
282            .traverse_bfs_with_depth(&["a"], &[], Direction::Out, 2, 3)
283            .into_iter()
284            .collect();
285        assert_eq!(map.len(), 3);
286        assert_eq!(map["a"], 0);
287        assert_eq!(map["b"], 1);
288        assert_eq!(map["m"], 1);
289    }
290
291    /// A walk deeper than 255 hops tags every node past 255 with `u8::MAX`.
292    #[test]
293    fn the_depth_tag_saturates_past_255() {
294        let mut csr = CsrIndex::new(test_memory());
295        for i in 0..270 {
296            csr.add_edge(&format!("n{i}"), "NEXT", &format!("n{}", i + 1))
297                .unwrap();
298        }
299        let map: HashMap<String, u8> = csr
300            .traverse_bfs_with_depth(&["n0"], &["NEXT"], Direction::Out, 300, DEFAULT_MAX_VISITED)
301            .into_iter()
302            .collect();
303        assert_eq!(map.len(), 271);
304        assert_eq!(map["n0"], 0);
305        assert_eq!(map["n200"], 200);
306        assert_eq!(map["n255"], 255);
307        assert_eq!(map["n256"], u8::MAX);
308        assert_eq!(map["n270"], u8::MAX);
309    }
310
311    #[test]
312    fn bfs_traversal() {
313        let csr = chain_csr();
314        let mut result = csr.traverse_bfs(
315            BfsParams {
316                start_nodes: &["a"],
317                label_filter: &["KNOWS"],
318                direction: Direction::Out,
319                max_depth: 2,
320                max_visited: DEFAULT_MAX_VISITED,
321                frontier_bitmap: None,
322            },
323            None,
324        );
325        result.sort();
326        assert_eq!(result, vec!["a", "b", "c"]);
327    }
328
329    #[test]
330    fn bfs_all_labels() {
331        let csr = chain_csr();
332        let mut result = csr.traverse_bfs(
333            BfsParams {
334                start_nodes: &["a"],
335                label_filter: &[],
336                direction: Direction::Out,
337                max_depth: 1,
338                max_visited: DEFAULT_MAX_VISITED,
339                frontier_bitmap: None,
340            },
341            None,
342        );
343        result.sort();
344        assert_eq!(result, vec!["a", "b", "e"]);
345    }
346
347    /// `a` points at `z`, `m` and `b`, stored in that order. A cap of 3 leaves
348    /// room for two of them, and name order picks `b` and `m`.
349    #[test]
350    fn a_capped_bfs_admits_each_level_in_name_order() {
351        let mut csr = CsrIndex::new(test_memory());
352        csr.add_edge("a", "L", "z").unwrap();
353        csr.add_edge("a", "L", "m").unwrap();
354        csr.add_edge("a", "L", "b").unwrap();
355        let mut result = csr.traverse_bfs(
356            BfsParams {
357                start_nodes: &["a"],
358                label_filter: &[],
359                direction: Direction::Out,
360                max_depth: 2,
361                max_visited: 3,
362                frontier_bitmap: None,
363            },
364            None,
365        );
366        result.sort();
367        assert_eq!(result, vec!["a", "b", "m"]);
368    }
369
370    #[test]
371    fn bfs_cycle() {
372        let mut csr = CsrIndex::new(test_memory());
373        csr.add_edge("a", "L", "b").unwrap();
374        csr.add_edge("b", "L", "c").unwrap();
375        csr.add_edge("c", "L", "a").unwrap();
376        let mut result = csr.traverse_bfs(
377            BfsParams {
378                start_nodes: &["a"],
379                label_filter: &[],
380                direction: Direction::Out,
381                max_depth: 10,
382                max_visited: DEFAULT_MAX_VISITED,
383                frontier_bitmap: None,
384            },
385            None,
386        );
387        result.sort();
388        assert_eq!(result, vec!["a", "b", "c"]);
389    }
390
391    #[test]
392    fn bfs_with_depth() {
393        let csr = chain_csr();
394        let result =
395            csr.traverse_bfs_with_depth(&["a"], &["KNOWS"], Direction::Out, 3, DEFAULT_MAX_VISITED);
396        let map: HashMap<String, u8> = result.into_iter().collect();
397        assert_eq!(map["a"], 0);
398        assert_eq!(map["b"], 1);
399        assert_eq!(map["c"], 2);
400        assert_eq!(map["d"], 3);
401    }
402
403    #[test]
404    fn large_graph_bfs() {
405        let csr = long_chain_csr();
406        let result = csr.traverse_bfs(
407            BfsParams {
408                start_nodes: &["n0"],
409                label_filter: &["NEXT"],
410                direction: Direction::Out,
411                max_depth: 100,
412                max_visited: DEFAULT_MAX_VISITED,
413                frontier_bitmap: None,
414            },
415            None,
416        );
417        assert_eq!(result.len(), 101);
418    }
419
420    /// BFS with a frontier bitmap that includes only "b". Starting from "a",
421    /// "b" is reachable but "c" is blocked (its surrogate is not in the bitmap).
422    #[test]
423    fn bfs_frontier_bitmap_excludes_non_members() {
424        use nodedb_types::{Surrogate, SurrogateBitmap};
425
426        let mut csr = chain_csr();
427        // Assign surrogates: b=10, c=20, d=30. "a" and "e" get no surrogate.
428        csr.set_node_surrogate("b", Surrogate::new(10));
429        csr.set_node_surrogate("c", Surrogate::new(20));
430        csr.set_node_surrogate("d", Surrogate::new(30));
431
432        // Bitmap contains only "b" (surrogate 10).
433        let mut bm = SurrogateBitmap::new();
434        bm.insert(Surrogate::new(10));
435
436        let mut result = csr.traverse_bfs(
437            BfsParams {
438                start_nodes: &["a"],
439                label_filter: &["KNOWS"],
440                direction: Direction::Out,
441                max_depth: 10,
442                max_visited: DEFAULT_MAX_VISITED,
443                frontier_bitmap: Some(&bm),
444            },
445            None,
446        );
447        result.sort();
448        // "a" is the start node (not gated). "b" passes the bitmap. "c" is
449        // excluded (surrogate 20 not in bitmap) so traversal stops there.
450        assert_eq!(result, vec!["a", "b"]);
451    }
452}