1use std::collections::{HashMap, HashSet, VecDeque};
36
37type BfsQueueEntry = (String, Vec<String>, Vec<String>, HashSet<String>);
43
44#[derive(Debug, Clone, PartialEq)]
50pub struct KnowledgeEdge {
51 pub subject: String,
53 pub predicate: String,
55 pub object: String,
57}
58
59impl KnowledgeEdge {
60 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#[derive(Debug, Clone, PartialEq)]
77pub struct KnowledgePath {
78 pub nodes: Vec<String>,
80 pub predicates: Vec<String>,
82 pub hop_count: usize,
84 pub score: f64,
86}
87
88impl KnowledgePath {
89 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#[derive(Debug, Clone)]
106pub struct PathFinderConfig {
107 pub max_depth: usize,
109 pub max_paths: usize,
111 pub allowed_predicates: Option<HashSet<String>>,
114 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#[derive(Debug, Clone)]
132pub struct PathFinderStats {
133 pub paths_found: usize,
135 pub min_hops: Option<usize>,
137 pub max_hops: Option<usize>,
139 pub nodes_visited: usize,
141}
142
143pub struct PathFinder {
149 adj: HashMap<String, Vec<(String, String)>>,
151 config: PathFinderConfig,
152}
153
154impl PathFinder {
155 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 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 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 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(¤t) {
198 for (pred, next) in neighbours {
199 if !self.is_allowed(pred) {
200 continue;
201 }
202 if visited.contains(next.as_str()) {
203 continue; }
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 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 pub fn shortest_path(&self, source: &str, target: &str) -> Option<KnowledgePath> {
271 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 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 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 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 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 pub fn edge_count(&self) -> usize {
339 self.adj.values().map(|nbrs| nbrs.len()).sum()
340 }
341
342 #[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; }
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 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 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#[cfg(test)]
429mod tests {
430 use super::*;
431
432 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 #[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 let paths = finder.bfs_paths("Alice", "Berlin", 1);
473 assert!(!paths.is_empty());
475 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 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 #[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 #[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 let path = finder.shortest_path("Alice", "Alice");
576 assert!(path.is_none());
577 }
578
579 #[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 let paths = finder.bfs_paths("Alice", "ACME", 3);
592 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 #[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 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 assert!((paths[0].score - 1.0).abs() < 1e-10);
636 }
637
638 #[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 #[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 #[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"), KnowledgeEdge::new("B", "rel", "D"),
714 ];
715 let finder = PathFinder::new(edges, PathFinderConfig::default());
716 let paths = finder.bfs_paths("A", "D", 4);
717 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"), ];
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 #[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 #[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 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 #[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 #[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 #[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 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}