Skip to main content

tsift_core/
store.rs

1use anyhow::Result;
2use lazily::{Context as LazyContext, SlotHandle};
3use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
4
5use crate::types::*;
6
7pub(crate) fn graph_node_matches_filters(
8    node: &GraphNode,
9    filters: &[GraphPropertyFilter],
10) -> bool {
11    filters
12        .iter()
13        .all(|filter| node.properties.get(&filter.key) == Some(&filter.value))
14}
15
16#[derive(Debug, Clone, Eq, PartialEq)]
17pub(crate) struct ScoredQueueEntry {
18    pub id: String,
19    pub depth: usize,
20    pub score: i64,
21}
22
23impl Ord for ScoredQueueEntry {
24    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
25        self.score
26            .cmp(&other.score)
27            .then_with(|| other.depth.cmp(&self.depth))
28            .then_with(|| self.id.cmp(&other.id))
29    }
30}
31
32impl PartialOrd for ScoredQueueEntry {
33    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
34        Some(self.cmp(other))
35    }
36}
37
38pub(crate) fn compute_neighborhood_score(
39    strategy: &NeighborhoodScoring,
40    depth: usize,
41    edge_kind: &str,
42    _node: &GraphNode,
43    degree_map: &BTreeMap<String, usize>,
44) -> i64 {
45    match strategy {
46        NeighborhoodScoring::BreadthFirst => {
47            (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0)
48        }
49        NeighborhoodScoring::EdgeKindWeighted => {
50            let depth_score = (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0);
51            let kind_score = edge_kind_weighted_score(edge_kind);
52            depth_score.saturating_add(kind_score)
53        }
54        NeighborhoodScoring::DegreeWeighted => {
55            let depth_score = (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0);
56            let degree = degree_map.values().copied().max().unwrap_or(1) as i64;
57            let degree_bonus = if degree <= 3 {
58                20
59            } else if degree <= 10 {
60                10
61            } else {
62                0
63            };
64            depth_score.saturating_add(degree_bonus)
65        }
66    }
67}
68
69#[derive(Clone)]
70pub(crate) struct NeighborhoodLayerState {
71    pub nodes: BTreeMap<String, GraphNode>,
72    pub edges: BTreeMap<(String, String, String), GraphEdge>,
73    pub frontier: Vec<String>,
74}
75
76#[derive(Clone)]
77pub(crate) struct NeighborhoodFetchedLayer {
78    pub edges: Vec<GraphEdge>,
79    pub nodes: Vec<GraphNode>,
80}
81
82#[derive(Clone)]
83pub(crate) struct RankedNeighborhoodLayerState {
84    pub nodes: BTreeMap<String, GraphNode>,
85    pub edges: BTreeMap<(String, String, String), GraphEdge>,
86    pub queue: BinaryHeap<ScoredQueueEntry>,
87    pub seen: BTreeSet<String>,
88    pub pruned_count: usize,
89    pub total_discovered: usize,
90    pub degree_map: BTreeMap<String, usize>,
91}
92
93#[derive(Clone)]
94pub(crate) struct RankedNeighborhoodFetchedExpansion {
95    pub edges: Vec<GraphEdge>,
96    pub neighbor_nodes: BTreeMap<String, GraphNode>,
97}
98
99pub(crate) fn graph_cache_error(message: String) -> anyhow::Error {
100    anyhow::anyhow!("{message}")
101}
102
103pub(crate) fn fetch_neighborhood_layer<S: GraphStore + ?Sized>(
104    store: &S,
105    frontier: &[String],
106    known_nodes: &BTreeMap<String, GraphNode>,
107    kind: Option<&str>,
108) -> Result<NeighborhoodFetchedLayer> {
109    let mut edges = Vec::new();
110    let mut missing_ids = Vec::new();
111    for current in frontier {
112        let outgoing = store.outgoing_edges(current, kind)?;
113        for edge in outgoing {
114            if !known_nodes.contains_key(&edge.to_id) {
115                missing_ids.push(edge.to_id.clone());
116            }
117            edges.push(edge);
118        }
119    }
120    missing_ids.sort();
121    missing_ids.dedup();
122    let mut nodes = Vec::new();
123    for id in missing_ids {
124        if !known_nodes.contains_key(&id)
125            && let Some(node) = store.node(&id)?
126        {
127            nodes.push(node);
128        }
129    }
130    Ok(NeighborhoodFetchedLayer { edges, nodes })
131}
132
133pub(crate) fn fetch_ranked_neighborhood_expansion<S: GraphStore + ?Sized>(
134    store: &S,
135    entry: &ScoredQueueEntry,
136    state: &RankedNeighborhoodLayerState,
137    kind: Option<&str>,
138) -> Result<RankedNeighborhoodFetchedExpansion> {
139    let edges = store.outgoing_edges(&entry.id, kind)?;
140    let mut neighbor_nodes = BTreeMap::new();
141    for edge in &edges {
142        if state.seen.contains(&edge.to_id) {
143            continue;
144        }
145        if let Some(node) = store.node(&edge.to_id)? {
146            neighbor_nodes.insert(edge.to_id.clone(), node);
147        }
148    }
149    Ok(RankedNeighborhoodFetchedExpansion {
150        edges,
151        neighbor_nodes,
152    })
153}
154
155pub(crate) fn edge_kind_weighted_score(edge_kind: &str) -> i64 {
156    match edge_kind {
157        "semantic_relation" => 34,
158        "mentions_entity" | "mentions_concept" | "tagged_entity" | "tagged_concept"
159        | "related_concept" => 28,
160        "mentions" => 22,
161        "calls" => 20,
162        "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
163        "defines" | "contains" | "belongs_to" => 12,
164        kind if kind.contains("community") => 20,
165        kind if kind.contains("semantic")
166            || kind.contains("concept")
167            || kind.contains("entity") =>
168        {
169            24
170        }
171        _ => 8,
172    }
173}
174
175pub(crate) fn graph_edge_matches_filters(
176    edge: &GraphEdge,
177    filters: &[GraphPropertyFilter],
178) -> bool {
179    filters
180        .iter()
181        .all(|filter| edge.properties.get(&filter.key) == Some(&filter.value))
182}
183
184pub fn apply_graph_query_page(
185    mut nodes: Vec<GraphNode>,
186    mut edges: Vec<GraphEdge>,
187    options: GraphQueryOptions,
188    mut diagnostics: Vec<String>,
189) -> GraphPagedSubgraph {
190    nodes.sort_by(|left, right| left.id.cmp(&right.id));
191    edges.sort_by(|left, right| {
192        left.from_id
193            .cmp(&right.from_id)
194            .then(left.kind.cmp(&right.kind))
195            .then(left.to_id.cmp(&right.to_id))
196    });
197
198    let before_filter = nodes.len();
199    if !options.property_filters.is_empty() {
200        nodes.retain(|node| graph_node_matches_filters(node, &options.property_filters));
201    }
202    let after_filter = nodes.len();
203
204    if let Some(cursor) = &options.cursor {
205        nodes.retain(|node| node.id > *cursor);
206    }
207
208    let before_limit = nodes.len();
209    let mut next_cursor = None;
210    if let Some(limit) = options.limit
211        && nodes.len() > limit
212    {
213        next_cursor = nodes
214            .get(limit.saturating_sub(1))
215            .map(|node| node.id.clone());
216        nodes.truncate(limit);
217    }
218
219    let node_ids = nodes
220        .iter()
221        .map(|node| node.id.as_str())
222        .collect::<BTreeSet<_>>();
223    edges.retain(|edge| {
224        node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
225    });
226
227    if after_filter != before_filter {
228        diagnostics.push(format!(
229            "property filters removed {} node(s)",
230            before_filter.saturating_sub(after_filter)
231        ));
232    }
233    if options.cursor.is_some() {
234        diagnostics.push("cursor is exclusive and ordered by node id".to_string());
235    }
236    if next_cursor.is_some() {
237        diagnostics.push(
238            "result was truncated; pass page.next_cursor as --cursor for the next page".to_string(),
239        );
240    }
241
242    GraphPagedSubgraph {
243        page: GraphQueryPage {
244            cursor: options.cursor,
245            limit: options.limit,
246            next_cursor,
247            returned_nodes: nodes.len(),
248            returned_edges: edges.len(),
249            truncated: options.limit.is_some_and(|limit| before_limit > limit),
250            diagnostics,
251        },
252        nodes,
253        edges,
254    }
255}
256
257pub fn apply_graph_edge_query_page(
258    mut edges: Vec<GraphEdge>,
259    options: GraphQueryOptions,
260    mut diagnostics: Vec<String>,
261) -> GraphPagedSubgraph {
262    edges.sort_by_key(graph_edge_id);
263
264    let before_filter = edges.len();
265    if !options.property_filters.is_empty() {
266        edges.retain(|edge| graph_edge_matches_filters(edge, &options.property_filters));
267    }
268    let after_filter = edges.len();
269
270    if let Some(cursor) = &options.cursor {
271        edges.retain(|edge| graph_edge_id(edge) > *cursor);
272    }
273
274    let before_limit = edges.len();
275    let mut next_cursor = None;
276    if let Some(limit) = options.limit
277        && edges.len() > limit
278    {
279        next_cursor = edges.get(limit.saturating_sub(1)).map(graph_edge_id);
280        edges.truncate(limit);
281    }
282
283    if after_filter != before_filter {
284        diagnostics.push(format!(
285            "edge property filters removed {} edge(s)",
286            before_filter.saturating_sub(after_filter)
287        ));
288    }
289    if options.cursor.is_some() {
290        diagnostics.push("cursor is exclusive and ordered by edge id".to_string());
291    }
292    if next_cursor.is_some() {
293        diagnostics.push(
294            "result was truncated; pass page.next_cursor as --cursor for the next page".to_string(),
295        );
296    }
297
298    GraphPagedSubgraph {
299        page: GraphQueryPage {
300            cursor: options.cursor,
301            limit: options.limit,
302            next_cursor,
303            returned_nodes: 0,
304            returned_edges: edges.len(),
305            truncated: options.limit.is_some_and(|limit| before_limit > limit),
306            diagnostics,
307        },
308        nodes: Vec::new(),
309        edges,
310    }
311}
312
313pub trait GraphStore {
314    fn upsert_node(&self, node: &GraphNode) -> Result<()>;
315    fn upsert_edge(&self, edge: &GraphEdge) -> Result<()>;
316    fn delete_node(&self, id: &str) -> Result<usize>;
317    fn delete_edge(&self, from_id: &str, to_id: &str, kind: &str) -> Result<usize>;
318    fn node(&self, id: &str) -> Result<Option<GraphNode>>;
319    fn all_nodes(&self) -> Result<Vec<GraphNode>>;
320    fn all_edges(&self) -> Result<Vec<GraphEdge>>;
321    fn edge(&self, edge_id: &str) -> Result<Option<GraphEdge>> {
322        Ok(self
323            .all_edges()?
324            .into_iter()
325            .find(|edge| graph_edge_id(edge) == edge_id))
326    }
327    fn graph_counts(&self) -> Result<(usize, usize)> {
328        Ok((self.all_nodes()?.len(), self.all_edges()?.len()))
329    }
330    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<GraphEdge>> {
331        let mut edges = self
332            .all_edges()?
333            .into_iter()
334            .filter(|edge| edge.from_id != edge.to_id)
335            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
336            .collect::<Vec<_>>();
337        edges.sort_by(|left, right| {
338            left.from_id
339                .cmp(&right.from_id)
340                .then(left.kind.cmp(&right.kind))
341                .then(left.to_id.cmp(&right.to_id))
342        });
343        Ok(edges.into_iter().next())
344    }
345    fn sample_edge_with_property(&self) -> Result<Option<(GraphEdge, GraphPropertyFilter)>> {
346        let mut probes = Vec::new();
347        for edge in self.all_edges()? {
348            if edge.from_id == edge.to_id {
349                continue;
350            }
351            if let Some((key, value)) = edge
352                .properties
353                .iter()
354                .next()
355                .map(|(key, value)| (key.clone(), value.clone()))
356            {
357                probes.push((edge, GraphPropertyFilter { key, value }));
358            }
359        }
360        probes.sort_by(|(left_edge, left_filter), (right_edge, right_filter)| {
361            left_filter
362                .key
363                .cmp(&right_filter.key)
364                .then(left_filter.value.cmp(&right_filter.value))
365                .then_with(|| graph_edge_id(left_edge).cmp(&graph_edge_id(right_edge)))
366        });
367        Ok(probes.into_iter().next())
368    }
369    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<GraphNode>>;
370    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>>;
371    fn incident_edges(&self, node_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>> {
372        let mut edges = self
373            .all_edges()?
374            .into_iter()
375            .filter(|edge| edge.from_id == node_id || edge.to_id == node_id)
376            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
377            .collect::<Vec<_>>();
378        edges.sort_by_key(graph_edge_id);
379        Ok(edges)
380    }
381    fn paged_edges(
382        &self,
383        kind: Option<&str>,
384        options: GraphQueryOptions,
385    ) -> Result<GraphPagedSubgraph> {
386        let edges = self
387            .all_edges()?
388            .into_iter()
389            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
390            .collect::<Vec<_>>();
391        Ok(apply_graph_edge_query_page(edges, options, Vec::new()))
392    }
393    fn paged_incident_edges(
394        &self,
395        node_id: &str,
396        kind: Option<&str>,
397        options: GraphQueryOptions,
398    ) -> Result<GraphPagedSubgraph> {
399        Ok(apply_graph_edge_query_page(
400            self.incident_edges(node_id, kind)?,
401            options,
402            Vec::new(),
403        ))
404    }
405    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<GraphEdge>> {
406        let mut edges = BTreeMap::<(String, String, String), GraphEdge>::new();
407        for from_id in node_ids {
408            for edge in self.outgoing_edges(from_id, None)? {
409                if node_ids.contains(&edge.to_id) {
410                    edges
411                        .entry((edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone()))
412                        .or_insert(edge);
413                }
414            }
415        }
416        Ok(edges.into_values().collect())
417    }
418    fn shortest_path(
419        &self,
420        from_id: &str,
421        to_id: &str,
422        kind: Option<&str>,
423    ) -> Result<Option<GraphPath>>;
424    fn shortest_path_with_max_hops(
425        &self,
426        from_id: &str,
427        to_id: &str,
428        kind: Option<&str>,
429        max_hops: Option<usize>,
430    ) -> Result<Option<GraphPath>> {
431        shortest_path_using_outgoing(from_id, to_id, kind, max_hops, |current, kind| {
432            self.outgoing_edges(current, kind)
433        })
434    }
435    fn neighborhood(
436        &self,
437        center_id: &str,
438        depth: usize,
439        kind: Option<&str>,
440    ) -> Result<Option<GraphSubgraph>> {
441        let Some(center) = self.node(center_id)? else {
442            return Ok(None);
443        };
444        let ctx = LazyContext::new();
445        let initial = NeighborhoodLayerState {
446            nodes: BTreeMap::from([(center_id.to_string(), center)]),
447            edges: BTreeMap::new(),
448            frontier: vec![center_id.to_string()],
449        };
450        let mut layer: SlotHandle<std::result::Result<NeighborhoodLayerState, String>> =
451            ctx.slot(move |_| Ok(initial.clone()));
452        let mut state = ctx.get(&layer).map_err(graph_cache_error)?;
453
454        for _ in 0..depth {
455            if state.frontier.is_empty() {
456                break;
457            }
458            let fetched = fetch_neighborhood_layer(self, &state.frontier, &state.nodes, kind)?;
459            let previous = layer;
460            layer = ctx.slot(move |ctx| {
461                let mut state = ctx.get(&previous)?;
462                for edge in &fetched.edges {
463                    let edge_key = (edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone());
464                    state.edges.entry(edge_key).or_insert_with(|| edge.clone());
465                }
466                state.frontier.clear();
467                for node in &fetched.nodes {
468                    if !state.nodes.contains_key(&node.id) {
469                        state.frontier.push(node.id.clone());
470                        state.nodes.insert(node.id.clone(), node.clone());
471                    }
472                }
473                Ok(state)
474            });
475            state = ctx.get(&layer).map_err(graph_cache_error)?;
476        }
477
478        Ok(Some(
479            GraphSubgraph {
480                nodes: state.nodes.into_values().collect(),
481                edges: state.edges.into_values().collect(),
482            }
483            .sorted(),
484        ))
485    }
486    fn paged_nodes_by_kind(
487        &self,
488        kind: &str,
489        options: GraphQueryOptions,
490    ) -> Result<GraphPagedSubgraph> {
491        Ok(apply_graph_query_page(
492            self.nodes_by_kind(kind)?,
493            Vec::new(),
494            options,
495            Vec::new(),
496        ))
497    }
498    fn paged_neighborhood(
499        &self,
500        center_id: &str,
501        depth: usize,
502        kind: Option<&str>,
503        options: GraphQueryOptions,
504    ) -> Result<Option<GraphPagedSubgraph>> {
505        Ok(self.neighborhood(center_id, depth, kind)?.map(|subgraph| {
506            apply_graph_query_page(subgraph.nodes, subgraph.edges, options, Vec::new())
507        }))
508    }
509    fn ranked_neighborhood(
510        &self,
511        center_id: &str,
512        options: &RankedNeighborhoodOptions,
513    ) -> Result<Option<RankedNeighborhoodResult>> {
514        let Some(center) = self.node(center_id)? else {
515            return Ok(None);
516        };
517        let ctx = LazyContext::new();
518        let initial = RankedNeighborhoodLayerState {
519            nodes: BTreeMap::from([(center_id.to_string(), center)]),
520            edges: BTreeMap::new(),
521            queue: BinaryHeap::from([ScoredQueueEntry {
522                id: center_id.to_string(),
523                depth: 0usize,
524                score: i64::MAX,
525            }]),
526            seen: BTreeSet::from([center_id.to_string()]),
527            pruned_count: 0,
528            total_discovered: 1,
529            degree_map: BTreeMap::new(),
530        };
531        let mut layer: SlotHandle<std::result::Result<RankedNeighborhoodLayerState, String>> =
532            ctx.slot(move |_| Ok(initial.clone()));
533        let mut state = ctx.get(&layer).map_err(graph_cache_error)?;
534        let options = options.clone();
535
536        while let Some(entry) = state.queue.peek().cloned() {
537            let fetched = if entry.depth < options.depth {
538                Some(fetch_ranked_neighborhood_expansion(
539                    self,
540                    &entry,
541                    &state,
542                    options.edge_kind.as_deref(),
543                )?)
544            } else {
545                None
546            };
547            let previous = layer;
548            let options = options.clone();
549            layer = ctx.slot(move |ctx| {
550                let mut state = ctx.get(&previous)?;
551                let Some(entry) = state.queue.pop() else {
552                    return Ok(state);
553                };
554                let Some(fetched) = &fetched else {
555                    return Ok(state);
556                };
557                for edge in &fetched.edges {
558                    let edge_key = (edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone());
559                    state.edges.entry(edge_key).or_insert_with(|| edge.clone());
560                    *state.degree_map.entry(edge.from_id.clone()).or_default() += 1;
561                    *state.degree_map.entry(edge.to_id.clone()).or_default() += 1;
562                    if state.seen.contains(&edge.to_id) {
563                        continue;
564                    }
565                    state.seen.insert(edge.to_id.clone());
566                    state.total_discovered += 1;
567                    let Some(neighbor) = fetched.neighbor_nodes.get(&edge.to_id) else {
568                        continue;
569                    };
570                    if state.nodes.len() > options.max_nodes {
571                        state.pruned_count += 1;
572                        continue;
573                    }
574                    let score = compute_neighborhood_score(
575                        &options.scoring,
576                        entry.depth + 1,
577                        &edge.kind,
578                        neighbor,
579                        &state.degree_map,
580                    );
581                    state.nodes.insert(edge.to_id.clone(), neighbor.clone());
582                    state.queue.push(ScoredQueueEntry {
583                        id: edge.to_id.clone(),
584                        depth: entry.depth + 1,
585                        score,
586                    });
587                }
588                Ok(state)
589            });
590            state = ctx.get(&layer).map_err(graph_cache_error)?;
591        }
592
593        let node_ids: BTreeSet<_> = state.nodes.keys().cloned().collect();
594        state
595            .edges
596            .retain(|_, edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id));
597
598        Ok(Some(RankedNeighborhoodResult {
599            nodes: state.nodes.into_values().collect(),
600            edges: state.edges.into_values().collect(),
601            pruned_count: state.pruned_count,
602            total_discovered: state.total_discovered,
603        }))
604    }
605    fn reachable_nodes_by_kind(
606        &self,
607        from_id: &str,
608        kind: &str,
609        depth: usize,
610        limit: usize,
611    ) -> Result<Vec<(GraphNode, GraphPath)>> {
612        let mut rows = BTreeMap::<String, (GraphNode, GraphPath)>::new();
613        let mut seen = BTreeSet::from([from_id.to_string()]);
614        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
615
616        while let Some((current, path)) = queue.pop_front() {
617            let current_depth = path.len().saturating_sub(1);
618            if current_depth >= depth {
619                continue;
620            }
621            for edge in self.outgoing_edges(&current, None)? {
622                if !seen.insert(edge.to_id.clone()) {
623                    continue;
624                }
625                let Some(node) = self.node(&edge.to_id)? else {
626                    continue;
627                };
628                let mut next_path = path.clone();
629                next_path.push(edge.to_id.clone());
630                let graph_path = GraphPath {
631                    hops: next_path.len().saturating_sub(1),
632                    nodes: next_path.clone(),
633                };
634                if node.kind == kind {
635                    rows.entry(node.id.clone()).or_insert((node, graph_path));
636                }
637                queue.push_back((edge.to_id, next_path));
638            }
639        }
640        let mut rows = rows.into_values().collect::<Vec<_>>();
641        rows.sort_by(|(left_node, left_path), (right_node, right_path)| {
642            left_path
643                .hops
644                .cmp(&right_path.hops)
645                .then(left_node.label.cmp(&right_node.label))
646                .then(left_node.id.cmp(&right_node.id))
647        });
648        if limit > 0 && rows.len() > limit {
649            rows.truncate(limit);
650        }
651        Ok(rows)
652    }
653
654    fn reachable_nodes_by_kinds(
655        &self,
656        from_id: &str,
657        kinds: &[&str],
658        depth: usize,
659        limit: usize,
660    ) -> Result<BTreeMap<String, Vec<(GraphNode, GraphPath)>>> {
661        let mut rows = BTreeMap::new();
662        for kind in kinds {
663            rows.insert(
664                (*kind).to_string(),
665                self.reachable_nodes_by_kind(from_id, kind, depth, limit)?,
666            );
667        }
668        Ok(rows)
669    }
670
671    fn resolve_evidence_target(&self, target: &str, kinds: &[&str]) -> Result<Option<GraphNode>> {
672        if let Some(node) = self.node(target)? {
673            return Ok(Some(node));
674        }
675        let normalized = target.trim().trim_start_matches('#');
676        for kind in kinds {
677            let mut candidates = self
678                .nodes_by_kind(kind)?
679                .into_iter()
680                .filter(|node| {
681                    node.properties.get("handle").map(String::as_str) == Some(target)
682                        || node.properties.get("ref_id").map(String::as_str) == Some(normalized)
683                        || node.label == target
684                        || node.label == format!("#{normalized}")
685                })
686                .collect::<Vec<_>>();
687            candidates.sort_by(|left, right| left.id.cmp(&right.id));
688            if let Some(candidate) = candidates.into_iter().next() {
689                return Ok(Some(candidate));
690            }
691        }
692        Ok(None)
693    }
694}
695
696pub fn shortest_path_using_outgoing(
697    from_id: &str,
698    to_id: &str,
699    kind: Option<&str>,
700    max_hops: Option<usize>,
701    mut outgoing_edges: impl FnMut(&str, Option<&str>) -> Result<Vec<GraphEdge>>,
702) -> Result<Option<GraphPath>> {
703    if from_id == to_id {
704        return Ok(Some(GraphPath {
705            nodes: vec![from_id.to_string()],
706            hops: 0,
707        }));
708    }
709
710    let mut queue = VecDeque::new();
711    let mut parent = BTreeMap::<String, (usize, String)>::new();
712    parent.insert(from_id.to_string(), (0, String::new()));
713    queue.push_back(from_id.to_string());
714
715    while let Some(current) = queue.pop_front() {
716        let current_depth = parent.get(&current).map(|(d, _)| *d).unwrap_or(0);
717        if max_hops.is_some_and(|max_hops| current_depth >= max_hops) {
718            continue;
719        }
720        for edge in outgoing_edges(&current, kind)? {
721            if parent.contains_key(&edge.to_id) {
722                continue;
723            }
724            parent.insert(edge.to_id.clone(), (current_depth + 1, current.clone()));
725            if edge.to_id == to_id {
726                let mut nodes = vec![to_id.to_string()];
727                let mut cursor = to_id;
728                while let Some((_, previous)) = parent.get(cursor) {
729                    if previous.is_empty() {
730                        break;
731                    }
732                    nodes.push(previous.clone());
733                    cursor = previous;
734                }
735                nodes.reverse();
736                let hops = nodes.len().saturating_sub(1);
737                return Ok(Some(GraphPath { nodes, hops }));
738            }
739            queue.push_back(edge.to_id);
740        }
741    }
742
743    Ok(None)
744}