Skip to main content

urna_runtime/graph/
traverse.rs

1//! bounded breadth-first traversal over `CsrIndex`. pure integer work: it
2//! GENERATES candidate chunk ordinals and never scores. the caller hands the
3//! deduped candidate set to the mandatory exact-cosine rerank
4//! (`score_subset`), so a graph edge can never leak into a returned score.
5//!
6//! the visited set is a GENERATIONAL buffer (a `Vec<u32>` of stamps), not a
7//! per-query `HashSet`: each call bumps a generation counter and marks a node
8//! visited by writing the current generation into its slot. that makes
9//! "already seen?" an O(1) integer compare with zero per-query allocation
10//! beyond the frontier vectors, and the buffer is reused across hops.
11
12use super::csr::CsrIndex;
13
14/// reusable traversal scratch. allocate once per `CsrIndex`, reuse across
15/// queries; `bounded_bfs` bumps the generation so stale stamps read as
16/// unvisited without clearing the buffer.
17pub struct Traversal {
18    /// per-node last-seen generation stamp; `stamp[node] == cur_gen` means
19    /// visited this query. 0 is the "never visited" sentinel, so the first
20    /// real generation is 1.
21    stamp: Vec<u32>,
22    cur_gen: u32,
23}
24
25impl Traversal {
26    pub fn new(n_nodes: usize) -> Self {
27        Self {
28            stamp: vec![0u32; n_nodes],
29            cur_gen: 0,
30        }
31    }
32
33    /// bounded bfs from `seeds`, expanding up to `hops` levels of the csr and
34    /// capping the total returned candidate count at `max_frontier`. returns
35    /// a deduped `Vec<usize>` of chunk ordinals (seeds first, in arrival
36    /// order). pure integer, no scoring.
37    pub fn bounded_bfs(
38        &mut self,
39        csr: &CsrIndex,
40        seeds: &[usize],
41        hops: usize,
42        max_frontier: usize,
43    ) -> Vec<usize> {
44        // bump the generation; on the (astronomically unlikely) wraparound,
45        // reset every stamp so a stale value cannot read as the new gen.
46        self.cur_gen = self.cur_gen.wrapping_add(1);
47        if self.cur_gen == 0 {
48            for s in self.stamp.iter_mut() {
49                *s = 0;
50            }
51            self.cur_gen = 1;
52        }
53        let cur_gen = self.cur_gen;
54        let n = csr.n_nodes();
55
56        let mut out: Vec<usize> = Vec::new();
57        let mut frontier: Vec<usize> = Vec::new();
58
59        for &s in seeds {
60            if out.len() >= max_frontier {
61                break;
62            }
63            if s >= n || s >= self.stamp.len() || self.stamp[s] == cur_gen {
64                continue;
65            }
66            self.stamp[s] = cur_gen;
67            out.push(s);
68            frontier.push(s);
69        }
70
71        // expand `hops` levels; each level walks the current frontier's
72        // neighbors, marking unseen ones, until the cap is hit.
73        for _ in 0..hops {
74            if out.len() >= max_frontier || frontier.is_empty() {
75                break;
76            }
77            let mut next: Vec<usize> = Vec::new();
78            'level: for &node in &frontier {
79                for &nbr in csr.neighbors(node) {
80                    if out.len() >= max_frontier {
81                        break 'level;
82                    }
83                    let nbr = nbr as usize;
84                    if nbr >= n || nbr >= self.stamp.len() || self.stamp[nbr] == cur_gen {
85                        continue;
86                    }
87                    self.stamp[nbr] = cur_gen;
88                    out.push(nbr);
89                    next.push(nbr);
90                }
91            }
92            frontier = next;
93        }
94        out
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use urna_format::sections::graph::{EDGE_TYPE_NEXT_CHUNK, Edge, encode_graph_adjacency};
102
103    /// a simple line graph 0->1->2->...->n-1 (NEXT_CHUNK forward) plus the
104    /// reverse so bfs can walk both directions.
105    fn line_csr(n: usize) -> CsrIndex {
106        let mut edges = Vec::new();
107        for i in 0..n.saturating_sub(1) {
108            edges.push(Edge {
109                src: i as u32,
110                dst: (i + 1) as u32,
111                edge_type: EDGE_TYPE_NEXT_CHUNK,
112            });
113            edges.push(Edge {
114                src: (i + 1) as u32,
115                dst: i as u32,
116                edge_type: EDGE_TYPE_NEXT_CHUNK,
117            });
118        }
119        let payload = encode_graph_adjacency(n, &edges).unwrap();
120        CsrIndex::from_bytes(&payload, n).unwrap()
121    }
122
123    #[test]
124    fn one_hop_collects_immediate_neighbors() {
125        let csr = line_csr(10);
126        let mut t = Traversal::new(10);
127        // seed 5, 1 hop -> {5, 4, 6}.
128        let mut got = t.bounded_bfs(&csr, &[5], 1, 100);
129        got.sort_unstable();
130        assert_eq!(got, vec![4, 5, 6]);
131    }
132
133    #[test]
134    fn hops_bound_the_radius() {
135        let csr = line_csr(20);
136        let mut t = Traversal::new(20);
137        // seed 10, 2 hops -> {10, 9, 11, 8, 12}.
138        let mut got = t.bounded_bfs(&csr, &[10], 2, 100);
139        got.sort_unstable();
140        assert_eq!(got, vec![8, 9, 10, 11, 12]);
141    }
142
143    #[test]
144    fn max_frontier_caps_the_candidate_count() {
145        let csr = line_csr(50);
146        let mut t = Traversal::new(50);
147        let got = t.bounded_bfs(&csr, &[25], 10, 5);
148        assert_eq!(got.len(), 5, "max_frontier must cap the result");
149    }
150
151    #[test]
152    fn dedup_and_seeds_come_first() {
153        let csr = line_csr(10);
154        let mut t = Traversal::new(10);
155        // overlapping seeds + neighbors must dedup; seeds preserved up front.
156        let got = t.bounded_bfs(&csr, &[3, 3, 4], 1, 100);
157        let mut sorted = got.clone();
158        sorted.sort_unstable();
159        sorted.dedup();
160        assert_eq!(sorted.len(), got.len(), "result must be deduped");
161        assert_eq!(&got[..2], &[3, 4], "seeds come first, in order");
162    }
163
164    #[test]
165    fn generational_buffer_reuse_is_correct() {
166        let csr = line_csr(10);
167        let mut t = Traversal::new(10);
168        // two queries reuse the same buffer; the second must not see stale
169        // stamps from the first.
170        let q1 = t.bounded_bfs(&csr, &[1], 1, 100);
171        let q2 = t.bounded_bfs(&csr, &[8], 1, 100);
172        let mut s1 = q1.clone();
173        s1.sort_unstable();
174        let mut s2 = q2.clone();
175        s2.sort_unstable();
176        assert_eq!(s1, vec![0, 1, 2]);
177        assert_eq!(s2, vec![7, 8, 9]);
178    }
179
180    #[test]
181    fn zero_hops_returns_only_seeds() {
182        let csr = line_csr(10);
183        let mut t = Traversal::new(10);
184        let got = t.bounded_bfs(&csr, &[2, 5], 0, 100);
185        assert_eq!(got, vec![2, 5]);
186    }
187
188    #[test]
189    fn out_of_range_seed_is_ignored() {
190        let csr = line_csr(5);
191        let mut t = Traversal::new(5);
192        let got = t.bounded_bfs(&csr, &[99, 2], 1, 100);
193        let mut sorted = got.clone();
194        sorted.sort_unstable();
195        assert_eq!(sorted, vec![1, 2, 3]);
196    }
197}