Skip to main content

nodedb_graph/
traversal_surrogate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Surrogate-keyed, collection-scoped BFS.
4//!
5//! The string-keyed traversals materialize a node name for every visited node —
6//! and, through `neighbors_in_collection`, one for every *edge* they walk. That
7//! is the wrong currency for cross-engine work. Vector, FTS and spatial all
8//! produce surrogates, so a graph result keyed on names has to be translated
9//! back before it can meet them, which is exactly the per-query translation the
10//! global surrogate exists to remove.
11//!
12//! This walk stays in the surrogate domain from end to end: it seeds from
13//! surrogates, expands over the dense `u32` CSR ids, and returns a
14//! [`SurrogateBitmap`] plus per-surrogate hop distances. The bitmap intersects
15//! directly with any other engine's. Node names are resolved once, by the
16//! caller, and only for the rows that survive fusion.
17
18use std::collections::HashSet;
19
20use nodedb_types::{Surrogate, SurrogateBitmap};
21
22use crate::csr::{CsrIndex, Direction};
23
24/// Inputs to [`CsrIndex::traverse_surrogates_in_collection`].
25pub struct SurrogateBfsParams<'a> {
26    /// Seed nodes as CSR-local ids. Callers holding surrogates convert with
27    /// [`CsrIndex::local_id_for_surrogate`]; callers holding names use
28    /// [`CsrIndex::local_id_for_node`]. Taking local ids keeps the walk
29    /// independent of which currency the caller happens to have, and keeps a
30    /// name-seeded walk working on an index whose surrogate bindings have not
31    /// been populated.
32    pub seeds: &'a [u32],
33    /// Edge labels to expand over. Empty keeps every edge. Otherwise an edge
34    /// whose label is any listed label passes.
35    pub label_filter: &'a [&'a str],
36    pub direction: Direction,
37    /// Hops from the seeds. `0` returns just the addressable seeds.
38    pub max_depth: usize,
39    /// Cap on visited nodes. Reaching it sets [`SurrogateHops::truncated`].
40    pub max_visited: usize,
41    /// Only edges inserted under this collection are traversed. A collection
42    /// with no edges here yields an empty result and never falls back to the
43    /// merged, cross-collection view.
44    pub collection: &'a str,
45}
46
47/// Outcome of a surrogate-keyed BFS.
48///
49/// The two result fields answer different questions and must not be conflated.
50/// `distances` is the *traversal* — every node the walk reached, always
51/// complete. `reached` is the *intersectable* subset: only nodes that carry a
52/// surrogate can meet another engine's candidate set. Gating `distances` on the
53/// surrogate binding would silently shrink a graph answer whenever bindings are
54/// missing, which is a storage-state question and has nothing to do with what
55/// the graph actually contains.
56pub struct SurrogateHops {
57    /// Reached nodes that carry a surrogate, ready to intersect with another
58    /// engine's candidate set. A subset of `distances`.
59    pub reached: SurrogateBitmap,
60    /// `(CSR-local node id, hop distance from the nearest seed)` in discovery
61    /// order — the complete reached set, so callers that rank by proximity need
62    /// no second pass over the graph. Local ids resolve to names through the
63    /// same index, which is why this stays complete even with no surrogates
64    /// bound at all.
65    pub distances: Vec<(u32, usize)>,
66    /// `max_visited` cut the walk short — the result is a prefix of the
67    /// reachable set, not the whole of it.
68    pub truncated: bool,
69    /// Reached nodes carrying no surrogate. They are traversed and reported in
70    /// `distances` as normal; what they cannot do is appear in `reached`, since
71    /// a node with no global identity has nothing to intersect against.
72    /// Non-zero means cross-engine fusion sees less than the traversal did, and
73    /// callers surface it rather than quietly narrowing the answer.
74    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    /// Record a newly reached node in the traversal, and in the intersectable
88    /// set when it carries a surrogate.
89    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    /// BFS from surrogate seeds over one collection's edges, returning
102    /// surrogates and hop distances.
103    ///
104    /// The frontier is the dense `u32` CSR id, exactly as in the durable
105    /// string-keyed path; the difference is that nothing is ever converted to a
106    /// name. Nodes without a surrogate stay traversable — they just cannot be
107    /// reported (see [`SurrogateHops::unaddressable`]).
108    ///
109    /// The walk admits nodes in `(depth, node name)` order: each level's new
110    /// nodes in name order. When `max_visited` cuts the walk, the admitted set
111    /// is the same however the edges are stored, which lets a cluster
112    /// coordinator walking the same edges across partitions admit the same
113    /// nodes.
114    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        // A set of unknown labels matches no edge. Falling through to
132        // unfiltered expansion would return other labels' neighbourhoods under
133        // the caller's labels.
134        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        // Level by level. Each level's new nodes are admitted in node-name
156        // order, so under the visit cap the admitted set is the same whatever
157        // order the edges are stored in, here or split across partitions.
158        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    /// Order CSR-local ids by node name.
199    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    /// Record the addressable seeds and nothing else. Used when no requested
204    /// edge label exists in this partition, so no expansion is possible
205    /// but the seeds themselves are still legitimately reachable at depth 0.
206    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    /// `a -knows-> b -knows-> c` in `people`, plus `a -knows-> x` in `other`.
225    /// Every node gets a surrogate except `x`, which deliberately has none.
226    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    /// Seed by name, the way a name-seeded read does.
243    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    /// A surrogate-holding caller enters through the same door, without ever
280    /// materializing a name.
281    #[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    /// The whole point of the surrogate domain: the result meets another
294    /// engine's candidate set with an intersection and no name lookup.
295    #[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    /// Edges of another collection are not walked, even though both live in
311    /// one partition under a shared node space.
312    #[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        // `x` is only reachable through the `other` collection, and carries no
318        // surrogate — so a leak would show up as an unaddressable node.
319        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    /// A node with no surrogate is still traversed and still reported by name —
327    /// only its cross-engine addressability is missing, and that is counted.
328    #[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    /// An unknown label must not fall through to unfiltered expansion — that
364    /// would answer with another label's neighbourhood.
365    #[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    /// `a -knows-> b -likes-> c -hates-> d` in `people`.
377    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    /// Under the cap, a level admits its nodes in name order, whatever order
432    /// their edges were inserted in.
433    #[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}