Skip to main content

ipfrs_tensorlogic/
distributed_backward_chainer.rs

1//! Distributed backward-chaining prover for TensorLogic.
2//!
3//! [`DistributedBackwardChainer`] extends the local backward-chaining engine
4//! with the ability to delegate unsatisfied sub-goals to remote IPFRS peers.
5//! When the local knowledge base cannot resolve a goal the chainer:
6//!
7//! 1. Looks up relevant rule CIDs in a predicate-name → CID index.
8//! 2. Queries the DHT for peers that are providers of those CIDs.
9//! 3. Sends the goal to at most `max_remote_peers` peers.
10//! 4. Integrates the first successful remote binding into the proof tree.
11//!
12//! The resulting [`ProofTree`] records exactly *which* peer resolved each
13//! node, making the derivation auditable across a distributed network.
14//!
15//! # Callback Conventions
16//!
17//! Both callbacks take *owned* arguments and return `BoxFuture<'static, ...>`.
18//! This avoids complex higher-ranked lifetime constraints while keeping the
19//! API ergonomic for both real-network integrations and unit-test mocks.
20
21use crate::ir::{Constant, KnowledgeBase, Predicate, Term};
22use crate::proof_tree::{ProofNode, ProofTree};
23use crate::reasoning::{apply_subst_predicate, rename_rule_vars, unify_predicates, Substitution};
24use futures::future::BoxFuture;
25use ipfrs_core::{Cid, Result};
26use std::collections::HashMap;
27use std::sync::Arc;
28
29/// A variable-binding map produced by a remote peer for a sub-goal.
30pub type Binding = HashMap<String, Term>;
31
32/// Distributed backward-chaining prover.
33pub struct DistributedBackwardChainer {
34    /// Maximum chaining depth.
35    pub max_depth: usize,
36    /// Maximum number of peers to contact per unresolved sub-goal.
37    pub max_remote_peers: usize,
38    /// Per-peer query timeout in milliseconds.
39    pub timeout_ms: u64,
40}
41
42impl Default for DistributedBackwardChainer {
43    fn default() -> Self {
44        Self {
45            max_depth: 10,
46            max_remote_peers: 3,
47            timeout_ms: 5000,
48        }
49    }
50}
51
52impl DistributedBackwardChainer {
53    /// Create a chainer with explicit parameters.
54    pub fn new(max_depth: usize, max_remote_peers: usize, timeout_ms: u64) -> Self {
55        Self {
56            max_depth,
57            max_remote_peers,
58            timeout_ms,
59        }
60    }
61
62    /// Attempt to prove `goal` and return a [`ProofTree`] recording the full
63    /// derivation path.
64    ///
65    /// Both callbacks take owned arguments and return `'static` futures.
66    pub async fn prove_with_tree<FP, FQ>(
67        &self,
68        goal: &Term,
69        local_kb: &KnowledgeBase,
70        find_providers: FP,
71        remote_query: FQ,
72    ) -> Result<ProofTree>
73    where
74        FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync + 'static,
75        FQ: Fn(String, Term) -> BoxFuture<'static, Option<Vec<Binding>>> + Send + Sync + 'static,
76    {
77        let ctx = Arc::new(ProveCtx {
78            max_depth: self.max_depth,
79            max_remote_peers: self.max_remote_peers,
80            find_providers: Arc::new(find_providers),
81            remote_query: Arc::new(remote_query),
82        });
83
84        let root =
85            prove_term_impl(goal.clone(), Substitution::new(), local_kb.clone(), 0, ctx).await;
86
87        let bindings = extract_top_level_bindings(goal, &root);
88        let tree = ProofTree::new(root, goal.clone(), bindings);
89        Ok(tree)
90    }
91}
92
93// ── Free functions for recursion ──────────────────────────────────────────────
94
95struct ProveCtx<FP, FQ> {
96    max_depth: usize,
97    max_remote_peers: usize,
98    find_providers: Arc<FP>,
99    remote_query: Arc<FQ>,
100}
101
102fn prove_term_impl<FP, FQ>(
103    goal: Term,
104    subst: Substitution,
105    kb: KnowledgeBase,
106    depth: usize,
107    ctx: Arc<ProveCtx<FP, FQ>>,
108) -> BoxFuture<'static, ProofNode>
109where
110    FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync + 'static,
111    FQ: Fn(String, Term) -> BoxFuture<'static, Option<Vec<Binding>>> + Send + Sync + 'static,
112{
113    Box::pin(async move {
114        if depth > ctx.max_depth {
115            return ProofNode::unresolved(goal, depth);
116        }
117
118        let pred = match term_to_predicate(&goal) {
119            Some(p) => p,
120            None => {
121                if goal.is_ground() {
122                    return ProofNode::fact(goal, depth, None);
123                }
124                return ProofNode::unresolved(goal, depth);
125            }
126        };
127
128        let pred = apply_subst_predicate(&pred, &subst);
129
130        // 1. Try local facts
131        let local_facts: Vec<_> = kb.get_predicates(&pred.name).into_iter().cloned().collect();
132        for fact in &local_facts {
133            if unify_predicates(&pred, fact, &subst).is_some() {
134                return ProofNode::fact(goal, depth, None);
135            }
136        }
137
138        // 2. Try local rules
139        let local_rules: Vec<_> = kb.get_rules(&pred.name).into_iter().cloned().collect();
140        for rule in &local_rules {
141            let renamed = rename_rule_vars(rule, depth);
142            if let Some(new_subst) = unify_predicates(&pred, &renamed.head, &subst) {
143                let mut children = Vec::with_capacity(renamed.body.len());
144                let mut body_resolved = true;
145
146                for body_pred in &renamed.body {
147                    let body_term = predicate_to_term(body_pred);
148                    let child = prove_term_impl(
149                        body_term,
150                        new_subst.clone(),
151                        kb.clone(),
152                        depth + 1,
153                        ctx.clone(),
154                    )
155                    .await;
156                    if !child.resolved {
157                        body_resolved = false;
158                    }
159                    children.push(child);
160                }
161
162                if body_resolved {
163                    return ProofNode::from_rule(goal, None, children, depth, None);
164                }
165            }
166        }
167
168        // 3. Remote delegation
169        if let Some(node) = try_remote_inner(
170            &goal,
171            &subst,
172            &kb,
173            depth,
174            ctx.max_remote_peers,
175            &*ctx.find_providers,
176            &*ctx.remote_query,
177        )
178        .await
179        {
180            return node;
181        }
182
183        ProofNode::unresolved(goal, depth)
184    })
185}
186
187async fn try_remote_inner<FP, FQ>(
188    goal: &Term,
189    subst: &Substitution,
190    kb: &KnowledgeBase,
191    depth: usize,
192    max_remote_peers: usize,
193    find_providers: &FP,
194    remote_query: &FQ,
195) -> Option<ProofNode>
196where
197    FP: Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync,
198    FQ: Fn(String, Term) -> BoxFuture<'static, Option<Vec<Binding>>> + Send + Sync,
199{
200    let pred = term_to_predicate(goal)?;
201    let pred = apply_subst_predicate(&pred, subst);
202
203    let local_index = kb.index_rules_by_predicate_local();
204    let rule_indices = local_index.get(&pred.name).cloned().unwrap_or_default();
205
206    let mut candidate_cids: Vec<Cid> = Vec::new();
207    for rule_idx in rule_indices {
208        if let Some(rule) = kb.rules.get(rule_idx) {
209            use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
210            if let Ok(rule_ipld) = rule_to_rule_ipld(rule) {
211                if let Ok(cid) = rule_cid(&rule_ipld) {
212                    candidate_cids.push(cid);
213                }
214            }
215        }
216    }
217
218    let mut peer_ids: Vec<String> = Vec::new();
219    for cid in candidate_cids {
220        if peer_ids.len() >= max_remote_peers {
221            break;
222        }
223        let providers = find_providers(cid).await;
224        for p in providers {
225            if !peer_ids.contains(&p) {
226                peer_ids.push(p);
227                if peer_ids.len() >= max_remote_peers {
228                    break;
229                }
230            }
231        }
232    }
233
234    for peer_id in peer_ids {
235        if let Some(bindings_list) = remote_query(peer_id.clone(), goal.clone()).await {
236            if !bindings_list.is_empty() {
237                return Some(ProofNode::fact(goal.clone(), depth, Some(peer_id)));
238            }
239        }
240    }
241
242    None
243}
244
245// ── Term ↔ Predicate helpers ──────────────────────────────────────────────────
246
247fn term_to_predicate(term: &Term) -> Option<Predicate> {
248    match term {
249        Term::Fun(name, args) => Some(Predicate::new(name.clone(), args.clone())),
250        Term::Const(Constant::String(s)) => Some(Predicate::new(s.clone(), Vec::new())),
251        _ => None,
252    }
253}
254
255fn predicate_to_term(pred: &Predicate) -> Term {
256    Term::Fun(pred.name.clone(), pred.args.clone())
257}
258
259fn extract_top_level_bindings(query: &Term, _root: &ProofNode) -> HashMap<String, Term> {
260    let mut bindings = HashMap::new();
261    collect_ground_terms(query, &mut bindings);
262    bindings
263}
264
265fn collect_ground_terms(term: &Term, acc: &mut HashMap<String, Term>) {
266    match term {
267        Term::Fun(_, args) => {
268            for arg in args {
269                collect_ground_terms(arg, acc);
270            }
271        }
272        Term::Const(Constant::String(s)) => {
273            acc.insert(s.clone(), term.clone());
274        }
275        _ => {}
276    }
277}
278
279// ─────────────────────────────────────────────────────────────────────────────
280// Tests
281// ─────────────────────────────────────────────────────────────────────────────
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::ipld_codec::{rule_cid, rule_to_rule_ipld};
287    use crate::ir::{Constant, Predicate, Rule, Term};
288    use std::collections::HashMap;
289
290    fn atom(s: &str) -> Term {
291        Term::Const(Constant::String(s.to_string()))
292    }
293
294    fn var(s: &str) -> Term {
295        Term::Var(s.to_string())
296    }
297
298    fn fun(name: &str, args: Vec<Term>) -> Term {
299        Term::Fun(name.to_string(), args)
300    }
301
302    fn pred(name: &str, args: Vec<Term>) -> Predicate {
303        Predicate::new(name.to_string(), args)
304    }
305
306    fn build_chain_kb() -> KnowledgeBase {
307        let mut kb = KnowledgeBase::new();
308        kb.add_fact(pred("a", vec![atom("alice")]));
309        kb.add_rule(Rule::new(
310            pred("b", vec![var("X")]),
311            vec![pred("a", vec![var("X")])],
312        ));
313        kb.add_rule(Rule::new(
314            pred("c", vec![var("X")]),
315            vec![pred("b", vec![var("X")])],
316        ));
317        kb
318    }
319
320    fn no_providers() -> impl Fn(Cid) -> BoxFuture<'static, Vec<String>> + Send + Sync + 'static {
321        |_cid| Box::pin(async { vec![] })
322    }
323
324    fn no_remote(
325    ) -> impl Fn(String, Term) -> BoxFuture<'static, Option<Vec<Binding>>> + Send + Sync + 'static
326    {
327        |_peer, _goal| Box::pin(async { None })
328    }
329
330    /// A simple A->B->C chain should resolve entirely locally.
331    #[tokio::test]
332    async fn test_proof_tree_local_only() {
333        let kb = build_chain_kb();
334        let chainer = DistributedBackwardChainer::default();
335        let goal = fun("c", vec![atom("alice")]);
336        let tree = chainer
337            .prove_with_tree(&goal, &kb, no_providers(), no_remote())
338            .await
339            .expect("prove_with_tree failed");
340
341        assert!(tree.is_complete, "chain should fully resolve locally");
342        assert!(
343            tree.contributing_peers().is_empty(),
344            "no remote peers expected"
345        );
346        assert!(!tree.root.children.is_empty(), "root should have children");
347        assert_eq!(tree.root.peer, None);
348    }
349
350    /// Local rule partially resolved; a mock remote peer returns the binding.
351    #[tokio::test]
352    async fn test_proof_tree_partial_remote() {
353        let mut kb = KnowledgeBase::new();
354        kb.add_rule(Rule::new(
355            pred("c", vec![var("X")]),
356            vec![pred("a", vec![var("X")])],
357        ));
358
359        let rule = kb.rules[0].clone();
360        let rule_ipld = rule_to_rule_ipld(&rule).expect("ipld");
361        let expected_cid = rule_cid(&rule_ipld).expect("cid");
362
363        let mock_peer = "mock-peer-001";
364
365        let find_providers = move |lookup_cid: Cid| -> BoxFuture<'static, Vec<String>> {
366            let peers = if lookup_cid == expected_cid {
367                vec![mock_peer.to_string()]
368            } else {
369                vec![]
370            };
371            Box::pin(async move { peers })
372        };
373
374        let remote_query =
375            move |peer: String, _goal: Term| -> BoxFuture<'static, Option<Vec<Binding>>> {
376                let bindings: Option<Vec<Binding>> = if peer == mock_peer {
377                    let mut b = HashMap::new();
378                    b.insert("X".to_string(), atom("alice"));
379                    Some(vec![b])
380                } else {
381                    None
382                };
383                Box::pin(async move { bindings })
384            };
385
386        let chainer = DistributedBackwardChainer::default();
387        let goal = fun("c", vec![atom("alice")]);
388        let tree = chainer
389            .prove_with_tree(&goal, &kb, find_providers, remote_query)
390            .await
391            .expect("prove_with_tree failed");
392
393        let peers = tree.contributing_peers();
394        assert!(
395            peers.contains(&mock_peer.to_string()),
396            "mock peer should appear in contributing peers: {:?}",
397            peers
398        );
399    }
400
401    /// Index 10 rules and look up by predicate name; verify CID list.
402    #[tokio::test]
403    async fn test_predicate_index_roundtrip() {
404        let mut kb = KnowledgeBase::new();
405        for i in 0..10 {
406            let head_name = format!("rule_{}", i);
407            kb.add_rule(Rule::new(
408                pred(&head_name, vec![var("X")]),
409                vec![pred("base", vec![var("X")])],
410            ));
411        }
412
413        let mut cid_map: HashMap<usize, Cid> = HashMap::new();
414        for (idx, rule) in kb.rules.iter().enumerate() {
415            let rule_ipld = rule_to_rule_ipld(rule).expect("ipld");
416            let cid = rule_cid(&rule_ipld).expect("cid");
417            cid_map.insert(idx, cid);
418        }
419
420        let index = kb.index_rules_by_predicate(&cid_map);
421
422        for i in 0..10 {
423            let name = format!("rule_{}", i);
424            let cids = index.get(&name).expect("predicate not indexed");
425            assert_eq!(cids.len(), 1, "expected 1 CID for {}", name);
426        }
427
428        assert!(
429            !index.contains_key("base"),
430            "body predicate should not be indexed"
431        );
432    }
433
434    /// Chain that exceeds max_depth should produce is_complete=false.
435    #[tokio::test]
436    async fn test_backward_chain_depth_limit() {
437        let mut kb = KnowledgeBase::new();
438        kb.add_rule(Rule::new(
439            pred("p", vec![var("X")]),
440            vec![pred("p", vec![var("X")])],
441        ));
442
443        let chainer = DistributedBackwardChainer::new(3, 0, 5000);
444        let goal = fun("p", vec![atom("a")]);
445
446        let tree = chainer
447            .prove_with_tree(&goal, &kb, no_providers(), no_remote())
448            .await
449            .expect("prove_with_tree should not error");
450
451        assert!(
452            !tree.is_complete,
453            "recursive chain should NOT be complete when depth limit is hit"
454        );
455    }
456}