Skip to main content

ipfrs_tensorlogic/
multi_hop.rs

1//! Multi-hop rule resolution with loop detection for the IPFRS distributed logic engine.
2//!
3//! This module extends distributed backward chaining with multi-hop traversal across
4//! peer nodes, tracking which `(peer_id, goal_hash)` pairs have been visited to
5//! prevent infinite loops in circular rule configurations.
6//!
7//! # Overview
8//!
9//! The central entry point is [`MultiHopResolver::resolve`], which:
10//!
11//! 1. Attempts local KB resolution (facts + one rule level) first.
12//! 2. On failure, discovers remote peers via the `find_providers` callback.
13//! 3. Delegates the goal to each peer via `remote_query`, recording each hop in a
14//!    [`HopTrace`].
15//! 4. Returns a [`MultiHopResult`] indicating whether the goal was resolved, the
16//!    full hop trace, and any top-level variable bindings.
17//!
18//! Loop detection is handled by [`VisitedSet`]: before each local or remote
19//! attempt the `(peer_id, goal_hash)` pair is inserted; if it already exists
20//! the attempt is skipped and `false` is returned immediately.
21
22use 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
28// ── FNV-1a hash ───────────────────────────────────────────────────────────────
29
30/// Compute a simple FNV-1a 64-bit hash of the `Debug` representation of `term`.
31///
32/// FNV-1a is deterministic, allocation-free on the hash side, and fast enough
33/// for the visited-set use case without pulling in extra dependencies.
34fn 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
47// ── VisitedSet ────────────────────────────────────────────────────────────────
48
49/// Tracks which `(peer_id, goal_hash)` pairs have been visited to prevent loops.
50///
51/// When a `(peer_id, goal)` pair is first visited `try_visit` returns `true`.
52/// Subsequent visits return `false`, signalling a loop.
53pub struct VisitedSet {
54    entries: HashSet<(String, u64)>,
55    max_entries: usize,
56}
57
58impl VisitedSet {
59    /// Create a new [`VisitedSet`] that holds at most `max_entries` pairs.
60    pub fn new(max_entries: usize) -> Self {
61        Self {
62            entries: HashSet::new(),
63            max_entries,
64        }
65    }
66
67    /// Attempt to record a visit for `(peer_id, goal)`.
68    ///
69    /// Returns `true` if this is the **first** visit (no loop), or `false` if
70    /// the pair has already been seen (loop detected) **or** the set is full.
71    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    /// Return the number of recorded visits.
80    pub fn len(&self) -> usize {
81        self.entries.len()
82    }
83
84    /// Return `true` if no visits have been recorded.
85    pub fn is_empty(&self) -> bool {
86        self.entries.is_empty()
87    }
88
89    /// Check whether `(peer_id, goal)` is already in the visited set without
90    /// modifying it.
91    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// ── HopRecord ─────────────────────────────────────────────────────────────────
98
99/// Records one hop in a multi-hop derivation chain.
100#[derive(Debug, Clone)]
101pub struct HopRecord {
102    /// Index in the chain: `0` = local, `1` = first remote, and so on.
103    pub hop_index: usize,
104    /// `None` for local resolution; `Some(peer_id)` for remote.
105    pub peer_id: Option<String>,
106    /// Display representation of the goal term at this hop.
107    pub goal: String,
108    /// Whether this hop resolved the goal.
109    pub resolved: bool,
110}
111
112// ── HopTrace ──────────────────────────────────────────────────────────────────
113
114/// Complete trace of all hops taken during a multi-hop resolution attempt.
115#[derive(Debug, Clone)]
116pub struct HopTrace {
117    hops: Vec<HopRecord>,
118}
119
120impl HopTrace {
121    /// Create an empty trace.
122    pub fn new() -> Self {
123        Self { hops: Vec::new() }
124    }
125
126    /// Append a [`HopRecord`] to the trace.
127    pub fn push(&mut self, record: HopRecord) {
128        self.hops.push(record);
129    }
130
131    /// Number of recorded hops.
132    pub fn len(&self) -> usize {
133        self.hops.len()
134    }
135
136    /// Return `true` if the trace is empty.
137    pub fn is_empty(&self) -> bool {
138        self.hops.is_empty()
139    }
140
141    /// Slice over all recorded hops.
142    pub fn hops(&self) -> &[HopRecord] {
143        &self.hops
144    }
145
146    /// References to hops that involved a remote peer (`peer_id` is `Some`).
147    pub fn remote_hops(&self) -> Vec<&HopRecord> {
148        self.hops.iter().filter(|h| h.peer_id.is_some()).collect()
149    }
150
151    /// Total number of hops (alias for `len`).
152    pub fn max_depth(&self) -> usize {
153        self.hops.len()
154    }
155
156    /// Number of hops whose `resolved` field is `true`.
157    pub fn resolved_count(&self) -> usize {
158        self.hops.iter().filter(|h| h.resolved).count()
159    }
160
161    /// Deduplicated list of all remote peer IDs present in the trace.
162    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
182// ── MultiHopConfig ────────────────────────────────────────────────────────────
183
184/// Configuration governing multi-hop resolution behaviour.
185pub struct MultiHopConfig {
186    /// Maximum number of remote hops before giving up (default: 5).
187    pub max_hops: usize,
188    /// Maximum local backward-chaining depth per hop (default: 10).
189    pub max_depth: usize,
190    /// Maximum number of `(peer_id, goal)` entries in the visited set (default: 1000).
191    pub max_visited: usize,
192    /// Maximum number of remote peers to contact per unresolved sub-goal (default: 3).
193    pub max_remote_peers: usize,
194    /// Per-hop timeout in milliseconds (default: 15 000).
195    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
210// ── MultiHopResult ────────────────────────────────────────────────────────────
211
212/// Result of a multi-hop resolution attempt.
213pub struct MultiHopResult {
214    /// Whether the goal was ultimately resolved.
215    pub resolved: bool,
216    /// Full hop trace.
217    pub trace: HopTrace,
218    /// Top-level variable bindings collected during resolution.
219    pub bindings: HashMap<String, Term>,
220}
221
222// ── Local resolution helper ───────────────────────────────────────────────────
223
224/// Try to resolve `goal` against `kb` using a simple single-pass backward chain.
225///
226/// This is synchronous — it checks:
227/// 1. Whether any fact in the KB unifies with `goal` directly.
228/// 2. Whether any rule head unifies with `goal` **and** every body predicate is
229///    itself satisfiable by a fact in the KB (one level of rule application).
230fn 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    // 1. Try local facts
248    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    // 2. Try local rules (one level deep)
256    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            // Check every body predicate is resolvable
261            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
282// ── MultiHopResolver ─────────────────────────────────────────────────────────
283
284/// Bundles mutable traversal state with the two async callbacks so that
285/// `resolve_inner` stays within clippy's argument-count limit.
286struct 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
293/// Multi-hop backward-chaining resolver with loop detection.
294///
295/// Unlike [`crate::distributed_backward_chainer::DistributedBackwardChainer`],
296/// which records a full [`crate::proof_tree::ProofTree`], this resolver focuses
297/// on *how many hops* were taken and whether any hop triggered a loop.
298pub struct MultiHopResolver {
299    config: MultiHopConfig,
300}
301
302impl MultiHopResolver {
303    /// Create a resolver with the given configuration.
304    pub fn new(config: MultiHopConfig) -> Self {
305        Self { config }
306    }
307
308    /// Attempt to resolve `goal` using multi-hop backward chaining.
309    ///
310    /// The two callbacks follow the same conventions as
311    /// `DistributedBackwardChainer::prove_with_tree`:
312    ///
313    /// - `find_providers(cid)` → list of peer IDs that might hold rules for `cid`.
314    /// - `remote_query(peer_id, goal)` → `Some(bindings)` on success, `None` on
315    ///   failure / timeout.
316    ///
317    /// Both callbacks take owned values and return `'static` futures so that
318    /// they can be used inside the recursive async implementation without
319    /// complex higher-ranked lifetime constraints.
320    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    // Recursive inner implementation — each call represents one "hop level".
354    //
355    // Mutable traversal state (`visited`, `trace`) and the two async callbacks
356    // are bundled in `ResolveCtx` to keep the argument count within clippy's
357    // default limit of 7.
358    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            // Depth guard
373            if hop > self.config.max_hops {
374                return false;
375            }
376
377            // Loop detection for local attempt
378            if !ctx.visited.try_visit("local", goal) {
379                return false;
380            }
381
382            // 1. Try local KB
383            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            // 2. Find remote providers via rule-CID index
394            let peer_ids = self.collect_peer_ids(goal, kb, ctx.find_providers).await;
395
396            // 3. Try each peer
397            for peer_id in peer_ids {
398                // Loop detection: same goal must not be sent to the same peer twice
399                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            // Nothing worked at this hop level
425            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    /// Collect up to `max_remote_peers` peer IDs that may have rules relevant
436    /// to `goal`, using the same CID-based lookup strategy as
437    /// [`DistributedBackwardChainer`].
438    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// ─────────────────────────────────────────────────────────────────────────────
489// Tests
490// ─────────────────────────────────────────────────────────────────────────────
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::ir::{Constant, Predicate, Rule, Term};
496    use std::collections::HashMap;
497
498    // ── Helpers ───────────────────────────────────────────────────────────────
499
500    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    /// `find_providers` that always returns an empty peer list.
517    fn no_providers() -> impl Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync {
518        |_cid| Box::pin(async { vec![] })
519    }
520
521    /// `remote_query` that always returns `None` (no remote knowledge).
522    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    // ── VisitedSet tests ──────────────────────────────────────────────────────
529
530    #[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    // ── HopTrace tests ────────────────────────────────────────────────────────
582
583    #[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        // duplicate
651        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    // ── MultiHopResolver tests ────────────────────────────────────────────────
665
666    fn build_local_kb() -> KnowledgeBase {
667        let mut kb = KnowledgeBase::new();
668        // a(alice) — base fact
669        kb.add_fact(pred("a", vec![atom("alice")]));
670        // b(X) :- a(X)
671        kb.add_rule(Rule::new(
672            pred("b", vec![var("X")]),
673            vec![pred("a", vec![var("X")])],
674        ));
675        // c(X) :- b(X)
676        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        // Circular rule: p(X) :- p(X)  — should NOT loop forever
706        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        // This must return without hanging or stack-overflowing
720        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        // KB has no rules at all — goal is completely unknown
733        let kb = KnowledgeBase::new();
734        let resolver = MultiHopResolver::new(MultiHopConfig {
735            max_hops: 2,
736            ..Default::default()
737        });
738
739        // Provide a remote peer that always says "not found" after max_hops
740        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}