Skip to main content

ipfrs_tensorlogic/
kg_traversal.rs

1//! Knowledge Graph Traversal for TensorLogic distributed reasoning.
2//!
3//! TensorLogic rules form an implicit knowledge graph. This module provides
4//! efficient traversal, path-finding, and subgraph extraction for distributed
5//! reasoning over predicate dependency graphs.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use thiserror::Error;
9
10/// Error types for knowledge graph operations.
11#[derive(Debug, Error)]
12pub enum KgError {
13    /// A node with the given ID was not found.
14    #[error("node not found: {0}")]
15    NodeNotFound(String),
16
17    /// A node with the given ID already exists.
18    #[error("duplicate node: {0}")]
19    DuplicateNode(String),
20
21    /// An edge references a node that does not exist.
22    #[error("edge endpoint missing: from={from}, to={to}")]
23    EdgeEndpointMissing { from: String, to: String },
24}
25
26/// Discriminates the semantic role of a knowledge graph node.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub enum NodeType {
29    /// A predicate symbol (e.g. `parent/2`).
30    Predicate,
31    /// A ground constant (e.g. `"alice"`).
32    Constant,
33    /// A logical variable (e.g. `X`).
34    Variable,
35    /// A named rule.
36    Rule,
37}
38
39/// A single node in the knowledge graph.
40#[derive(Debug, Clone)]
41pub struct KgNode {
42    /// Unique identifier (predicate name, constant literal, etc.).
43    pub id: String,
44    /// Semantic role of the node.
45    pub node_type: NodeType,
46    /// Arbitrary key-value metadata.
47    pub metadata: HashMap<String, String>,
48}
49
50impl KgNode {
51    /// Construct a new node with empty metadata.
52    pub fn new(id: impl Into<String>, node_type: NodeType) -> Self {
53        Self {
54            id: id.into(),
55            node_type,
56            metadata: HashMap::new(),
57        }
58    }
59
60    /// Construct a new node with pre-populated metadata.
61    pub fn with_metadata(
62        id: impl Into<String>,
63        node_type: NodeType,
64        metadata: HashMap<String, String>,
65    ) -> Self {
66        Self {
67            id: id.into(),
68            node_type,
69            metadata,
70        }
71    }
72}
73
74/// Discriminates the semantic relationship encoded by an edge.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub enum EdgeType {
77    /// Node A uses node B inside a rule body / goal.
78    UsesIn,
79    /// Node A defines (introduces) node B.
80    DefinesIn,
81    /// Node A depends on node B for derivation.
82    DependsOn,
83    /// Node A and node B are logically contradictory.
84    Contradicts,
85    /// Node A subsumes (is more general than) node B.
86    Subsumes,
87}
88
89/// A directed, weighted edge between two knowledge graph nodes.
90#[derive(Debug, Clone)]
91pub struct KgEdge {
92    /// Source node ID.
93    pub from_id: String,
94    /// Target node ID.
95    pub to_id: String,
96    /// Semantic type of the relationship.
97    pub edge_type: EdgeType,
98    /// Edge weight (defaults to 1.0).
99    pub weight: f32,
100}
101
102impl KgEdge {
103    /// Create a new edge with default weight 1.0.
104    pub fn new(from_id: impl Into<String>, to_id: impl Into<String>, edge_type: EdgeType) -> Self {
105        Self {
106            from_id: from_id.into(),
107            to_id: to_id.into(),
108            edge_type,
109            weight: 1.0,
110        }
111    }
112
113    /// Create a new edge with an explicit weight.
114    pub fn with_weight(
115        from_id: impl Into<String>,
116        to_id: impl Into<String>,
117        edge_type: EdgeType,
118        weight: f32,
119    ) -> Self {
120        Self {
121            from_id: from_id.into(),
122            to_id: to_id.into(),
123            edge_type,
124            weight,
125        }
126    }
127}
128
129/// A directed knowledge graph whose nodes are predicates, constants, variables,
130/// or rules, and whose edges encode semantic relationships among them.
131///
132/// Adjacency is maintained as a map from node ID to a list of indices into
133/// the `edges` vector for O(1) lookup of outgoing edges.
134#[derive(Debug, Clone, Default)]
135pub struct KnowledgeGraph {
136    /// All nodes, keyed by their unique identifier.
137    pub nodes: HashMap<String, KgNode>,
138    /// All edges stored in insertion order.
139    pub edges: Vec<KgEdge>,
140    /// node_id → indices into `edges` for edges leaving that node.
141    pub adjacency: HashMap<String, Vec<usize>>,
142}
143
144impl KnowledgeGraph {
145    /// Create an empty knowledge graph.
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Insert a node.  Returns `KgError::DuplicateNode` if the ID already exists.
151    pub fn add_node(&mut self, node: KgNode) -> Result<(), KgError> {
152        if self.nodes.contains_key(&node.id) {
153            return Err(KgError::DuplicateNode(node.id));
154        }
155        // Pre-allocate an adjacency list entry so `neighbors` can always find it.
156        self.adjacency.entry(node.id.clone()).or_default();
157        self.nodes.insert(node.id.clone(), node);
158        Ok(())
159    }
160
161    /// Insert an edge.  Both endpoints must already exist as nodes.
162    ///
163    /// Returns `KgError::EdgeEndpointMissing` if either endpoint is absent.
164    pub fn add_edge(&mut self, edge: KgEdge) -> Result<(), KgError> {
165        if !self.nodes.contains_key(&edge.from_id) || !self.nodes.contains_key(&edge.to_id) {
166            return Err(KgError::EdgeEndpointMissing {
167                from: edge.from_id.clone(),
168                to: edge.to_id.clone(),
169            });
170        }
171        let idx = self.edges.len();
172        self.adjacency
173            .entry(edge.from_id.clone())
174            .or_default()
175            .push(idx);
176        self.edges.push(edge);
177        Ok(())
178    }
179
180    /// Number of nodes in the graph.
181    pub fn node_count(&self) -> usize {
182        self.nodes.len()
183    }
184
185    /// Number of edges in the graph.
186    pub fn edge_count(&self) -> usize {
187        self.edges.len()
188    }
189
190    /// Return references to all nodes directly reachable from `node_id` via
191    /// outgoing edges (i.e. the immediate successors).
192    ///
193    /// Returns an empty slice if the node has no outgoing edges or does not
194    /// exist.
195    pub fn neighbors(&self, node_id: &str) -> Vec<&KgNode> {
196        let Some(edge_indices) = self.adjacency.get(node_id) else {
197            return Vec::new();
198        };
199        edge_indices
200            .iter()
201            .filter_map(|&idx| {
202                let edge = self.edges.get(idx)?;
203                self.nodes.get(&edge.to_id)
204            })
205            .collect()
206    }
207}
208
209/// Provides graph traversal, path-finding, and subgraph extraction over a
210/// [`KnowledgeGraph`].
211///
212/// All algorithms are iterative (no recursion) to avoid stack-overflow on
213/// large graphs and to give predictable performance for distributed workloads.
214pub struct KnowledgeGraphTraverser {
215    /// The underlying knowledge graph.
216    pub graph: KnowledgeGraph,
217}
218
219impl KnowledgeGraphTraverser {
220    /// Wrap a [`KnowledgeGraph`] in a traverser.
221    pub fn new(graph: KnowledgeGraph) -> Self {
222        Self { graph }
223    }
224
225    /// Breadth-first search starting from `start`.
226    ///
227    /// Returns the node IDs in BFS visit order.  Nodes deeper than
228    /// `max_depth` hops from `start` are not visited.  A visited-set
229    /// prevents cycles from causing duplicate visits.
230    ///
231    /// Returns an empty `Vec` if `start` does not exist in the graph.
232    pub fn bfs(&self, start: &str, max_depth: usize) -> Vec<String> {
233        if !self.graph.nodes.contains_key(start) {
234            return Vec::new();
235        }
236
237        let mut visited: HashSet<String> = HashSet::new();
238        let mut order: Vec<String> = Vec::new();
239        // Queue stores (node_id, current_depth).
240        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
241
242        visited.insert(start.to_string());
243        queue.push_back((start.to_string(), 0));
244
245        while let Some((current, depth)) = queue.pop_front() {
246            order.push(current.clone());
247
248            if depth >= max_depth {
249                continue;
250            }
251
252            if let Some(edge_indices) = self.graph.adjacency.get(&current) {
253                for &idx in edge_indices {
254                    if let Some(edge) = self.graph.edges.get(idx) {
255                        let neighbor = &edge.to_id;
256                        if !visited.contains(neighbor) {
257                            visited.insert(neighbor.clone());
258                            queue.push_back((neighbor.clone(), depth + 1));
259                        }
260                    }
261                }
262            }
263        }
264
265        order
266    }
267
268    /// Depth-first search starting from `start`.
269    ///
270    /// Returns node IDs in DFS visit order (pre-order).  Nodes deeper than
271    /// `max_depth` hops from `start` are not visited.  A visited-set
272    /// prevents revisiting nodes in cyclic graphs.
273    ///
274    /// Returns an empty `Vec` if `start` does not exist in the graph.
275    pub fn dfs(&self, start: &str, max_depth: usize) -> Vec<String> {
276        if !self.graph.nodes.contains_key(start) {
277            return Vec::new();
278        }
279
280        let mut visited: HashSet<String> = HashSet::new();
281        let mut order: Vec<String> = Vec::new();
282        // Stack stores (node_id, depth).
283        let mut stack: Vec<(String, usize)> = vec![(start.to_string(), 0)];
284
285        while let Some((current, depth)) = stack.pop() {
286            if visited.contains(&current) {
287                continue;
288            }
289            visited.insert(current.clone());
290            order.push(current.clone());
291
292            if depth < max_depth {
293                // Collect neighbours and push them in reverse so that the
294                // first listed neighbour is visited first.
295                if let Some(edge_indices) = self.graph.adjacency.get(&current) {
296                    let neighbours: Vec<String> = edge_indices
297                        .iter()
298                        .filter_map(|&idx| {
299                            let edge = self.graph.edges.get(idx)?;
300                            if !visited.contains(&edge.to_id) {
301                                Some(edge.to_id.clone())
302                            } else {
303                                None
304                            }
305                        })
306                        .collect();
307                    for n in neighbours.into_iter().rev() {
308                        stack.push((n, depth + 1));
309                    }
310                }
311            }
312        }
313
314        order
315    }
316
317    /// Find the shortest path (fewest hops) from `from` to `to` using BFS.
318    ///
319    /// Returns `Some(path)` where `path` is the sequence of node IDs from
320    /// `from` to `to` inclusive, or `None` if no path exists or either
321    /// endpoint is absent.
322    pub fn find_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
323        if !self.graph.nodes.contains_key(from) || !self.graph.nodes.contains_key(to) {
324            return None;
325        }
326        if from == to {
327            return Some(vec![from.to_string()]);
328        }
329
330        // BFS; track predecessor so we can reconstruct the path.
331        let mut visited: HashSet<String> = HashSet::new();
332        let mut predecessor: HashMap<String, String> = HashMap::new();
333        let mut queue: VecDeque<String> = VecDeque::new();
334
335        visited.insert(from.to_string());
336        queue.push_back(from.to_string());
337
338        let mut found = false;
339
340        'outer: while let Some(current) = queue.pop_front() {
341            if let Some(edge_indices) = self.graph.adjacency.get(&current) {
342                for &idx in edge_indices {
343                    if let Some(edge) = self.graph.edges.get(idx) {
344                        let neighbor = &edge.to_id;
345                        if !visited.contains(neighbor) {
346                            visited.insert(neighbor.clone());
347                            predecessor.insert(neighbor.clone(), current.clone());
348                            if neighbor == to {
349                                found = true;
350                                break 'outer;
351                            }
352                            queue.push_back(neighbor.clone());
353                        }
354                    }
355                }
356            }
357        }
358
359        if !found {
360            return None;
361        }
362
363        // Reconstruct path by walking predecessors backwards.
364        let mut path = vec![to.to_string()];
365        let mut node = to.to_string();
366        while node != from {
367            let prev = predecessor.get(&node)?.clone();
368            path.push(prev.clone());
369            node = prev;
370        }
371        path.reverse();
372        Some(path)
373    }
374
375    /// Extract the subgraph reachable from any of the `roots` within `depth`
376    /// hops (inclusive).
377    ///
378    /// The returned [`KnowledgeGraph`] contains all reachable nodes together
379    /// with every edge whose both endpoints are in the reachable set.
380    pub fn subgraph(&self, roots: &[String], depth: usize) -> KnowledgeGraph {
381        // Collect all reachable node IDs via multi-source BFS.
382        let mut reachable: HashSet<String> = HashSet::new();
383        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
384
385        for root in roots {
386            if self.graph.nodes.contains_key(root.as_str()) && !reachable.contains(root) {
387                reachable.insert(root.clone());
388                queue.push_back((root.clone(), 0));
389            }
390        }
391
392        while let Some((current, d)) = queue.pop_front() {
393            if d >= depth {
394                continue;
395            }
396            if let Some(edge_indices) = self.graph.adjacency.get(&current) {
397                for &idx in edge_indices {
398                    if let Some(edge) = self.graph.edges.get(idx) {
399                        let neighbor = &edge.to_id;
400                        if !reachable.contains(neighbor) {
401                            reachable.insert(neighbor.clone());
402                            queue.push_back((neighbor.clone(), d + 1));
403                        }
404                    }
405                }
406            }
407        }
408
409        // Build the sub-graph.
410        let mut sg = KnowledgeGraph::new();
411        for id in &reachable {
412            if let Some(node) = self.graph.nodes.get(id) {
413                // Ignore duplicate errors — should never occur here since we
414                // deduplicate via the `reachable` set.
415                let _ = sg.add_node(node.clone());
416            }
417        }
418        for edge in &self.graph.edges {
419            if reachable.contains(&edge.from_id) && reachable.contains(&edge.to_id) {
420                let _ = sg.add_edge(edge.clone());
421            }
422        }
423        sg
424    }
425
426    /// Compute the connected components of the **undirected** version of the
427    /// graph using union-find (path-compressed, union-by-rank).
428    ///
429    /// Returns one `Vec<String>` per component.  Each component's node IDs
430    /// are sorted lexicographically.  Components are themselves ordered by
431    /// their smallest member.
432    pub fn connected_components(&self) -> Vec<Vec<String>> {
433        // Collect all node IDs into a deterministic order.
434        let mut ids: Vec<String> = self.graph.nodes.keys().cloned().collect();
435        ids.sort();
436
437        // Map each ID to a compact integer index.
438        let index: HashMap<&str, usize> = ids
439            .iter()
440            .enumerate()
441            .map(|(i, id)| (id.as_str(), i))
442            .collect();
443        let n = ids.len();
444
445        // parent[i] initially points to itself; rank[i] starts at 0.
446        let mut parent: Vec<usize> = (0..n).collect();
447        let mut rank: Vec<usize> = vec![0; n];
448
449        fn find(parent: &mut [usize], x: usize) -> usize {
450            if parent[x] != x {
451                parent[x] = find(parent, parent[x]); // path compression
452            }
453            parent[x]
454        }
455
456        fn union(parent: &mut [usize], rank: &mut [usize], x: usize, y: usize) {
457            let rx = find(parent, x);
458            let ry = find(parent, y);
459            if rx == ry {
460                return;
461            }
462            match rank[rx].cmp(&rank[ry]) {
463                std::cmp::Ordering::Less => parent[rx] = ry,
464                std::cmp::Ordering::Greater => parent[ry] = rx,
465                std::cmp::Ordering::Equal => {
466                    parent[ry] = rx;
467                    rank[rx] += 1;
468                }
469            }
470        }
471
472        // Treat every edge as undirected: union from_id and to_id.
473        for edge in &self.graph.edges {
474            if let (Some(&ai), Some(&bi)) = (
475                index.get(edge.from_id.as_str()),
476                index.get(edge.to_id.as_str()),
477            ) {
478                union(&mut parent, &mut rank, ai, bi);
479            }
480        }
481
482        // Group node IDs by their root representative.
483        let mut components: HashMap<usize, Vec<String>> = HashMap::new();
484        for (i, id) in ids.iter().enumerate() {
485            let root = find(&mut parent, i);
486            components.entry(root).or_default().push(id.clone());
487        }
488
489        // Each component is already in sorted order because `ids` was sorted.
490        let mut result: Vec<Vec<String>> = components.into_values().collect();
491        // Order components by their first (smallest) element.
492        result.sort_by(|a, b| a[0].cmp(&b[0]));
493        result
494    }
495
496    /// Detect whether the **directed** graph contains at least one cycle.
497    ///
498    /// Uses iterative DFS with three-colour marking:
499    /// - White (0): unvisited
500    /// - Gray  (1): on the current DFS path (recursion stack)
501    /// - Black (2): fully processed
502    pub fn has_cycle(&self) -> bool {
503        // 0 = white, 1 = gray, 2 = black
504        let mut color: HashMap<&str, u8> = HashMap::new();
505
506        for start_id in self.graph.nodes.keys() {
507            if color.get(start_id.as_str()).copied().unwrap_or(0) != 0 {
508                continue;
509            }
510            // Iterative DFS: stack entries are (node_id, iterator_index_into_adjacency).
511            // We use an explicit "call stack" of (node, edge_cursor) pairs.
512            let mut dfs_stack: Vec<(&str, usize)> = vec![(start_id.as_str(), 0)];
513            color.insert(start_id.as_str(), 1); // mark gray
514
515            'dfs: while let Some((node, cursor)) = dfs_stack.last_mut() {
516                let node: &str = node; // reborrow for lifetime
517                let edge_indices = match self.graph.adjacency.get(node) {
518                    Some(v) => v,
519                    None => {
520                        // No outgoing edges: mark black and pop.
521                        color.insert(node, 2);
522                        dfs_stack.pop();
523                        continue 'dfs;
524                    }
525                };
526
527                if *cursor < edge_indices.len() {
528                    let idx = edge_indices[*cursor];
529                    *cursor += 1;
530                    if let Some(edge) = self.graph.edges.get(idx) {
531                        let neighbor = edge.to_id.as_str();
532                        let c = color.get(neighbor).copied().unwrap_or(0);
533                        if c == 1 {
534                            // Back-edge → cycle found.
535                            return true;
536                        }
537                        if c == 0 {
538                            color.insert(neighbor, 1);
539                            dfs_stack.push((neighbor, 0));
540                        }
541                        // c == 2: already fully explored, skip.
542                    }
543                } else {
544                    // All neighbours processed: mark black and pop.
545                    color.insert(node, 2);
546                    dfs_stack.pop();
547                }
548            }
549        }
550
551        false
552    }
553}
554
555// ─── Tests ───────────────────────────────────────────────────────────────────
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    // ── helpers ──────────────────────────────────────────────────────────────
562
563    /// Build a small diamond graph:
564    ///   A → B → D
565    ///   A → C → D
566    fn diamond_graph() -> KnowledgeGraph {
567        let mut g = KnowledgeGraph::new();
568        for id in ["A", "B", "C", "D"] {
569            g.add_node(KgNode::new(id, NodeType::Predicate))
570                .expect("test: should succeed");
571        }
572        g.add_edge(KgEdge::new("A", "B", EdgeType::DependsOn))
573            .expect("test: should succeed");
574        g.add_edge(KgEdge::new("A", "C", EdgeType::DependsOn))
575            .expect("test: should succeed");
576        g.add_edge(KgEdge::new("B", "D", EdgeType::DependsOn))
577            .expect("test: should succeed");
578        g.add_edge(KgEdge::new("C", "D", EdgeType::DependsOn))
579            .expect("test: should succeed");
580        g
581    }
582
583    // ── node / edge basic operations ─────────────────────────────────────────
584
585    #[test]
586    fn test_add_node_and_count() {
587        let mut g = KnowledgeGraph::new();
588        g.add_node(KgNode::new("X", NodeType::Variable))
589            .expect("test: should succeed");
590        g.add_node(KgNode::new("Y", NodeType::Constant))
591            .expect("test: should succeed");
592        assert_eq!(g.node_count(), 2);
593        assert_eq!(g.edge_count(), 0);
594    }
595
596    #[test]
597    fn test_duplicate_node_error() {
598        let mut g = KnowledgeGraph::new();
599        g.add_node(KgNode::new("dup", NodeType::Rule))
600            .expect("test: should succeed");
601        let err = g.add_node(KgNode::new("dup", NodeType::Rule)).unwrap_err();
602        assert!(matches!(err, KgError::DuplicateNode(ref s) if s == "dup"));
603    }
604
605    #[test]
606    fn test_add_edge_and_count() {
607        let mut g = KnowledgeGraph::new();
608        g.add_node(KgNode::new("p", NodeType::Predicate))
609            .expect("test: should succeed");
610        g.add_node(KgNode::new("q", NodeType::Predicate))
611            .expect("test: should succeed");
612        g.add_edge(KgEdge::new("p", "q", EdgeType::UsesIn))
613            .expect("test: should succeed");
614        assert_eq!(g.edge_count(), 1);
615    }
616
617    #[test]
618    fn test_edge_missing_from_endpoint_error() {
619        let mut g = KnowledgeGraph::new();
620        g.add_node(KgNode::new("exists", NodeType::Predicate))
621            .expect("test: should succeed");
622        let err = g
623            .add_edge(KgEdge::new("missing", "exists", EdgeType::DefinesIn))
624            .unwrap_err();
625        assert!(matches!(err, KgError::EdgeEndpointMissing { .. }));
626    }
627
628    #[test]
629    fn test_edge_missing_to_endpoint_error() {
630        let mut g = KnowledgeGraph::new();
631        g.add_node(KgNode::new("exists", NodeType::Predicate))
632            .expect("test: should succeed");
633        let err = g
634            .add_edge(KgEdge::new("exists", "ghost", EdgeType::DefinesIn))
635            .unwrap_err();
636        assert!(matches!(err, KgError::EdgeEndpointMissing { .. }));
637    }
638
639    // ── neighbors ────────────────────────────────────────────────────────────
640
641    #[test]
642    fn test_neighbors_listing() {
643        let g = diamond_graph();
644        let mut nb: Vec<String> = g.neighbors("A").iter().map(|n| n.id.clone()).collect();
645        nb.sort();
646        assert_eq!(nb, vec!["B", "C"]);
647    }
648
649    #[test]
650    fn test_neighbors_leaf_node_is_empty() {
651        let g = diamond_graph();
652        assert!(g.neighbors("D").is_empty());
653    }
654
655    // ── BFS ──────────────────────────────────────────────────────────────────
656
657    #[test]
658    fn test_bfs_visit_order() {
659        let g = diamond_graph();
660        let t = KnowledgeGraphTraverser::new(g);
661        let visited = t.bfs("A", 10);
662        // A must come first; B and C before D.
663        assert_eq!(visited[0], "A");
664        let pos_b = visited
665            .iter()
666            .position(|x| x == "B")
667            .expect("test: should succeed");
668        let pos_c = visited
669            .iter()
670            .position(|x| x == "C")
671            .expect("test: should succeed");
672        let pos_d = visited
673            .iter()
674            .position(|x| x == "D")
675            .expect("test: should succeed");
676        assert!(pos_b < pos_d);
677        assert!(pos_c < pos_d);
678        assert_eq!(visited.len(), 4);
679    }
680
681    #[test]
682    fn test_bfs_max_depth_cutoff() {
683        let g = diamond_graph();
684        let t = KnowledgeGraphTraverser::new(g);
685        // depth 1: only A and its direct neighbours B, C
686        let visited = t.bfs("A", 1);
687        assert!(visited.contains(&"A".to_string()));
688        assert!(visited.contains(&"B".to_string()));
689        assert!(visited.contains(&"C".to_string()));
690        assert!(!visited.contains(&"D".to_string()));
691    }
692
693    #[test]
694    fn test_bfs_nonexistent_start_returns_empty() {
695        let g = diamond_graph();
696        let t = KnowledgeGraphTraverser::new(g);
697        assert!(t.bfs("NOPE", 5).is_empty());
698    }
699
700    // ── DFS ──────────────────────────────────────────────────────────────────
701
702    #[test]
703    fn test_dfs_visit_order_starts_with_root() {
704        let g = diamond_graph();
705        let t = KnowledgeGraphTraverser::new(g);
706        let visited = t.dfs("A", 10);
707        assert_eq!(visited[0], "A");
708        assert_eq!(visited.len(), 4);
709    }
710
711    #[test]
712    fn test_dfs_max_depth_cutoff() {
713        let g = diamond_graph();
714        let t = KnowledgeGraphTraverser::new(g);
715        let visited = t.dfs("A", 1);
716        assert!(visited.contains(&"A".to_string()));
717        assert!(!visited.contains(&"D".to_string()));
718    }
719
720    // ── find_path ────────────────────────────────────────────────────────────
721
722    #[test]
723    fn test_find_path_direct() {
724        let g = diamond_graph();
725        let t = KnowledgeGraphTraverser::new(g);
726        let path = t.find_path("A", "D").expect("test: should succeed");
727        assert_eq!(path[0], "A");
728        assert_eq!(*path.last().expect("test: should succeed"), "D");
729        // Shortest path has exactly 3 hops (A→B→D or A→C→D).
730        assert_eq!(path.len(), 3);
731    }
732
733    #[test]
734    fn test_find_path_self() {
735        let g = diamond_graph();
736        let t = KnowledgeGraphTraverser::new(g);
737        let path = t.find_path("A", "A").expect("test: should succeed");
738        assert_eq!(path, vec!["A"]);
739    }
740
741    #[test]
742    fn test_find_path_not_found() {
743        let g = diamond_graph();
744        let t = KnowledgeGraphTraverser::new(g);
745        // D has no outgoing edges, so D→A is unreachable.
746        assert!(t.find_path("D", "A").is_none());
747    }
748
749    #[test]
750    fn test_find_path_missing_node() {
751        let g = diamond_graph();
752        let t = KnowledgeGraphTraverser::new(g);
753        assert!(t.find_path("A", "NOPE").is_none());
754    }
755
756    // ── subgraph ─────────────────────────────────────────────────────────────
757
758    #[test]
759    fn test_subgraph_contains_correct_nodes() {
760        let g = diamond_graph();
761        let t = KnowledgeGraphTraverser::new(g);
762        let sg = t.subgraph(&["A".to_string()], 1);
763        // Depth 1 from A: A, B, C — but not D (which is depth 2).
764        assert!(sg.nodes.contains_key("A"));
765        assert!(sg.nodes.contains_key("B"));
766        assert!(sg.nodes.contains_key("C"));
767        assert!(!sg.nodes.contains_key("D"));
768    }
769
770    #[test]
771    fn test_subgraph_full_depth_includes_all() {
772        let g = diamond_graph();
773        let t = KnowledgeGraphTraverser::new(g);
774        let sg = t.subgraph(&["A".to_string()], 10);
775        assert_eq!(sg.node_count(), 4);
776    }
777
778    // ── connected_components ─────────────────────────────────────────────────
779
780    #[test]
781    fn test_connected_components_disconnected() {
782        let mut g = KnowledgeGraph::new();
783        // Component 1: P1 → P2
784        g.add_node(KgNode::new("P1", NodeType::Predicate))
785            .expect("test: should succeed");
786        g.add_node(KgNode::new("P2", NodeType::Predicate))
787            .expect("test: should succeed");
788        g.add_edge(KgEdge::new("P1", "P2", EdgeType::DependsOn))
789            .expect("test: should succeed");
790        // Component 2: isolated Q1
791        g.add_node(KgNode::new("Q1", NodeType::Constant))
792            .expect("test: should succeed");
793
794        let t = KnowledgeGraphTraverser::new(g);
795        let comps = t.connected_components();
796        assert_eq!(comps.len(), 2);
797        // Verify each node appears in exactly one component.
798        let flat: Vec<String> = comps.iter().flatten().cloned().collect();
799        assert_eq!(flat.len(), 3);
800        assert!(flat.contains(&"P1".to_string()));
801        assert!(flat.contains(&"Q1".to_string()));
802    }
803
804    #[test]
805    fn test_connected_components_single_component() {
806        let g = diamond_graph();
807        let t = KnowledgeGraphTraverser::new(g);
808        let comps = t.connected_components();
809        assert_eq!(comps.len(), 1);
810        let mut comp = comps[0].clone();
811        comp.sort();
812        assert_eq!(comp, vec!["A", "B", "C", "D"]);
813    }
814
815    // ── has_cycle ────────────────────────────────────────────────────────────
816
817    #[test]
818    fn test_has_cycle_detects_cycle() {
819        let mut g = KnowledgeGraph::new();
820        for id in ["X", "Y", "Z"] {
821            g.add_node(KgNode::new(id, NodeType::Rule))
822                .expect("test: should succeed");
823        }
824        g.add_edge(KgEdge::new("X", "Y", EdgeType::DependsOn))
825            .expect("test: should succeed");
826        g.add_edge(KgEdge::new("Y", "Z", EdgeType::DependsOn))
827            .expect("test: should succeed");
828        g.add_edge(KgEdge::new("Z", "X", EdgeType::DependsOn))
829            .expect("test: should succeed"); // back-edge
830
831        let t = KnowledgeGraphTraverser::new(g);
832        assert!(t.has_cycle());
833    }
834
835    #[test]
836    fn test_has_cycle_false_on_dag() {
837        let g = diamond_graph(); // A→B→D, A→C→D — no back-edges
838        let t = KnowledgeGraphTraverser::new(g);
839        assert!(!t.has_cycle());
840    }
841
842    // ── metadata and edge weight ──────────────────────────────────────────────
843
844    #[test]
845    fn test_node_metadata_preserved() {
846        let mut meta = HashMap::new();
847        meta.insert("arity".to_string(), "2".to_string());
848        let node = KgNode::with_metadata("parent", NodeType::Predicate, meta.clone());
849        let mut g = KnowledgeGraph::new();
850        g.add_node(node).expect("test: should succeed");
851        let stored = g.nodes.get("parent").expect("test: should succeed");
852        assert_eq!(stored.metadata.get("arity").map(String::as_str), Some("2"));
853    }
854
855    #[test]
856    fn test_edge_weight_preserved() {
857        let mut g = KnowledgeGraph::new();
858        g.add_node(KgNode::new("a", NodeType::Predicate))
859            .expect("test: should succeed");
860        g.add_node(KgNode::new("b", NodeType::Predicate))
861            .expect("test: should succeed");
862        g.add_edge(KgEdge::with_weight("a", "b", EdgeType::Subsumes, 0.42))
863            .expect("test: should succeed");
864        let edge = &g.edges[0];
865        assert!((edge.weight - 0.42).abs() < f32::EPSILON);
866    }
867}