1use std::collections::HashSet;
19
20use nodedb_types::{Surrogate, SurrogateBitmap};
21
22use crate::csr::{CsrIndex, Direction};
23
24pub struct SurrogateBfsParams<'a> {
26 pub seeds: &'a [u32],
33 pub label_filter: &'a [&'a str],
36 pub direction: Direction,
37 pub max_depth: usize,
39 pub max_visited: usize,
41 pub collection: &'a str,
45}
46
47pub struct SurrogateHops {
57 pub reached: SurrogateBitmap,
60 pub distances: Vec<(u32, usize)>,
66 pub truncated: bool,
69 pub unaddressable: usize,
75}
76
77impl SurrogateHops {
78 fn empty() -> Self {
79 Self {
80 reached: SurrogateBitmap::new(),
81 distances: Vec::new(),
82 truncated: false,
83 unaddressable: 0,
84 }
85 }
86
87 fn record(&mut self, csr: &CsrIndex, local: u32, depth: usize) {
90 self.distances.push((local, depth));
91 let raw = csr.node_surrogate_raw(local);
92 if raw == 0 {
93 self.unaddressable += 1;
94 } else {
95 self.reached.insert(Surrogate::new(raw));
96 }
97 }
98}
99
100impl CsrIndex {
101 pub fn traverse_surrogates_in_collection(
115 &self,
116 params: SurrogateBfsParams<'_>,
117 ) -> SurrogateHops {
118 let SurrogateBfsParams {
119 seeds,
120 label_filter,
121 direction,
122 max_depth,
123 max_visited,
124 collection,
125 } = params;
126
127 let mut hops = SurrogateHops::empty();
128 let Some(collection_id) = self.collection_id(collection) else {
129 return hops;
130 };
131 let labels = self.label_filter(label_filter);
135 if labels.keeps_none() {
136 self.seed_only(seeds, &mut hops);
137 return hops;
138 }
139
140 let mut visited: HashSet<u32> = HashSet::with_capacity(max_visited.min(1024));
141 let mut frontier: Vec<u32> = Vec::new();
142 for &local in seeds {
143 if self.is_local_node(local) && visited.insert(local) {
144 frontier.push(local);
145 }
146 }
147 self.sort_by_name(&mut frontier);
148 for &local in &frontier {
149 hops.record(self, local, 0);
150 }
151
152 let want_out = matches!(direction, Direction::Out | Direction::Both);
153 let want_in = matches!(direction, Direction::In | Direction::Both);
154
155 for depth in 0..max_depth {
159 if frontier.is_empty() {
160 break;
161 }
162 let mut candidates: Vec<u32> = Vec::new();
163 let mut offered: HashSet<u32> = HashSet::new();
164 for &node in &frontier {
165 self.record_access(node);
166 let mut neighbors: Vec<(u32, u32)> = Vec::new();
167 if want_out {
168 neighbors.extend(self.iter_out_edges_raw_in(node, collection_id));
169 }
170 if want_in {
171 neighbors.extend(self.iter_in_edges_raw_in(node, collection_id));
172 }
173 for (lid, other) in neighbors {
174 if !labels.keeps(lid) || visited.contains(&other) || !offered.insert(other) {
175 continue;
176 }
177 candidates.push(other);
178 }
179 }
180 self.sort_by_name(&mut candidates);
181 let mut next: Vec<u32> = Vec::with_capacity(candidates.len());
182 for other in candidates {
183 if visited.len() >= max_visited {
184 hops.truncated = true;
185 return hops;
186 }
187 visited.insert(other);
188 hops.record(self, other, depth + 1);
189 self.prefetch_node(other);
190 next.push(other);
191 }
192 frontier = next;
193 }
194
195 hops
196 }
197
198 pub(crate) fn sort_by_name(&self, nodes: &mut [u32]) {
200 nodes.sort_by(|a, b| self.node_name_checked(*a).cmp(&self.node_name_checked(*b)));
201 }
202
203 fn seed_only(&self, seeds: &[u32], hops: &mut SurrogateHops) {
207 let mut seen: HashSet<u32> = HashSet::new();
208 for &local in seeds {
209 if !self.is_local_node(local) {
210 continue;
211 }
212 if seen.insert(local) {
213 hops.record(self, local, 0);
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::test_support::test_memory;
223
224 fn seeded_csr() -> CsrIndex {
227 let mut csr = CsrIndex::new(test_memory());
228 for (src, dst, coll) in [
229 ("a", "b", "people"),
230 ("b", "c", "people"),
231 ("a", "x", "other"),
232 ] {
233 csr.add_edge_in_collection(src, "knows", dst, coll)
234 .unwrap_or_else(|e| panic!("seed edge {src}->{dst} in {coll}: {e}"));
235 }
236 for (node, sur) in [("a", 10u32), ("b", 20), ("c", 30)] {
237 csr.set_node_surrogate(node, Surrogate::new(sur));
238 }
239 csr
240 }
241
242 fn local(csr: &CsrIndex, node: &str) -> u32 {
244 csr.local_id_for_node(node)
245 .unwrap_or_else(|| panic!("node {node} not interned"))
246 }
247
248 fn params<'a>(seeds: &'a [u32], collection: &'a str) -> SurrogateBfsParams<'a> {
249 SurrogateBfsParams {
250 seeds,
251 label_filter: &[],
252 direction: Direction::Out,
253 max_depth: 5,
254 max_visited: 1000,
255 collection,
256 }
257 }
258
259 #[test]
260 fn walk_returns_surrogates_and_hop_distances() {
261 let csr = seeded_csr();
262 let seeds = [local(&csr, "a")];
263 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "people"));
264
265 assert!(hops.reached.contains(Surrogate::new(20)));
266 assert!(hops.reached.contains(Surrogate::new(30)));
267 let depth_of = |node: &str| {
268 let id = local(&csr, node);
269 hops.distances
270 .iter()
271 .find(|(l, _)| *l == id)
272 .map(|(_, d)| *d)
273 };
274 assert_eq!(depth_of("a"), Some(0), "seed sits at depth 0");
275 assert_eq!(depth_of("b"), Some(1));
276 assert_eq!(depth_of("c"), Some(2));
277 }
278
279 #[test]
282 fn a_surrogate_seed_resolves_to_the_same_walk() {
283 let csr = seeded_csr();
284 let seeds = [csr
285 .local_id_for_surrogate(Surrogate::new(10))
286 .expect("surrogate 10 is bound to `a`")];
287 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "people"));
288
289 assert!(hops.reached.contains(Surrogate::new(20)));
290 assert!(hops.reached.contains(Surrogate::new(30)));
291 }
292
293 #[test]
296 fn reached_set_intersects_another_engines_bitmap() {
297 let csr = seeded_csr();
298 let seeds = [local(&csr, "a")];
299 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "people"));
300
301 let mut vector_hits = SurrogateBitmap::new();
302 vector_hits.insert(Surrogate::new(30));
303 vector_hits.insert(Surrogate::new(99));
304
305 let both = hops.reached.intersect(&vector_hits);
306 assert_eq!(both.len(), 1);
307 assert!(both.contains(Surrogate::new(30)));
308 }
309
310 #[test]
313 fn walk_does_not_cross_a_collection_boundary() {
314 let csr = seeded_csr();
315 let seeds = [local(&csr, "a")];
316 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "people"));
317 assert_eq!(hops.unaddressable, 0, "crossed into another collection");
320 assert!(
321 !hops.distances.iter().any(|&(l, _)| l == local(&csr, "x")),
322 "`x` belongs to the `other` collection"
323 );
324 }
325
326 #[test]
329 fn surrogateless_node_is_traversed_and_counted_not_dropped() {
330 let csr = seeded_csr();
331 let seeds = [local(&csr, "a")];
332 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "other"));
333 assert_eq!(hops.unaddressable, 1, "`x` has no surrogate to intersect");
334 assert!(
335 hops.distances.iter().any(|&(l, _)| l == local(&csr, "x")),
336 "the traversal itself must stay complete"
337 );
338 assert!(!hops.reached.contains(Surrogate::new(0)));
339 }
340
341 #[test]
342 fn seed_that_is_no_node_of_this_partition_is_skipped_not_fatal() {
343 let csr = seeded_csr();
344 let seeds = [local(&csr, "a"), 4242];
345 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "people"));
346 assert!(hops.reached.contains(Surrogate::new(10)));
347 assert!(
348 !hops.distances.iter().any(|&(l, _)| l == 4242),
349 "an out-of-range seed must not enter the result"
350 );
351 assert_eq!(hops.unaddressable, 0, "it is absent, not unaddressable");
352 }
353
354 #[test]
355 fn unknown_collection_yields_empty_not_the_merged_view() {
356 let csr = seeded_csr();
357 let seeds = [local(&csr, "a")];
358 let hops = csr.traverse_surrogates_in_collection(params(&seeds, "absent"));
359 assert!(hops.reached.is_empty());
360 assert!(hops.distances.is_empty());
361 }
362
363 #[test]
366 fn unknown_label_expands_nothing_but_keeps_the_seed() {
367 let csr = seeded_csr();
368 let seeds = [local(&csr, "a")];
369 let mut p = params(&seeds, "people");
370 p.label_filter = &["never_inserted"];
371 let hops = csr.traverse_surrogates_in_collection(p);
372 assert!(hops.reached.contains(Surrogate::new(10)));
373 assert!(!hops.reached.contains(Surrogate::new(20)));
374 }
375
376 fn three_label_csr() -> CsrIndex {
378 let mut csr = CsrIndex::new(test_memory());
379 for (src, label, dst) in [
380 ("a", "knows", "b"),
381 ("b", "likes", "c"),
382 ("c", "hates", "d"),
383 ] {
384 csr.add_edge_in_collection(src, label, dst, "people")
385 .unwrap_or_else(|e| panic!("seed edge {src}->{dst}: {e}"));
386 }
387 csr
388 }
389
390 fn reached_names<'a>(csr: &'a CsrIndex, hops: &SurrogateHops) -> Vec<&'a str> {
391 let mut names: Vec<&str> = hops
392 .distances
393 .iter()
394 .filter_map(|&(l, _)| csr.node_name_checked(l))
395 .collect();
396 names.sort_unstable();
397 names
398 }
399
400 #[test]
401 fn a_label_set_expands_over_every_listed_label() {
402 let csr = three_label_csr();
403 let seeds = [local(&csr, "a")];
404 let mut p = params(&seeds, "people");
405 p.label_filter = &["knows", "likes"];
406 let hops = csr.traverse_surrogates_in_collection(p);
407 assert_eq!(reached_names(&csr, &hops), vec!["a", "b", "c"]);
408 }
409
410 #[test]
411 fn an_unknown_label_in_a_set_adds_nothing() {
412 let csr = three_label_csr();
413 let seeds = [local(&csr, "a")];
414 let mut p = params(&seeds, "people");
415 p.label_filter = &["knows", "never_inserted"];
416 let hops = csr.traverse_surrogates_in_collection(p);
417 assert_eq!(reached_names(&csr, &hops), vec!["a", "b"]);
418 }
419
420 #[test]
421 fn max_depth_zero_returns_only_the_seeds() {
422 let csr = seeded_csr();
423 let seeds = [local(&csr, "a")];
424 let mut p = params(&seeds, "people");
425 p.max_depth = 0;
426 let hops = csr.traverse_surrogates_in_collection(p);
427 assert_eq!(hops.reached.len(), 1);
428 assert!(hops.reached.contains(Surrogate::new(10)));
429 }
430
431 #[test]
434 fn a_capped_level_admits_nodes_in_name_order() {
435 let mut csr = CsrIndex::new(test_memory());
436 for dst in ["z", "m", "b"] {
437 csr.add_edge_in_collection("a", "knows", dst, "people")
438 .unwrap_or_else(|e| panic!("seed edge a->{dst}: {e}"));
439 }
440 let seeds = [local(&csr, "a")];
441 let mut p = params(&seeds, "people");
442 p.max_visited = 3;
443 let hops = csr.traverse_surrogates_in_collection(p);
444 assert!(hops.truncated);
445 let admitted: Vec<&str> = hops
446 .distances
447 .iter()
448 .filter_map(|&(l, _)| csr.node_name_checked(l))
449 .collect();
450 assert_eq!(admitted, vec!["a", "b", "m"]);
451 }
452
453 #[test]
454 fn hitting_max_visited_reports_truncation() {
455 let csr = seeded_csr();
456 let seeds = [local(&csr, "a")];
457 let mut p = params(&seeds, "people");
458 p.max_visited = 2;
459 let hops = csr.traverse_surrogates_in_collection(p);
460 assert!(hops.truncated, "a cut-short walk must say so");
461 }
462}