1use crate::ir::{KnowledgeBase, Predicate, Term};
23use crate::reasoning::{apply_subst_predicate, rename_rule_vars, unify_predicates, Substitution};
24use futures::future::BoxFuture;
25use ipfrs_core::Cid;
26use std::collections::{HashMap, HashSet};
27
28fn fnv1a_hash_term(term: &Term) -> u64 {
35 const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
36 const PRIME: u64 = 1_099_511_628_211;
37
38 let repr = format!("{:?}", term);
39 let mut hash = OFFSET_BASIS;
40 for byte in repr.bytes() {
41 hash ^= u64::from(byte);
42 hash = hash.wrapping_mul(PRIME);
43 }
44 hash
45}
46
47pub struct VisitedSet {
54 entries: HashSet<(String, u64)>,
55 max_entries: usize,
56}
57
58impl VisitedSet {
59 pub fn new(max_entries: usize) -> Self {
61 Self {
62 entries: HashSet::new(),
63 max_entries,
64 }
65 }
66
67 pub fn try_visit(&mut self, peer_id: &str, goal: &Term) -> bool {
72 if self.entries.len() >= self.max_entries {
73 return false;
74 }
75 let hash = fnv1a_hash_term(goal);
76 self.entries.insert((peer_id.to_string(), hash))
77 }
78
79 pub fn len(&self) -> usize {
81 self.entries.len()
82 }
83
84 pub fn is_empty(&self) -> bool {
86 self.entries.is_empty()
87 }
88
89 pub fn contains(&self, peer_id: &str, goal: &Term) -> bool {
92 let hash = fnv1a_hash_term(goal);
93 self.entries.contains(&(peer_id.to_string(), hash))
94 }
95}
96
97#[derive(Debug, Clone)]
101pub struct HopRecord {
102 pub hop_index: usize,
104 pub peer_id: Option<String>,
106 pub goal: String,
108 pub resolved: bool,
110}
111
112#[derive(Debug, Clone)]
116pub struct HopTrace {
117 hops: Vec<HopRecord>,
118}
119
120impl HopTrace {
121 pub fn new() -> Self {
123 Self { hops: Vec::new() }
124 }
125
126 pub fn push(&mut self, record: HopRecord) {
128 self.hops.push(record);
129 }
130
131 pub fn len(&self) -> usize {
133 self.hops.len()
134 }
135
136 pub fn is_empty(&self) -> bool {
138 self.hops.is_empty()
139 }
140
141 pub fn hops(&self) -> &[HopRecord] {
143 &self.hops
144 }
145
146 pub fn remote_hops(&self) -> Vec<&HopRecord> {
148 self.hops.iter().filter(|h| h.peer_id.is_some()).collect()
149 }
150
151 pub fn max_depth(&self) -> usize {
153 self.hops.len()
154 }
155
156 pub fn resolved_count(&self) -> usize {
158 self.hops.iter().filter(|h| h.resolved).count()
159 }
160
161 pub fn all_peers(&self) -> Vec<String> {
163 let mut seen: HashSet<String> = HashSet::new();
164 let mut peers: Vec<String> = Vec::new();
165 for hop in &self.hops {
166 if let Some(ref pid) = hop.peer_id {
167 if seen.insert(pid.clone()) {
168 peers.push(pid.clone());
169 }
170 }
171 }
172 peers
173 }
174}
175
176impl Default for HopTrace {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182pub struct MultiHopConfig {
186 pub max_hops: usize,
188 pub max_depth: usize,
190 pub max_visited: usize,
192 pub max_remote_peers: usize,
194 pub timeout_ms: u64,
196}
197
198impl Default for MultiHopConfig {
199 fn default() -> Self {
200 Self {
201 max_hops: 5,
202 max_depth: 10,
203 max_visited: 1000,
204 max_remote_peers: 3,
205 timeout_ms: 15_000,
206 }
207 }
208}
209
210pub struct MultiHopResult {
214 pub resolved: bool,
216 pub trace: HopTrace,
218 pub bindings: HashMap<String, Term>,
220}
221
222fn try_local(goal: &Term, kb: &KnowledgeBase, max_depth: usize) -> bool {
231 try_local_recursive(goal, kb, 0, max_depth)
232}
233
234fn try_local_recursive(goal: &Term, kb: &KnowledgeBase, depth: usize, max_depth: usize) -> bool {
235 if depth > max_depth {
236 return false;
237 }
238
239 let pred = match term_to_predicate_local(goal) {
240 Some(p) => p,
241 None => return goal.is_ground(),
242 };
243
244 let empty_subst = Substitution::new();
245 let pred = apply_subst_predicate(&pred, &empty_subst);
246
247 let facts: Vec<_> = kb.get_predicates(&pred.name).into_iter().cloned().collect();
249 for fact in &facts {
250 if unify_predicates(&pred, fact, &empty_subst).is_some() {
251 return true;
252 }
253 }
254
255 let rules: Vec<_> = kb.get_rules(&pred.name).into_iter().cloned().collect();
257 for rule in &rules {
258 let renamed = rename_rule_vars(rule, depth);
259 if let Some(_new_subst) = unify_predicates(&pred, &renamed.head, &empty_subst) {
260 let all_body_ok = renamed.body.iter().all(|body_pred| {
262 let body_term = Term::Fun(body_pred.name.clone(), body_pred.args.clone());
263 try_local_recursive(&body_term, kb, depth + 1, max_depth)
264 });
265 if all_body_ok {
266 return true;
267 }
268 }
269 }
270
271 false
272}
273
274fn term_to_predicate_local(term: &Term) -> Option<Predicate> {
275 match term {
276 Term::Fun(name, args) => Some(Predicate::new(name.clone(), args.clone())),
277 Term::Const(crate::ir::Constant::String(s)) => Some(Predicate::new(s.clone(), Vec::new())),
278 _ => None,
279 }
280}
281
282struct ResolveCtx<'a, FP, FQ> {
287 visited: &'a mut VisitedSet,
288 trace: &'a mut HopTrace,
289 find_providers: &'a FP,
290 remote_query: &'a FQ,
291}
292
293pub struct MultiHopResolver {
299 config: MultiHopConfig,
300}
301
302impl MultiHopResolver {
303 pub fn new(config: MultiHopConfig) -> Self {
305 Self { config }
306 }
307
308 pub async fn resolve<FP, FQ>(
321 &self,
322 goal: &Term,
323 local_kb: &KnowledgeBase,
324 find_providers: &FP,
325 remote_query: &FQ,
326 ) -> MultiHopResult
327 where
328 FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync,
329 FQ: Fn(String, Term) -> BoxFuture<'static, Option<Vec<HashMap<String, Term>>>>
330 + Send
331 + Sync,
332 {
333 let mut visited = VisitedSet::new(self.config.max_visited);
334 let mut trace = HopTrace::new();
335
336 let resolved = {
337 let mut ctx = ResolveCtx {
338 visited: &mut visited,
339 trace: &mut trace,
340 find_providers,
341 remote_query,
342 };
343 self.resolve_inner(goal, local_kb, 0, &mut ctx).await
344 };
345
346 MultiHopResult {
347 resolved,
348 trace,
349 bindings: HashMap::new(),
350 }
351 }
352
353 fn resolve_inner<'a, FP, FQ>(
359 &'a self,
360 goal: &'a Term,
361 kb: &'a KnowledgeBase,
362 hop: usize,
363 ctx: &'a mut ResolveCtx<'a, FP, FQ>,
364 ) -> BoxFuture<'a, bool>
365 where
366 FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync,
367 FQ: Fn(String, Term) -> BoxFuture<'static, Option<Vec<HashMap<String, Term>>>>
368 + Send
369 + Sync,
370 {
371 Box::pin(async move {
372 if hop > self.config.max_hops {
374 return false;
375 }
376
377 if !ctx.visited.try_visit("local", goal) {
379 return false;
380 }
381
382 if try_local(goal, kb, self.config.max_depth) {
384 ctx.trace.push(HopRecord {
385 hop_index: hop,
386 peer_id: None,
387 goal: format!("{:?}", goal),
388 resolved: true,
389 });
390 return true;
391 }
392
393 let peer_ids = self.collect_peer_ids(goal, kb, ctx.find_providers).await;
395
396 for peer_id in peer_ids {
398 if !ctx.visited.try_visit(&peer_id, goal) {
400 continue;
401 }
402
403 let result = (ctx.remote_query)(peer_id.clone(), goal.clone()).await;
404 if let Some(bindings_list) = result {
405 if !bindings_list.is_empty() {
406 ctx.trace.push(HopRecord {
407 hop_index: hop,
408 peer_id: Some(peer_id),
409 goal: format!("{:?}", goal),
410 resolved: true,
411 });
412 return true;
413 }
414 }
415
416 ctx.trace.push(HopRecord {
417 hop_index: hop,
418 peer_id: Some(peer_id),
419 goal: format!("{:?}", goal),
420 resolved: false,
421 });
422 }
423
424 ctx.trace.push(HopRecord {
426 hop_index: hop,
427 peer_id: None,
428 goal: format!("{:?}", goal),
429 resolved: false,
430 });
431 false
432 })
433 }
434
435 async fn collect_peer_ids<FP>(
439 &self,
440 goal: &Term,
441 kb: &KnowledgeBase,
442 find_providers: &FP,
443 ) -> Vec<String>
444 where
445 FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync,
446 {
447 let pred_name = match goal {
448 Term::Fun(name, _) => name.clone(),
449 Term::Const(crate::ir::Constant::String(s)) => s.clone(),
450 _ => return Vec::new(),
451 };
452
453 let local_index = kb.index_rules_by_predicate_local();
454 let rule_indices = local_index.get(&pred_name).cloned().unwrap_or_default();
455
456 let mut candidate_cids: Vec<Cid> = Vec::new();
457 for rule_idx in rule_indices {
458 if let Some(rule) = kb.rules.get(rule_idx) {
459 use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
460 if let Ok(rule_ipld) = rule_to_rule_ipld(rule) {
461 if let Ok(cid) = rule_cid(&rule_ipld) {
462 candidate_cids.push(cid);
463 }
464 }
465 }
466 }
467
468 let mut peer_ids: Vec<String> = Vec::new();
469 for cid in candidate_cids {
470 if peer_ids.len() >= self.config.max_remote_peers {
471 break;
472 }
473 let providers = find_providers(cid).await;
474 for p in providers {
475 if !peer_ids.contains(&p) {
476 peer_ids.push(p.clone());
477 if peer_ids.len() >= self.config.max_remote_peers {
478 break;
479 }
480 }
481 }
482 }
483
484 peer_ids
485 }
486}
487
488#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::ir::{Constant, Predicate, Rule, Term};
496 use std::collections::HashMap;
497
498 fn atom(s: &str) -> Term {
501 Term::Const(Constant::String(s.to_string()))
502 }
503
504 fn var(s: &str) -> Term {
505 Term::Var(s.to_string())
506 }
507
508 fn fun(name: &str, args: Vec<Term>) -> Term {
509 Term::Fun(name.to_string(), args)
510 }
511
512 fn pred(name: &str, args: Vec<Term>) -> Predicate {
513 Predicate::new(name.to_string(), args)
514 }
515
516 fn no_providers() -> impl Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync {
518 |_cid| Box::pin(async { vec![] })
519 }
520
521 fn no_remote(
523 ) -> impl Fn(String, Term) -> BoxFuture<'static, Option<Vec<HashMap<String, Term>>>> + Send + Sync
524 {
525 |_peer, _goal| Box::pin(async { None })
526 }
527
528 #[test]
531 fn test_visited_set_first_visit() {
532 let mut vs = VisitedSet::new(100);
533 let goal = fun("p", vec![atom("a")]);
534 assert!(
535 vs.try_visit("local", &goal),
536 "first visit should return true"
537 );
538 }
539
540 #[test]
541 fn test_visited_set_loop_detection() {
542 let mut vs = VisitedSet::new(100);
543 let goal = fun("p", vec![atom("a")]);
544 vs.try_visit("local", &goal);
545 assert!(
546 !vs.try_visit("local", &goal),
547 "second visit of same goal should return false (loop detected)"
548 );
549 }
550
551 #[test]
552 fn test_visited_set_different_goals() {
553 let mut vs = VisitedSet::new(100);
554 let g1 = fun("p", vec![atom("a")]);
555 let g2 = fun("p", vec![atom("b")]);
556 assert!(
557 vs.try_visit("local", &g1),
558 "first goal should be first visit"
559 );
560 assert!(
561 vs.try_visit("local", &g2),
562 "different goal should not be a loop"
563 );
564 }
565
566 #[test]
567 fn test_visited_set_different_peers() {
568 let mut vs = VisitedSet::new(100);
569 let goal = fun("p", vec![atom("a")]);
570 assert!(vs.try_visit("peer-1", &goal), "peer-1 first visit");
571 assert!(
572 vs.try_visit("peer-2", &goal),
573 "same goal but different peer is not a loop"
574 );
575 assert!(
576 !vs.try_visit("peer-1", &goal),
577 "peer-1 second visit is a loop"
578 );
579 }
580
581 #[test]
584 fn test_hop_trace_push_and_len() {
585 let mut trace = HopTrace::new();
586 assert_eq!(trace.len(), 0);
587 assert!(trace.is_empty());
588
589 trace.push(HopRecord {
590 hop_index: 0,
591 peer_id: None,
592 goal: "p(a)".to_string(),
593 resolved: true,
594 });
595 assert_eq!(trace.len(), 1);
596 assert!(!trace.is_empty());
597
598 trace.push(HopRecord {
599 hop_index: 1,
600 peer_id: Some("peer-1".to_string()),
601 goal: "q(b)".to_string(),
602 resolved: false,
603 });
604 assert_eq!(trace.len(), 2);
605 }
606
607 #[test]
608 fn test_hop_trace_remote_hops() {
609 let mut trace = HopTrace::new();
610 trace.push(HopRecord {
611 hop_index: 0,
612 peer_id: None,
613 goal: "p(a)".to_string(),
614 resolved: false,
615 });
616 trace.push(HopRecord {
617 hop_index: 1,
618 peer_id: Some("peer-1".to_string()),
619 goal: "p(a)".to_string(),
620 resolved: true,
621 });
622 trace.push(HopRecord {
623 hop_index: 2,
624 peer_id: Some("peer-2".to_string()),
625 goal: "q(b)".to_string(),
626 resolved: false,
627 });
628
629 let remotes = trace.remote_hops();
630 assert_eq!(remotes.len(), 2, "only hops with Some(peer_id) are remote");
631 assert_eq!(remotes[0].peer_id.as_deref(), Some("peer-1"));
632 assert_eq!(remotes[1].peer_id.as_deref(), Some("peer-2"));
633 }
634
635 #[test]
636 fn test_hop_trace_all_peers() {
637 let mut trace = HopTrace::new();
638 trace.push(HopRecord {
639 hop_index: 0,
640 peer_id: Some("peer-A".to_string()),
641 goal: "p(a)".to_string(),
642 resolved: false,
643 });
644 trace.push(HopRecord {
645 hop_index: 1,
646 peer_id: Some("peer-B".to_string()),
647 goal: "p(a)".to_string(),
648 resolved: false,
649 });
650 trace.push(HopRecord {
652 hop_index: 2,
653 peer_id: Some("peer-A".to_string()),
654 goal: "q(x)".to_string(),
655 resolved: true,
656 });
657
658 let peers = trace.all_peers();
659 assert_eq!(peers.len(), 2, "should deduplicate peer IDs");
660 assert!(peers.contains(&"peer-A".to_string()));
661 assert!(peers.contains(&"peer-B".to_string()));
662 }
663
664 fn build_local_kb() -> KnowledgeBase {
667 let mut kb = KnowledgeBase::new();
668 kb.add_fact(pred("a", vec![atom("alice")]));
670 kb.add_rule(Rule::new(
672 pred("b", vec![var("X")]),
673 vec![pred("a", vec![var("X")])],
674 ));
675 kb.add_rule(Rule::new(
677 pred("c", vec![var("X")]),
678 vec![pred("b", vec![var("X")])],
679 ));
680 kb
681 }
682
683 #[tokio::test]
684 async fn test_multi_hop_local_resolution() {
685 let kb = build_local_kb();
686 let resolver = MultiHopResolver::new(MultiHopConfig::default());
687 let goal = fun("b", vec![atom("alice")]);
688
689 let result = resolver
690 .resolve(&goal, &kb, &no_providers(), &no_remote())
691 .await;
692
693 assert!(
694 result.resolved,
695 "goal b(alice) should resolve locally via rule b(X):-a(X)"
696 );
697 assert!(
698 result.trace.remote_hops().is_empty(),
699 "no remote hops expected for purely local resolution"
700 );
701 }
702
703 #[tokio::test]
704 async fn test_multi_hop_loop_prevention() {
705 let mut kb = KnowledgeBase::new();
707 kb.add_rule(Rule::new(
708 pred("p", vec![var("X")]),
709 vec![pred("p", vec![var("X")])],
710 ));
711
712 let resolver = MultiHopResolver::new(MultiHopConfig {
713 max_hops: 3,
714 max_depth: 3,
715 ..Default::default()
716 });
717 let goal = fun("p", vec![atom("x")]);
718
719 let result = resolver
721 .resolve(&goal, &kb, &no_providers(), &no_remote())
722 .await;
723
724 assert!(
725 !result.resolved,
726 "circular rule with no base fact should not resolve"
727 );
728 }
729
730 #[tokio::test]
731 async fn test_multi_hop_max_hops_limit() {
732 let kb = KnowledgeBase::new();
734 let resolver = MultiHopResolver::new(MultiHopConfig {
735 max_hops: 2,
736 ..Default::default()
737 });
738
739 let goal = fun("unknown_pred", vec![atom("x")]);
741 let result = resolver
742 .resolve(&goal, &kb, &no_providers(), &no_remote())
743 .await;
744
745 assert!(
746 !result.resolved,
747 "should return unresolved when max_hops exceeded"
748 );
749 }
750
751 #[test]
752 fn test_default_config_values() {
753 let cfg = MultiHopConfig::default();
754 assert_eq!(cfg.max_hops, 5);
755 assert_eq!(cfg.max_depth, 10);
756 assert_eq!(cfg.max_visited, 1000);
757 assert_eq!(cfg.max_remote_peers, 3);
758 assert_eq!(cfg.timeout_ms, 15_000);
759 }
760
761 #[test]
762 fn test_multi_hop_result_struct() {
763 let trace = HopTrace::new();
764 let result = MultiHopResult {
765 resolved: false,
766 trace,
767 bindings: HashMap::new(),
768 };
769 assert!(!result.resolved);
770 assert!(result.trace.is_empty());
771 assert!(result.bindings.is_empty());
772 }
773}