Skip to main content

khive_runtime/
graph_traversal.rs

1// Copyright 2024-2025 Haiyang Li
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{HashMap, HashSet};
16
17use uuid::Uuid;
18
19use khive_storage::types::{Direction, Edge, LinkId, NeighborQuery, TraversalOptions};
20
21use crate::error::{RuntimeError, RuntimeResult};
22use crate::runtime::{KhiveRuntime, NamespaceToken};
23
24/// A node in a traversal path.
25#[derive(Debug, Clone)]
26pub struct PathNode {
27    /// Entity at this position.
28    pub entity_id: Uuid,
29    /// Distance from the start node (0 = start node).
30    pub depth: usize,
31    /// Edge that led to this node (`None` for the start node).
32    pub via_edge: Option<Edge>,
33}
34
35impl KhiveRuntime {
36    /// BFS traversal from `start`, returning nodes in level order.
37    ///
38    /// The first element is always the start node (`via_edge = None`, `depth = 0`).
39    /// Nodes already visited are skipped so the result set is deduplicated.
40    ///
41    /// DB round-trips are O(max_depth): one `batch_neighbors` call and one
42    /// `get_edges` call per BFS level, rather than one call per node/edge.
43    pub async fn bfs_traverse(
44        &self,
45        token: &NamespaceToken,
46        start: Uuid,
47        options: TraversalOptions,
48    ) -> RuntimeResult<Vec<PathNode>> {
49        if !self.substrate_exists_in_ns(token, start).await? {
50            return Ok(Vec::new());
51        }
52
53        let graph = self.graph(token)?;
54        let limit = options.limit.map(|n| n as usize).unwrap_or(usize::MAX);
55
56        let mut visited: HashSet<Uuid> = HashSet::new();
57        let mut results: Vec<PathNode> = Vec::new();
58        let mut frontier: Vec<Uuid> = Vec::new();
59
60        visited.insert(start);
61        results.push(PathNode {
62            entity_id: start,
63            depth: 0,
64            via_edge: None,
65        });
66        frontier.push(start);
67
68        let mut depth = 0usize;
69
70        'bfs: while !frontier.is_empty() && depth < options.max_depth {
71            let query = NeighborQuery {
72                direction: options.direction.clone(),
73                relations: options.relations.clone(),
74                limit: None,
75                min_weight: None,
76            };
77
78            // One DB round-trip for all nodes in the current frontier.
79            let all_hits = graph.batch_neighbors(&frontier, query).await?;
80
81            let mut level_new: Vec<(Uuid, Uuid)> = Vec::new();
82            for (_src, hit) in &all_hits {
83                if visited.contains(&hit.node_id) {
84                    continue;
85                }
86                // Mark visited before pushing so duplicate edges in the same level don't duplicate PathNodes.
87                if visited.insert(hit.node_id) {
88                    level_new.push((hit.node_id, hit.edge_id));
89                }
90            }
91
92            if level_new.is_empty() {
93                break 'bfs;
94            }
95
96            // One DB round-trip to fetch all edges for this level.
97            let edge_ids: Vec<LinkId> = level_new
98                .iter()
99                .map(|(_, eid)| LinkId::from(*eid))
100                .collect();
101            let edges = graph.get_edges(&edge_ids).await?;
102            let edge_map: HashMap<Uuid, Edge> =
103                edges.into_iter().map(|e| (Uuid::from(e.id), e)).collect();
104
105            let next_depth = depth + 1;
106            frontier.clear();
107            for (node_id, edge_id) in level_new {
108                let via_edge = edge_map.get(&edge_id).cloned().or(None);
109                // A missing edge here means it was soft-deleted between the neighbors and get_edges calls;
110                // error out rather than silently returning an incomplete path.
111                if via_edge.is_none() {
112                    return Err(RuntimeError::NotFound(format!("edge {} missing", edge_id)));
113                }
114                results.push(PathNode {
115                    entity_id: node_id,
116                    depth: next_depth,
117                    via_edge,
118                });
119                if results.len() >= limit {
120                    break 'bfs;
121                }
122                frontier.push(node_id);
123            }
124
125            depth = next_depth;
126        }
127
128        Ok(results)
129    }
130
131    /// Bidirectional BFS shortest path from `from` to `to`.
132    ///
133    /// Returns `Some(path)` where `path[0]` is `from` and `path.last()` is `to`,
134    /// or `None` if no path exists within `max_depth` hops.
135    /// For `from == to` returns `Some` with a single-node path immediately.
136    ///
137    /// DB round-trips are O(max_depth): one `batch_neighbors` per frontier
138    /// expansion level, plus one `get_edges` call during path reconstruction.
139    pub async fn shortest_path(
140        &self,
141        token: &NamespaceToken,
142        from: Uuid,
143        to: Uuid,
144        max_depth: usize,
145    ) -> RuntimeResult<Option<Vec<PathNode>>> {
146        if !self.substrate_exists_in_ns(token, from).await?
147            || !self.substrate_exists_in_ns(token, to).await?
148        {
149            return Ok(None);
150        }
151
152        if from == to {
153            return Ok(Some(vec![PathNode {
154                entity_id: from,
155                depth: 0,
156                via_edge: None,
157            }]));
158        }
159
160        let graph = self.graph(token)?;
161
162        // Forward map: node -> (depth, parent, edge_id that reached this node)
163        let mut fwd: HashMap<Uuid, (usize, Option<Uuid>, Option<Uuid>)> = HashMap::new();
164        let mut fwd_frontier: Vec<Uuid> = vec![from];
165        fwd.insert(from, (0, None, None));
166
167        // Backward map: node -> (depth, child, edge_id that reached this node from `to` side)
168        let mut bwd: HashMap<Uuid, (usize, Option<Uuid>, Option<Uuid>)> = HashMap::new();
169        let mut bwd_frontier: Vec<Uuid> = vec![to];
170        bwd.insert(to, (0, None, None));
171
172        let mut meeting: Option<(Uuid, usize)> = None;
173        let mut current_depth = 0usize;
174
175        while (!fwd_frontier.is_empty() || !bwd_frontier.is_empty()) && current_depth <= max_depth {
176            if !fwd_frontier.is_empty() {
177                let hits = graph
178                    .batch_neighbors(
179                        &fwd_frontier,
180                        NeighborQuery {
181                            direction: Direction::Out,
182                            relations: None,
183                            limit: None,
184                            min_weight: None,
185                        },
186                    )
187                    .await?;
188
189                let mut next_fwd: Vec<Uuid> = Vec::new();
190                for (src, hit) in &hits {
191                    if fwd.contains_key(&hit.node_id) {
192                        continue;
193                    }
194                    let new_depth = fwd[src].0 + 1;
195                    fwd.insert(hit.node_id, (new_depth, Some(*src), Some(hit.edge_id)));
196                    next_fwd.push(hit.node_id);
197
198                    if let Some(&(bwd_depth, _, _)) = bwd.get(&hit.node_id) {
199                        let total = new_depth + bwd_depth;
200                        if total <= max_depth
201                            && meeting.as_ref().is_none_or(|&(_, best)| total < best)
202                        {
203                            meeting = Some((hit.node_id, total));
204                        }
205                    }
206                }
207                fwd_frontier = next_fwd;
208            }
209
210            if meeting.is_some() {
211                break;
212            }
213
214            if !bwd_frontier.is_empty() {
215                let hits = graph
216                    .batch_neighbors(
217                        &bwd_frontier,
218                        NeighborQuery {
219                            direction: Direction::In,
220                            relations: None,
221                            limit: None,
222                            min_weight: None,
223                        },
224                    )
225                    .await?;
226
227                let mut next_bwd: Vec<Uuid> = Vec::new();
228                for (src, hit) in &hits {
229                    if bwd.contains_key(&hit.node_id) {
230                        continue;
231                    }
232                    let new_depth = bwd[src].0 + 1;
233                    bwd.insert(hit.node_id, (new_depth, Some(*src), Some(hit.edge_id)));
234                    next_bwd.push(hit.node_id);
235
236                    if let Some(&(fwd_depth, _, _)) = fwd.get(&hit.node_id) {
237                        let total = fwd_depth + new_depth;
238                        if total <= max_depth
239                            && meeting.as_ref().is_none_or(|&(_, best)| total < best)
240                        {
241                            meeting = Some((hit.node_id, total));
242                        }
243                    }
244                }
245                bwd_frontier = next_bwd;
246            }
247
248            if meeting.is_some() {
249                break;
250            }
251
252            current_depth += 1;
253        }
254
255        let (mid, _) = match meeting {
256            None => return Ok(None),
257            Some(m) => m,
258        };
259
260        let mut fwd_chain: Vec<(Uuid, Option<Uuid>)> = Vec::new();
261        {
262            let mut cur = mid;
263            loop {
264                let (_, parent, edge_id) = fwd[&cur];
265                fwd_chain.push((cur, edge_id));
266                match parent {
267                    Some(p) => cur = p,
268                    None => break,
269                }
270            }
271        }
272        fwd_chain.reverse();
273
274        let mut bwd_chain: Vec<(Uuid, Option<Uuid>)> = Vec::new();
275        {
276            let mut cur = mid;
277            while let Some(&(_, Some(child), edge_id)) = bwd.get(&cur) {
278                bwd_chain.push((child, edge_id));
279                cur = child;
280            }
281        }
282
283        // Fetch all path edges in one batch call rather than one round-trip per edge.
284        let path_edge_ids: Vec<LinkId> = fwd_chain
285            .iter()
286            .chain(bwd_chain.iter())
287            .filter_map(|(_, eid)| eid.map(LinkId::from))
288            .collect();
289
290        let path_edges = graph.get_edges(&path_edge_ids).await?;
291        let edge_map: HashMap<Uuid, Edge> = path_edges
292            .into_iter()
293            .map(|e| (Uuid::from(e.id), e))
294            .collect();
295
296        let mut path: Vec<PathNode> = Vec::new();
297        for (i, (node_id, edge_id)) in fwd_chain.iter().enumerate() {
298            let via_edge = if i == 0 {
299                None // start node
300            } else {
301                edge_id.and_then(|eid| edge_map.get(&eid).cloned())
302            };
303            path.push(PathNode {
304                entity_id: *node_id,
305                depth: i,
306                via_edge,
307            });
308        }
309
310        let base = path.len();
311        for (i, (node_id, edge_id)) in bwd_chain.iter().enumerate() {
312            let via_edge = edge_id.and_then(|eid| edge_map.get(&eid).cloned());
313            path.push(PathNode {
314                entity_id: *node_id,
315                depth: base + i,
316                via_edge,
317            });
318        }
319
320        Ok(Some(path))
321    }
322}
323
324// Kept inline (not in tests/) because these tests exercise private traversal state that
325// would otherwise need to be made pub.
326#[cfg(test)]
327#[path = "graph_traversal_tests.rs"]
328mod tests;