Skip to main content

oxirs_graphrag/
path_finder.rs

1//! # Path Finder for Graph-RAG
2//!
3//! Graph path-finding algorithms optimised for knowledge graph retrieval:
4//!
5//! - **BFS paths** — all paths between two entity nodes up to a depth limit.
6//! - **DFS paths** — depth-first enumeration with cycle detection.
7//! - **Shortest path** — minimum hop count (BFS-based).
8//! - **Predicate filtering** — only traverse edges whose predicate IRI is in an
9//!   allow-list.
10//! - **Path scoring** — weight edges by predicate relevance.
11//! - **Path narrative** — convert a path into a human-readable sentence.
12//! - **Multi-hop collection** — all paths up to depth N.
13//! - **Cycle detection** — prevent infinite traversal on cyclic graphs.
14//!
15//! ## Example
16//!
17//! ```rust
18//! use oxirs_graphrag::path_finder::{PathFinder, KnowledgeEdge, PathFinderConfig};
19//!
20//! let edges = vec![
21//!     KnowledgeEdge::new("Alice", "knows", "Bob"),
22//!     KnowledgeEdge::new("Bob", "works_at", "ACME"),
23//!     KnowledgeEdge::new("ACME", "located_in", "Berlin"),
24//! ];
25//!
26//! let config = PathFinderConfig::default();
27//! let finder = PathFinder::new(edges, config);
28//!
29//! let paths = finder.bfs_paths("Alice", "ACME", 3);
30//! assert!(!paths.is_empty());
31//! assert_eq!(paths[0].nodes[0], "Alice");
32//! assert_eq!(*paths[0].nodes.last().expect("should succeed"), "ACME");
33//! ```
34
35use std::collections::{HashMap, HashSet, VecDeque};
36
37// ─────────────────────────────────────────────────────────────────────────────
38// Private type aliases
39// ─────────────────────────────────────────────────────────────────────────────
40
41/// BFS queue entry: (current_node, path_nodes, path_predicates, visited_set).
42type BfsQueueEntry = (String, Vec<String>, Vec<String>, HashSet<String>);
43
44// ─────────────────────────────────────────────────────────────────────────────
45// Public types
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// A directed edge in the knowledge graph.
49#[derive(Debug, Clone, PartialEq)]
50pub struct KnowledgeEdge {
51    /// Subject / source node IRI.
52    pub subject: String,
53    /// Predicate / relation IRI.
54    pub predicate: String,
55    /// Object / target node IRI.
56    pub object: String,
57}
58
59impl KnowledgeEdge {
60    /// Create a new edge.
61    pub fn new(
62        subject: impl Into<String>,
63        predicate: impl Into<String>,
64        object: impl Into<String>,
65    ) -> Self {
66        Self {
67            subject: subject.into(),
68            predicate: predicate.into(),
69            object: object.into(),
70        }
71    }
72}
73
74/// A path through the knowledge graph represented as an ordered list of nodes
75/// and the edges traversed.
76#[derive(Debug, Clone, PartialEq)]
77pub struct KnowledgePath {
78    /// Ordered node sequence (subject → ... → object).
79    pub nodes: Vec<String>,
80    /// Predicates traversed at each step (length = `nodes.len() - 1`).
81    pub predicates: Vec<String>,
82    /// Number of hops.
83    pub hop_count: usize,
84    /// Aggregate path score (sum of edge weights).
85    pub score: f64,
86}
87
88impl KnowledgePath {
89    /// Build a human-readable narrative of the path.
90    ///
91    /// Example: `"Alice —[knows]→ Bob —[works_at]→ ACME"`
92    pub fn narrative(&self) -> String {
93        if self.nodes.is_empty() {
94            return String::new();
95        }
96        let mut parts = vec![self.nodes[0].clone()];
97        for (pred, node) in self.predicates.iter().zip(self.nodes.iter().skip(1)) {
98            parts.push(format!("—[{pred}]→ {node}"));
99        }
100        parts.join(" ")
101    }
102}
103
104/// Configuration for the [`PathFinder`].
105#[derive(Debug, Clone)]
106pub struct PathFinderConfig {
107    /// Maximum hop depth for BFS / DFS searches.
108    pub max_depth: usize,
109    /// Maximum number of paths returned per query.
110    pub max_paths: usize,
111    /// Optional set of predicate IRIs to traverse.  When `None`, all
112    /// predicates are traversed.
113    pub allowed_predicates: Option<HashSet<String>>,
114    /// Predicate relevance weights for path scoring.  Unlisted predicates
115    /// receive weight `1.0`.
116    pub predicate_weights: HashMap<String, f64>,
117}
118
119impl Default for PathFinderConfig {
120    fn default() -> Self {
121        Self {
122            max_depth: 4,
123            max_paths: 50,
124            allowed_predicates: None,
125            predicate_weights: HashMap::new(),
126        }
127    }
128}
129
130/// Statistics produced by a path-finding run.
131#[derive(Debug, Clone)]
132pub struct PathFinderStats {
133    /// Number of paths found.
134    pub paths_found: usize,
135    /// Minimum hop count across all paths.
136    pub min_hops: Option<usize>,
137    /// Maximum hop count across all paths.
138    pub max_hops: Option<usize>,
139    /// Nodes visited during the search.
140    pub nodes_visited: usize,
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// PathFinder
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Knowledge-graph path finder.
148pub struct PathFinder {
149    /// Adjacency list: source → list of (predicate, target).
150    adj: HashMap<String, Vec<(String, String)>>,
151    config: PathFinderConfig,
152}
153
154impl PathFinder {
155    /// Build a path finder from a set of edges.
156    pub fn new(edges: Vec<KnowledgeEdge>, config: PathFinderConfig) -> Self {
157        let mut adj: HashMap<String, Vec<(String, String)>> = HashMap::new();
158        for edge in edges {
159            adj.entry(edge.subject.clone())
160                .or_default()
161                .push((edge.predicate.clone(), edge.object.clone()));
162        }
163        Self { adj, config }
164    }
165
166    /// Add a single edge to the graph.
167    pub fn add_edge(&mut self, edge: KnowledgeEdge) {
168        self.adj
169            .entry(edge.subject)
170            .or_default()
171            .push((edge.predicate, edge.object));
172    }
173
174    /// Find all paths from `source` to `target` using **BFS** up to
175    /// `max_depth` hops.
176    ///
177    /// Returns paths sorted by score (descending).
178    pub fn bfs_paths(&self, source: &str, target: &str, max_depth: usize) -> Vec<KnowledgePath> {
179        let depth = max_depth.min(self.config.max_depth);
180        let mut results = Vec::new();
181
182        // BFS queue: (current_node, path_nodes, path_predicates, visited_set)
183        let mut queue: VecDeque<BfsQueueEntry> = VecDeque::new();
184        let mut start_visited = HashSet::new();
185        start_visited.insert(source.to_string());
186        queue.push_back((
187            source.to_string(),
188            vec![source.to_string()],
189            Vec::new(),
190            start_visited,
191        ));
192
193        while let Some((current, nodes, preds, visited)) = queue.pop_front() {
194            if nodes.len() > depth {
195                continue;
196            }
197            if let Some(neighbours) = self.adj.get(&current) {
198                for (pred, next) in neighbours {
199                    if !self.is_allowed(pred) {
200                        continue;
201                    }
202                    if visited.contains(next.as_str()) {
203                        continue; // cycle detection
204                    }
205                    let mut new_nodes = nodes.clone();
206                    let mut new_preds = preds.clone();
207                    let mut new_visited = visited.clone();
208                    new_nodes.push(next.clone());
209                    new_preds.push(pred.clone());
210                    new_visited.insert(next.clone());
211
212                    if next == target {
213                        let score = self.score_path(&new_preds);
214                        results.push(KnowledgePath {
215                            hop_count: new_preds.len(),
216                            nodes: new_nodes,
217                            predicates: new_preds,
218                            score,
219                        });
220                        if results.len() >= self.config.max_paths {
221                            results.sort_by(|a, b| {
222                                b.score
223                                    .partial_cmp(&a.score)
224                                    .unwrap_or(std::cmp::Ordering::Equal)
225                            });
226                            return results;
227                        }
228                    } else {
229                        queue.push_back((next.clone(), new_nodes, new_preds, new_visited));
230                    }
231                }
232            }
233        }
234
235        results.sort_by(|a, b| {
236            b.score
237                .partial_cmp(&a.score)
238                .unwrap_or(std::cmp::Ordering::Equal)
239        });
240        results
241    }
242
243    /// Find all paths from `source` to `target` using **DFS** up to
244    /// `max_depth` hops.
245    pub fn dfs_paths(&self, source: &str, target: &str, max_depth: usize) -> Vec<KnowledgePath> {
246        let depth = max_depth.min(self.config.max_depth);
247        let mut results = Vec::new();
248        let mut visited = HashSet::new();
249        visited.insert(source.to_string());
250        self.dfs_recursive(
251            source,
252            target,
253            &mut vec![source.to_string()],
254            &mut Vec::new(),
255            &mut visited,
256            depth,
257            &mut results,
258        );
259        results.sort_by(|a, b| {
260            b.score
261                .partial_cmp(&a.score)
262                .unwrap_or(std::cmp::Ordering::Equal)
263        });
264        results
265    }
266
267    /// Find the **shortest path** (minimum hops) from `source` to `target`.
268    ///
269    /// Returns `None` when no path exists within `max_depth`.
270    pub fn shortest_path(&self, source: &str, target: &str) -> Option<KnowledgePath> {
271        // BFS naturally finds the shortest path first
272        let paths = self.bfs_paths(source, target, self.config.max_depth);
273        paths.into_iter().min_by_key(|p| p.hop_count)
274    }
275
276    /// Collect **all paths** up to depth N (multi-hop collection).
277    ///
278    /// Returns paths to all reachable nodes that are different from `source`.
279    /// Useful for building context windows.
280    pub fn multi_hop_paths(&self, source: &str, max_depth: usize) -> Vec<KnowledgePath> {
281        let depth = max_depth.min(self.config.max_depth);
282        let mut results = Vec::new();
283
284        // Collect all nodes reachable from source
285        let reachable: HashSet<String> = self
286            .adj
287            .values()
288            .flat_map(|nbrs| nbrs.iter().map(|(_, t)| t.clone()))
289            .chain(self.adj.keys().cloned())
290            .collect();
291
292        for target in &reachable {
293            if target == source {
294                continue;
295            }
296            let mut sub_paths = self.bfs_paths(source, target, depth);
297            results.append(&mut sub_paths);
298            if results.len() >= self.config.max_paths {
299                break;
300            }
301        }
302
303        results.sort_by(|a, b| {
304            b.score
305                .partial_cmp(&a.score)
306                .unwrap_or(std::cmp::Ordering::Equal)
307        });
308        results.truncate(self.config.max_paths);
309        results
310    }
311
312    /// Compute search statistics for the paths returned by a previous search.
313    pub fn stats(&self, paths: &[KnowledgePath]) -> PathFinderStats {
314        let nodes_visited: HashSet<&str> = paths
315            .iter()
316            .flat_map(|p| p.nodes.iter().map(String::as_str))
317            .collect();
318        PathFinderStats {
319            paths_found: paths.len(),
320            min_hops: paths.iter().map(|p| p.hop_count).min(),
321            max_hops: paths.iter().map(|p| p.hop_count).max(),
322            nodes_visited: nodes_visited.len(),
323        }
324    }
325
326    /// Number of nodes in the graph.
327    pub fn node_count(&self) -> usize {
328        let subjects: HashSet<&str> = self.adj.keys().map(String::as_str).collect();
329        let objects: HashSet<&str> = self
330            .adj
331            .values()
332            .flat_map(|nbrs| nbrs.iter().map(|(_, t)| t.as_str()))
333            .collect();
334        subjects.union(&objects).count()
335    }
336
337    /// Number of edges in the graph.
338    pub fn edge_count(&self) -> usize {
339        self.adj.values().map(|nbrs| nbrs.len()).sum()
340    }
341
342    // ── Private helpers ──────────────────────────────────────────────────────
343
344    #[allow(clippy::too_many_arguments)]
345    fn dfs_recursive(
346        &self,
347        current: &str,
348        target: &str,
349        nodes: &mut Vec<String>,
350        predicates: &mut Vec<String>,
351        visited: &mut HashSet<String>,
352        remaining_depth: usize,
353        results: &mut Vec<KnowledgePath>,
354    ) {
355        if remaining_depth == 0 {
356            return;
357        }
358        if let Some(neighbours) = self.adj.get(current) {
359            for (pred, next) in neighbours {
360                if !self.is_allowed(pred) {
361                    continue;
362                }
363                if visited.contains(next.as_str()) {
364                    continue; // cycle detection
365                }
366                nodes.push(next.clone());
367                predicates.push(pred.clone());
368                visited.insert(next.clone());
369
370                if next == target {
371                    let score = self.score_path(predicates);
372                    results.push(KnowledgePath {
373                        hop_count: predicates.len(),
374                        nodes: nodes.clone(),
375                        predicates: predicates.clone(),
376                        score,
377                    });
378                } else {
379                    self.dfs_recursive(
380                        next,
381                        target,
382                        nodes,
383                        predicates,
384                        visited,
385                        remaining_depth - 1,
386                        results,
387                    );
388                }
389
390                nodes.pop();
391                predicates.pop();
392                visited.remove(next.as_str());
393
394                if results.len() >= self.config.max_paths {
395                    return;
396                }
397            }
398        }
399    }
400
401    /// Return `true` if the predicate is allowed by the configuration.
402    fn is_allowed(&self, predicate: &str) -> bool {
403        match &self.config.allowed_predicates {
404            None => true,
405            Some(allowed) => allowed.contains(predicate),
406        }
407    }
408
409    /// Compute the aggregate score for a path (sum of edge weights).
410    fn score_path(&self, predicates: &[String]) -> f64 {
411        predicates
412            .iter()
413            .map(|p| {
414                *self
415                    .config
416                    .predicate_weights
417                    .get(p.as_str())
418                    .unwrap_or(&1.0)
419            })
420            .sum()
421    }
422}
423
424// ─────────────────────────────────────────────────────────────────────────────
425// Tests
426// ─────────────────────────────────────────────────────────────────────────────
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    // ── Test helpers ─────────────────────────────────────────────────────────
433
434    fn simple_edges() -> Vec<KnowledgeEdge> {
435        vec![
436            KnowledgeEdge::new("Alice", "knows", "Bob"),
437            KnowledgeEdge::new("Bob", "works_at", "ACME"),
438            KnowledgeEdge::new("ACME", "located_in", "Berlin"),
439            KnowledgeEdge::new("Alice", "lives_in", "Berlin"),
440            KnowledgeEdge::new("Bob", "knows", "Carol"),
441        ]
442    }
443
444    fn default_finder() -> PathFinder {
445        PathFinder::new(simple_edges(), PathFinderConfig::default())
446    }
447
448    // ── BFS paths ────────────────────────────────────────────────────────────
449
450    #[test]
451    fn test_bfs_direct_connection() {
452        let finder = default_finder();
453        let paths = finder.bfs_paths("Alice", "Bob", 1);
454        assert!(!paths.is_empty());
455        assert_eq!(paths[0].hop_count, 1);
456        assert_eq!(paths[0].nodes[0], "Alice");
457        assert_eq!(paths[0].nodes[1], "Bob");
458    }
459
460    #[test]
461    fn test_bfs_two_hop_path() {
462        let finder = default_finder();
463        let paths = finder.bfs_paths("Alice", "ACME", 2);
464        assert!(!paths.is_empty());
465        assert!(paths.iter().any(|p| p.hop_count == 2));
466    }
467
468    #[test]
469    fn test_bfs_no_path_beyond_depth() {
470        let finder = default_finder();
471        // Berlin is 3 hops from Alice via ACME, but we limit to 1
472        let paths = finder.bfs_paths("Alice", "Berlin", 1);
473        // Direct Alice→Berlin (lives_in) has 1 hop — should be found
474        assert!(!paths.is_empty());
475        // But Alice→Bob→ACME→Berlin requires 3 hops — not returned
476        assert!(paths.iter().all(|p| p.hop_count <= 1));
477    }
478
479    #[test]
480    fn test_bfs_no_such_path_returns_empty() {
481        let finder = default_finder();
482        let paths = finder.bfs_paths("Berlin", "Alice", 5);
483        // Graph is directed — Berlin has no outgoing edges
484        assert!(paths.is_empty());
485    }
486
487    #[test]
488    fn test_bfs_paths_sorted_by_score_desc() {
489        let finder = default_finder();
490        let paths = finder.bfs_paths("Alice", "Berlin", 4);
491        for w in paths.windows(2) {
492            assert!(w[0].score >= w[1].score - 1e-10);
493        }
494    }
495
496    #[test]
497    fn test_bfs_respects_max_paths() {
498        let config = PathFinderConfig {
499            max_paths: 1,
500            ..Default::default()
501        };
502        let finder = PathFinder::new(simple_edges(), config);
503        let paths = finder.bfs_paths("Alice", "Berlin", 4);
504        assert!(paths.len() <= 1);
505    }
506
507    // ── DFS paths ────────────────────────────────────────────────────────────
508
509    #[test]
510    fn test_dfs_finds_paths() {
511        let finder = default_finder();
512        let paths = finder.dfs_paths("Alice", "Bob", 2);
513        assert!(!paths.is_empty());
514    }
515
516    #[test]
517    fn test_dfs_hop_count_within_depth() {
518        let finder = default_finder();
519        let paths = finder.dfs_paths("Alice", "ACME", 3);
520        for p in &paths {
521            assert!(p.hop_count <= 3);
522        }
523    }
524
525    #[test]
526    fn test_dfs_no_cycles_in_paths() {
527        let finder = default_finder();
528        let paths = finder.dfs_paths("Alice", "Berlin", 5);
529        for p in &paths {
530            let mut seen = HashSet::new();
531            for node in &p.nodes {
532                assert!(seen.insert(node), "cycle detected in path: {node}");
533            }
534        }
535    }
536
537    #[test]
538    fn test_dfs_no_path_returns_empty() {
539        let finder = default_finder();
540        let paths = finder.dfs_paths("Berlin", "Alice", 5);
541        assert!(paths.is_empty());
542    }
543
544    // ── Shortest path ─────────────────────────────────────────────────────────
545
546    #[test]
547    fn test_shortest_path_direct() {
548        let finder = default_finder();
549        let path = finder
550            .shortest_path("Alice", "Bob")
551            .expect("should succeed");
552        assert_eq!(path.hop_count, 1);
553    }
554
555    #[test]
556    fn test_shortest_path_two_hops() {
557        let finder = default_finder();
558        let path = finder
559            .shortest_path("Alice", "ACME")
560            .expect("should succeed");
561        assert_eq!(path.hop_count, 2);
562    }
563
564    #[test]
565    fn test_shortest_path_no_path_is_none() {
566        let finder = default_finder();
567        let path = finder.shortest_path("Berlin", "Alice");
568        assert!(path.is_none());
569    }
570
571    #[test]
572    fn test_shortest_path_self_loop_check() {
573        let finder = default_finder();
574        // source == target: BFS won't find it since we start with source visited
575        let path = finder.shortest_path("Alice", "Alice");
576        assert!(path.is_none());
577    }
578
579    // ── Predicate filtering ───────────────────────────────────────────────────
580
581    #[test]
582    fn test_predicate_filter_restricts_traversal() {
583        let mut allowed = HashSet::new();
584        allowed.insert("knows".to_string());
585        let config = PathFinderConfig {
586            allowed_predicates: Some(allowed),
587            ..Default::default()
588        };
589        let finder = PathFinder::new(simple_edges(), config);
590        // Alice→Bob (knows) is allowed; Alice→ACME (works_at) is not
591        let paths = finder.bfs_paths("Alice", "ACME", 3);
592        // ACME can only be reached via works_at — filtered out → no paths
593        assert!(paths.is_empty());
594    }
595
596    #[test]
597    fn test_predicate_filter_allows_direct_path() {
598        let mut allowed = HashSet::new();
599        allowed.insert("knows".to_string());
600        allowed.insert("works_at".to_string());
601        let config = PathFinderConfig {
602            allowed_predicates: Some(allowed),
603            ..Default::default()
604        };
605        let finder = PathFinder::new(simple_edges(), config);
606        let paths = finder.bfs_paths("Alice", "ACME", 3);
607        assert!(!paths.is_empty());
608    }
609
610    // ── Path scoring ──────────────────────────────────────────────────────────
611
612    #[test]
613    fn test_path_scoring_uses_weights() {
614        let mut weights = HashMap::new();
615        weights.insert("knows".to_string(), 2.0);
616        weights.insert("works_at".to_string(), 1.0);
617        let config = PathFinderConfig {
618            predicate_weights: weights,
619            ..Default::default()
620        };
621        let finder = PathFinder::new(simple_edges(), config);
622        let paths = finder.bfs_paths("Alice", "ACME", 3);
623        // Path Alice→Bob(knows,w=2)→ACME(works_at,w=1) → score=3
624        assert!(!paths.is_empty());
625        let best = &paths[0];
626        assert!((best.score - 3.0).abs() < 1e-10);
627    }
628
629    #[test]
630    fn test_path_scoring_default_weight_one() {
631        let finder = default_finder();
632        let paths = finder.bfs_paths("Alice", "Bob", 1);
633        assert!(!paths.is_empty());
634        // 1 hop, default weight 1.0 → score = 1.0
635        assert!((paths[0].score - 1.0).abs() < 1e-10);
636    }
637
638    // ── Narrative ─────────────────────────────────────────────────────────────
639
640    #[test]
641    fn test_narrative_single_hop() {
642        let path = KnowledgePath {
643            nodes: vec!["Alice".into(), "Bob".into()],
644            predicates: vec!["knows".into()],
645            hop_count: 1,
646            score: 1.0,
647        };
648        let narrative = path.narrative();
649        assert!(narrative.contains("Alice"));
650        assert!(narrative.contains("knows"));
651        assert!(narrative.contains("Bob"));
652    }
653
654    #[test]
655    fn test_narrative_multi_hop() {
656        let path = KnowledgePath {
657            nodes: vec!["Alice".into(), "Bob".into(), "ACME".into()],
658            predicates: vec!["knows".into(), "works_at".into()],
659            hop_count: 2,
660            score: 2.0,
661        };
662        let narrative = path.narrative();
663        assert!(narrative.contains("—[knows]→"));
664        assert!(narrative.contains("—[works_at]→"));
665    }
666
667    #[test]
668    fn test_narrative_empty_path() {
669        let path = KnowledgePath {
670            nodes: vec![],
671            predicates: vec![],
672            hop_count: 0,
673            score: 0.0,
674        };
675        assert_eq!(path.narrative(), "");
676    }
677
678    // ── Multi-hop collection ──────────────────────────────────────────────────
679
680    #[test]
681    fn test_multi_hop_returns_paths() {
682        let finder = default_finder();
683        let paths = finder.multi_hop_paths("Alice", 3);
684        assert!(!paths.is_empty());
685    }
686
687    #[test]
688    fn test_multi_hop_all_start_at_source() {
689        let finder = default_finder();
690        let paths = finder.multi_hop_paths("Alice", 2);
691        for p in &paths {
692            assert_eq!(p.nodes[0], "Alice");
693        }
694    }
695
696    #[test]
697    fn test_multi_hop_respects_depth() {
698        let finder = default_finder();
699        let paths = finder.multi_hop_paths("Alice", 1);
700        for p in &paths {
701            assert!(p.hop_count <= 1);
702        }
703    }
704
705    // ── Cycle detection ───────────────────────────────────────────────────────
706
707    #[test]
708    fn test_bfs_no_cycles_even_in_cyclic_graph() {
709        let edges = vec![
710            KnowledgeEdge::new("A", "rel", "B"),
711            KnowledgeEdge::new("B", "rel", "C"),
712            KnowledgeEdge::new("C", "rel", "A"), // cycle
713            KnowledgeEdge::new("B", "rel", "D"),
714        ];
715        let finder = PathFinder::new(edges, PathFinderConfig::default());
716        let paths = finder.bfs_paths("A", "D", 4);
717        // Should find A→B→D without looping
718        assert!(!paths.is_empty());
719        for p in &paths {
720            let mut seen = HashSet::new();
721            for node in &p.nodes {
722                assert!(seen.insert(node));
723            }
724        }
725    }
726
727    #[test]
728    fn test_dfs_no_cycles_in_cyclic_graph() {
729        let edges = vec![
730            KnowledgeEdge::new("X", "p", "Y"),
731            KnowledgeEdge::new("Y", "p", "Z"),
732            KnowledgeEdge::new("Z", "p", "X"), // cycle
733        ];
734        let finder = PathFinder::new(edges, PathFinderConfig::default());
735        let paths = finder.dfs_paths("X", "Z", 5);
736        for p in &paths {
737            let mut seen = HashSet::new();
738            for node in &p.nodes {
739                assert!(seen.insert(node));
740            }
741        }
742    }
743
744    // ── Statistics ────────────────────────────────────────────────────────────
745
746    #[test]
747    fn test_stats_empty_paths() {
748        let finder = default_finder();
749        let stats = finder.stats(&[]);
750        assert_eq!(stats.paths_found, 0);
751        assert!(stats.min_hops.is_none());
752        assert!(stats.max_hops.is_none());
753    }
754
755    #[test]
756    fn test_stats_populated() {
757        let finder = default_finder();
758        let paths = finder.bfs_paths("Alice", "Berlin", 4);
759        let stats = finder.stats(&paths);
760        assert_eq!(stats.paths_found, paths.len());
761        assert!(stats.nodes_visited > 0);
762    }
763
764    #[test]
765    fn test_stats_min_max_hops() {
766        let finder = default_finder();
767        let paths = finder.bfs_paths("Alice", "Berlin", 4);
768        if !paths.is_empty() {
769            let stats = finder.stats(&paths);
770            assert!(
771                stats.min_hops.expect("should succeed") <= stats.max_hops.expect("should succeed")
772            );
773        }
774    }
775
776    // ── Graph mutation ─────────────────────────────────────────────────────────
777
778    #[test]
779    fn test_add_edge_extends_graph() {
780        let mut finder = default_finder();
781        let before = finder.edge_count();
782        finder.add_edge(KnowledgeEdge::new("Carol", "works_at", "GlobalCo"));
783        assert_eq!(finder.edge_count(), before + 1);
784    }
785
786    #[test]
787    fn test_node_count() {
788        let finder = default_finder();
789        // Alice, Bob, ACME, Berlin, Carol
790        assert!(finder.node_count() >= 5);
791    }
792
793    #[test]
794    fn test_edge_count() {
795        let finder = default_finder();
796        assert_eq!(finder.edge_count(), simple_edges().len());
797    }
798
799    // ── KnowledgeEdge ─────────────────────────────────────────────────────────
800
801    #[test]
802    fn test_edge_fields() {
803        let edge = KnowledgeEdge::new("S", "p", "O");
804        assert_eq!(edge.subject, "S");
805        assert_eq!(edge.predicate, "p");
806        assert_eq!(edge.object, "O");
807    }
808
809    #[test]
810    fn test_edge_clone() {
811        let edge = KnowledgeEdge::new("A", "b", "C");
812        let cloned = edge.clone();
813        assert_eq!(cloned, edge);
814    }
815
816    // ── PathFinderConfig ──────────────────────────────────────────────────────
817
818    #[test]
819    fn test_config_defaults() {
820        let cfg = PathFinderConfig::default();
821        assert_eq!(cfg.max_depth, 4);
822        assert_eq!(cfg.max_paths, 50);
823        assert!(cfg.allowed_predicates.is_none());
824        assert!(cfg.predicate_weights.is_empty());
825    }
826
827    // ── KnowledgePath ─────────────────────────────────────────────────────────
828
829    #[test]
830    fn test_knowledge_path_fields() {
831        let p = KnowledgePath {
832            nodes: vec!["A".into(), "B".into()],
833            predicates: vec!["rel".into()],
834            hop_count: 1,
835            score: 2.5,
836        };
837        assert_eq!(p.hop_count, 1);
838        assert!((p.score - 2.5).abs() < 1e-10);
839    }
840
841    #[test]
842    fn test_bfs_and_dfs_agree_on_shortest_path() {
843        let finder = default_finder();
844        let bfs = finder.bfs_paths("Alice", "Bob", 3);
845        let dfs = finder.dfs_paths("Alice", "Bob", 3);
846        // Both should find the direct 1-hop path
847        assert!(bfs.iter().any(|p| p.hop_count == 1));
848        assert!(dfs.iter().any(|p| p.hop_count == 1));
849    }
850
851    #[test]
852    fn test_multi_hop_no_self_paths() {
853        let finder = default_finder();
854        let paths = finder.multi_hop_paths("Alice", 3);
855        for p in &paths {
856            assert_ne!(p.nodes.first(), p.nodes.last().or(Some(&String::new())));
857            assert!(p.hop_count > 0);
858        }
859    }
860}