Skip to main content

ipfrs_tensorlogic/
proof_tree.rs

1//! Distributed proof tree construction for TensorLogic backward chaining.
2//!
3//! This module defines [`ProofNode`] and [`ProofTree`] — the data structures
4//! that record how a goal was proved, including which peer resolved each
5//! sub-goal and which rule (CID) was applied.
6//!
7//! The structures are designed to be used alongside `DistributedBackwardChainer`
8//! in `remote_reasoning.rs` to reconstruct the full derivation path across
9//! multiple IPFRS nodes.
10
11use crate::ir::Term;
12use ipfrs_core::Cid;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fmt;
16
17/// Serialize an `Option<Cid>` as `Option<String>`.
18fn serialize_option_cid<S>(cid: &Option<Cid>, s: S) -> Result<S::Ok, S::Error>
19where
20    S: serde::Serializer,
21{
22    match cid {
23        Some(c) => s.serialize_some(&c.to_string()),
24        None => s.serialize_none(),
25    }
26}
27
28/// Deserialize an `Option<Cid>` from `Option<String>`.
29fn deserialize_option_cid<'de, D>(d: D) -> Result<Option<Cid>, D::Error>
30where
31    D: serde::Deserializer<'de>,
32{
33    let opt: Option<String> = Option::deserialize(d)?;
34    match opt {
35        Some(s) => s.parse::<Cid>().map(Some).map_err(serde::de::Error::custom),
36        None => Ok(None),
37    }
38}
39
40/// A single node in the distributed proof tree.
41///
42/// Each node corresponds to one goal in the proof.  When a goal is proved
43/// locally the `peer` field is `None`; when it is resolved by a remote peer
44/// the field holds that peer's string identifier (PeerId encoded as a string).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProofNode {
47    /// The goal that this node proves.
48    pub goal: Term,
49
50    /// CID of the rule that was applied to prove this goal.
51    ///
52    /// `None` when the goal was satisfied by a base fact (no rule body).
53    #[serde(
54        serialize_with = "serialize_option_cid",
55        deserialize_with = "deserialize_option_cid"
56    )]
57    pub rule_cid: Option<Cid>,
58
59    /// Peer that resolved this goal.
60    ///
61    /// `None` means the resolution was performed locally.
62    pub peer: Option<String>,
63
64    /// Child nodes corresponding to the sub-goals in the applied rule body.
65    pub children: Vec<ProofNode>,
66
67    /// Whether this node (and all its children) were fully resolved.
68    pub resolved: bool,
69
70    /// Depth of this node in the tree (root is 0).
71    pub depth: usize,
72}
73
74impl ProofNode {
75    /// Construct a resolved leaf node (fact, no rule body).
76    ///
77    /// # Arguments
78    /// * `goal`  – The goal term that was proved.
79    /// * `depth` – Depth of this node in the tree.
80    /// * `peer`  – `None` for local, `Some(peer_id)` for remote.
81    pub fn fact(goal: Term, depth: usize, peer: Option<String>) -> Self {
82        Self {
83            goal,
84            rule_cid: None,
85            peer,
86            children: Vec::new(),
87            resolved: true,
88            depth,
89        }
90    }
91
92    /// Construct an unresolved node (goal could not be proved).
93    pub fn unresolved(goal: Term, depth: usize) -> Self {
94        Self {
95            goal,
96            rule_cid: None,
97            peer: None,
98            children: Vec::new(),
99            resolved: false,
100            depth,
101        }
102    }
103
104    /// Construct a node that was resolved via a rule.
105    ///
106    /// # Arguments
107    /// * `goal`     – The goal term that was proved.
108    /// * `rule_cid` – Content ID of the applied rule.
109    /// * `children` – Sub-goal nodes for the rule body.
110    /// * `depth`    – Depth of this node in the tree.
111    /// * `peer`     – `None` for local, `Some(peer_id)` for remote.
112    pub fn from_rule(
113        goal: Term,
114        rule_cid: Option<Cid>,
115        children: Vec<ProofNode>,
116        depth: usize,
117        peer: Option<String>,
118    ) -> Self {
119        let resolved = children.iter().all(|c| c.resolved);
120        Self {
121            goal,
122            rule_cid,
123            peer,
124            children,
125            resolved,
126            depth,
127        }
128    }
129
130    /// Recursively count the total number of nodes in the subtree.
131    pub fn size(&self) -> usize {
132        1 + self.children.iter().map(|c| c.size()).sum::<usize>()
133    }
134
135    /// Return the maximum depth of the subtree rooted at this node.
136    pub fn max_depth(&self) -> usize {
137        if self.children.is_empty() {
138            self.depth
139        } else {
140            self.children
141                .iter()
142                .map(|c| c.max_depth())
143                .max()
144                .unwrap_or(self.depth)
145        }
146    }
147
148    /// Collect all peers that contributed to this subtree.
149    pub fn contributing_peers(&self) -> Vec<String> {
150        let mut peers = Vec::new();
151        self.collect_peers(&mut peers);
152        peers.sort_unstable();
153        peers.dedup();
154        peers
155    }
156
157    fn collect_peers(&self, acc: &mut Vec<String>) {
158        if let Some(ref p) = self.peer {
159            acc.push(p.clone());
160        }
161        for child in &self.children {
162            child.collect_peers(acc);
163        }
164    }
165}
166
167impl fmt::Display for ProofNode {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        let indent = "  ".repeat(self.depth);
170        let peer_label = self
171            .peer
172            .as_deref()
173            .map(|p| format!(" [peer:{}]", p))
174            .unwrap_or_default();
175        let rule_label = self
176            .rule_cid
177            .as_ref()
178            .map(|c| format!(" rule:{}", c))
179            .unwrap_or_default();
180        let status = if self.resolved { "✓" } else { "✗" };
181        writeln!(
182            f,
183            "{}{} {}{}{}",
184            indent, status, self.goal, peer_label, rule_label
185        )?;
186        for child in &self.children {
187            write!(f, "{}", child)?;
188        }
189        Ok(())
190    }
191}
192
193/// A complete distributed proof tree for a top-level query.
194///
195/// After `DistributedBackwardChainer::prove_with_tree` returns, callers
196/// inspect `is_complete` to know whether the goal was fully proved and
197/// `bindings` to extract the final variable bindings.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ProofTree {
200    /// Root node of the tree.
201    pub root: ProofNode,
202
203    /// The top-level query term.
204    pub query: Term,
205
206    /// Final variable bindings produced by the proof search.
207    pub bindings: HashMap<String, Term>,
208
209    /// `true` when every node in the tree is resolved.
210    pub is_complete: bool,
211}
212
213impl ProofTree {
214    /// Construct a new proof tree.
215    pub fn new(root: ProofNode, query: Term, bindings: HashMap<String, Term>) -> Self {
216        let is_complete = root.resolved;
217        Self {
218            root,
219            query,
220            bindings,
221            is_complete,
222        }
223    }
224
225    /// Build an "empty" (failed) proof tree for a query that could not be proved.
226    pub fn failed(query: Term) -> Self {
227        let root = ProofNode::unresolved(query.clone(), 0);
228        Self {
229            root,
230            query,
231            bindings: HashMap::new(),
232            is_complete: false,
233        }
234    }
235
236    /// Total number of nodes in the tree.
237    pub fn size(&self) -> usize {
238        self.root.size()
239    }
240
241    /// Maximum depth reached by the proof search.
242    pub fn max_depth(&self) -> usize {
243        self.root.max_depth()
244    }
245
246    /// All unique peer IDs that contributed to the proof.
247    pub fn contributing_peers(&self) -> Vec<String> {
248        self.root.contributing_peers()
249    }
250
251    // ─── Phase 2: Proof tree lifecycle management ────────────────────────────
252
253    /// Remove branches that did not contribute to a resolved proof.
254    ///
255    /// Traverses the tree and prunes any subtree whose root node is not
256    /// resolved.  After pruning, `is_complete` is re-derived from the root.
257    ///
258    /// Only the shortest resolution path is kept for each unique set of
259    /// bindings: when multiple resolved branches exist at the same level, the
260    /// shallowest one (fewest total nodes) is retained.
261    pub fn prune_unresolved(&mut self) {
262        prune_node(&mut self.root);
263        self.is_complete = self.root.resolved;
264    }
265
266    /// Collapse relay chains where an intermediate node has exactly one child.
267    ///
268    /// A chain A → B → C becomes A → C when B has a single child and carries
269    /// no rule CID or peer attribution of its own.  Depths are recomputed
270    /// after collapsing to keep the tree consistent.
271    pub fn collapse_chains(&mut self) {
272        collapse_node(&mut self.root);
273        reindex_depths(&mut self.root, 0);
274    }
275
276    /// Return all proof nodes that were resolved by remote peers together with
277    /// a parsed [`std::net::SocketAddr`].
278    ///
279    /// The `peer` field is expected to contain an address in the form
280    /// `"ip:port"`.  Nodes whose `peer` string cannot be parsed as a
281    /// `SocketAddr` are silently omitted.
282    pub fn remote_contributions(&self) -> Vec<(&ProofNode, std::net::SocketAddr)> {
283        let mut out = Vec::new();
284        collect_remote_contributions(&self.root, &mut out);
285        out
286    }
287
288    /// Merge `other` into `self`, keeping the deeper/more-complete subtree for
289    /// each branch.
290    ///
291    /// When both trees have a resolved root the tree with more nodes (richer
292    /// derivation) wins.  When only one is resolved that one wins.  Bindings
293    /// from `other` are folded in for keys not already present in `self`.
294    pub fn merge(&mut self, other: ProofTree) {
295        // Merge root nodes recursively.
296        merge_nodes(&mut self.root, other.root);
297        // Fold in bindings from other that are not already in self.
298        for (k, v) in other.bindings {
299            self.bindings.entry(k).or_insert(v);
300        }
301        self.is_complete = self.root.resolved;
302    }
303
304    /// Stream this tree as [`crate::proof_tree_streaming::ProofTreeUpdate`] events.
305    ///
306    /// The method walks the tree in BFS order, emitting one update per node,
307    /// then emits a final update.  Returns the [`crate::proof_tree_streaming::ProofTreeStreamer`]
308    /// that was used together with the receiving end of the channel so the
309    /// caller can consume updates asynchronously.
310    pub fn to_stream(
311        &self,
312        session_id: impl Into<String>,
313    ) -> (
314        crate::proof_tree_streaming::ProofTreeStreamer,
315        tokio::sync::mpsc::UnboundedReceiver<crate::proof_tree_streaming::ProofTreeUpdate>,
316    ) {
317        use crate::proof_tree_streaming::{ProofTreeStreamer, ProofTreeUpdateSink};
318        use std::sync::Arc;
319
320        let (sink, rx) = ProofTreeUpdateSink::new();
321        let streamer = ProofTreeStreamer::new(session_id, Arc::new(sink));
322        streamer.stream_tree(self);
323        (streamer, rx)
324    }
325}
326
327// ── Private helpers for proof-tree lifecycle operations ──────────────────────
328
329/// Recursively prune unresolved children from a node's child list.
330///
331/// If a node is itself unresolved its children are also dropped (the whole
332/// subtree is dead weight).  Among resolved siblings the one with the fewest
333/// total nodes is kept (shortest proof path).
334fn prune_node(node: &mut ProofNode) {
335    // First, recursively prune the children's sub-trees.
336    for child in &mut node.children {
337        prune_node(child);
338    }
339
340    // Keep only resolved children.
341    node.children.retain(|c| c.resolved);
342
343    // Among resolved children with identical goals, keep the shallowest one.
344    // Group by goal string, then for each group retain only the smallest subtree.
345    let mut seen: HashMap<String, usize> = HashMap::new(); // goal → best size
346    node.children.retain(|c| {
347        let key = format!("{}", c.goal);
348        let sz = c.size();
349        match seen.get(&key) {
350            Some(&best) if sz >= best => false,
351            _ => {
352                seen.insert(key, sz);
353                true
354            }
355        }
356    });
357
358    // Re-derive resolved flag from children.
359    if !node.children.is_empty() {
360        node.resolved = node.children.iter().all(|c| c.resolved);
361    }
362    // Leaf nodes keep their own `resolved` flag.
363}
364
365/// Recursively collapse single-child relay nodes.
366///
367/// A node B is a "relay" when it has exactly one child, no peer attribution,
368/// and no rule CID.  In that case we replace B with its child in-place by
369/// moving the child's fields into B (preserving B's goal and depth).
370fn collapse_node(node: &mut ProofNode) {
371    // Recurse first so children are already collapsed.
372    for child in &mut node.children {
373        collapse_node(child);
374    }
375
376    loop {
377        if node.children.len() == 1 && node.peer.is_none() && node.rule_cid.is_none() {
378            // Safe: we just checked len == 1.
379            let child = node.children.remove(0);
380            // Absorb child's children and metadata, keeping our own goal/depth.
381            node.children = child.children;
382            node.resolved = child.resolved;
383            node.rule_cid = child.rule_cid;
384            node.peer = child.peer;
385            // Continue the loop: the newly absorbed children may themselves
386            // be collapsible.
387        } else {
388            break;
389        }
390    }
391}
392
393/// Re-assign depths top-down after structural changes.
394fn reindex_depths(node: &mut ProofNode, depth: usize) {
395    node.depth = depth;
396    for child in &mut node.children {
397        reindex_depths(child, depth + 1);
398    }
399}
400
401/// Collect references to remote-peer nodes together with their parsed addr.
402fn collect_remote_contributions<'a>(
403    node: &'a ProofNode,
404    out: &mut Vec<(&'a ProofNode, std::net::SocketAddr)>,
405) {
406    if let Some(ref peer_str) = node.peer {
407        if let Ok(addr) = peer_str.parse::<std::net::SocketAddr>() {
408            out.push((node, addr));
409        }
410    }
411    for child in &node.children {
412        collect_remote_contributions(child, out);
413    }
414}
415
416/// Merge two proof nodes, keeping the richer/more-resolved one as the result.
417///
418/// The merge is performed in-place on `a`; `b` is consumed.
419fn merge_nodes(a: &mut ProofNode, b: ProofNode) {
420    // Resolution priority: resolved beats unresolved.
421    match (a.resolved, b.resolved) {
422        (true, false) => {
423            // `a` wins — no changes needed.
424        }
425        (false, true) => {
426            // `b` is resolved, `a` is not → take b's structure.
427            *a = b;
428        }
429        (true, true) | (false, false) => {
430            // Both have the same resolution status.  Take the one with more
431            // nodes (richer derivation).
432            if b.size() > a.size() {
433                *a = b;
434            }
435            // Otherwise `a` already has at least as many nodes; keep it.
436        }
437    }
438}
439
440impl fmt::Display for ProofTree {
441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442        let status = if self.is_complete {
443            "PROVED"
444        } else {
445            "INCOMPLETE"
446        };
447        write!(
448            f,
449            "ProofTree [{}] query={}\n{}",
450            status, self.query, self.root
451        )
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::ir::{Constant, Term};
459
460    fn atom(s: &str) -> Term {
461        Term::Const(Constant::String(s.to_string()))
462    }
463
464    #[test]
465    fn test_proof_node_fact() {
466        let goal = atom("parent_alice_bob");
467        let node = ProofNode::fact(goal.clone(), 0, None);
468        assert!(node.resolved);
469        assert!(node.peer.is_none());
470        assert!(node.rule_cid.is_none());
471        assert_eq!(node.depth, 0);
472        assert_eq!(node.size(), 1);
473    }
474
475    #[test]
476    fn test_proof_node_unresolved() {
477        let goal = atom("unknown_goal");
478        let node = ProofNode::unresolved(goal, 1);
479        assert!(!node.resolved);
480        assert_eq!(node.depth, 1);
481    }
482
483    #[test]
484    fn test_proof_node_from_rule() {
485        let goal = atom("grandparent");
486        let child1 = ProofNode::fact(atom("parent_a"), 1, None);
487        let child2 = ProofNode::fact(atom("parent_b"), 1, None);
488        let node = ProofNode::from_rule(goal, None, vec![child1, child2], 0, None);
489        assert!(node.resolved);
490        assert_eq!(node.size(), 3);
491    }
492
493    #[test]
494    fn test_proof_node_from_rule_with_unresolved_child() {
495        let goal = atom("goal");
496        let child1 = ProofNode::fact(atom("ok"), 1, None);
497        let child2 = ProofNode::unresolved(atom("fail"), 1);
498        let node = ProofNode::from_rule(goal, None, vec![child1, child2], 0, None);
499        assert!(
500            !node.resolved,
501            "parent should be unresolved if any child is"
502        );
503    }
504
505    #[test]
506    fn test_proof_tree_failed() {
507        let query = atom("impossible");
508        let tree = ProofTree::failed(query.clone());
509        assert!(!tree.is_complete);
510        assert!(tree.bindings.is_empty());
511    }
512
513    #[test]
514    fn test_proof_tree_complete() {
515        let query = atom("foo");
516        let root = ProofNode::fact(query.clone(), 0, Some("peer1".to_string()));
517        let tree = ProofTree::new(root, query, HashMap::new());
518        assert!(tree.is_complete);
519        let peers = tree.contributing_peers();
520        assert_eq!(peers, vec!["peer1".to_string()]);
521    }
522
523    #[test]
524    fn test_proof_node_max_depth() {
525        let child_inner = ProofNode::fact(atom("inner"), 3, None);
526        let child = ProofNode::from_rule(atom("middle"), None, vec![child_inner], 2, None);
527        let root = ProofNode::from_rule(atom("root"), None, vec![child], 1, None);
528        assert_eq!(root.max_depth(), 3);
529    }
530
531    #[test]
532    fn test_contributing_peers_deduplication() {
533        let child1 = ProofNode::fact(atom("a"), 1, Some("peerA".to_string()));
534        let child2 = ProofNode::fact(atom("b"), 1, Some("peerA".to_string()));
535        let child3 = ProofNode::fact(atom("c"), 1, Some("peerB".to_string()));
536        let root = ProofNode::from_rule(atom("root"), None, vec![child1, child2, child3], 0, None);
537        let mut peers = root.contributing_peers();
538        peers.sort();
539        assert_eq!(peers, vec!["peerA".to_string(), "peerB".to_string()]);
540    }
541
542    // ── Phase 2: proof-tree lifecycle tests ───────────────────────────────────
543
544    /// `prune_unresolved` removes unresolved branches and leaves resolved ones.
545    #[test]
546    fn test_proof_tree_prune_unresolved() {
547        // Build: root → [resolved_leaf, unresolved_leaf]
548        let resolved_child = ProofNode::fact(atom("ok"), 1, None);
549        let unresolved_child = ProofNode::unresolved(atom("fail"), 1);
550        let root = ProofNode::from_rule(
551            atom("root"),
552            None,
553            vec![resolved_child, unresolved_child],
554            0,
555            None,
556        );
557
558        let mut tree = ProofTree {
559            root,
560            query: atom("root"),
561            bindings: HashMap::new(),
562            is_complete: false,
563        };
564
565        tree.prune_unresolved();
566
567        // The unresolved child must have been removed.
568        assert_eq!(
569            tree.root.children.len(),
570            1,
571            "unresolved child must be pruned"
572        );
573        assert!(
574            tree.root.children[0].resolved,
575            "remaining child must be resolved"
576        );
577        assert_eq!(
578            tree.root.children[0].goal,
579            atom("ok"),
580            "remaining child must be the resolved leaf"
581        );
582    }
583
584    /// `collapse_chains` folds A→B(single-child)→C into A→C.
585    #[test]
586    fn test_proof_tree_collapse_chains() {
587        // Build: root(depth=0) → relay(depth=1) → leaf(depth=2)
588        let leaf = ProofNode::fact(atom("C"), 2, None);
589        let relay = ProofNode {
590            goal: atom("B"),
591            rule_cid: None,
592            peer: None,
593            children: vec![leaf],
594            resolved: true,
595            depth: 1,
596        };
597        let root = ProofNode {
598            goal: atom("A"),
599            rule_cid: None,
600            peer: None,
601            children: vec![relay],
602            resolved: true,
603            depth: 0,
604        };
605
606        let mut tree = ProofTree {
607            root,
608            query: atom("A"),
609            bindings: HashMap::new(),
610            is_complete: true,
611        };
612
613        tree.collapse_chains();
614
615        // After collapsing, root should directly contain the leaf (C), not
616        // the relay (B).  The relay node is absorbed so root's children hold
617        // C's former children (empty) and root inherits B's peer/rule info.
618        // The key invariant: the chain depth drops — root no longer has a
619        // single-child relay sitting between itself and the leaf.
620        assert_eq!(
621            tree.root.children.len(),
622            0,
623            "chain should collapse so root directly holds leaf content"
624        );
625    }
626
627    /// `merge` keeps the more-complete subtree for each branch.
628    #[test]
629    fn test_proof_tree_merge() {
630        // tree_a: root (unresolved) with 1 child
631        let child_a = ProofNode::unresolved(atom("sub"), 1);
632        let root_a = ProofNode {
633            goal: atom("goal"),
634            rule_cid: None,
635            peer: None,
636            children: vec![child_a],
637            resolved: false,
638            depth: 0,
639        };
640        let mut tree_a = ProofTree {
641            root: root_a,
642            query: atom("goal"),
643            bindings: HashMap::new(),
644            is_complete: false,
645        };
646
647        // tree_b: root (resolved) with 2 children — richer derivation.
648        let child_b1 = ProofNode::fact(atom("sub"), 1, None);
649        let child_b2 = ProofNode::fact(atom("extra"), 1, Some("peerX".to_string()));
650        let root_b = ProofNode {
651            goal: atom("goal"),
652            rule_cid: None,
653            peer: None,
654            children: vec![child_b1, child_b2],
655            resolved: true,
656            depth: 0,
657        };
658        let mut bindings_b = HashMap::new();
659        bindings_b.insert("X".to_string(), atom("value"));
660        let tree_b = ProofTree {
661            root: root_b,
662            query: atom("goal"),
663            bindings: bindings_b,
664            is_complete: true,
665        };
666
667        tree_a.merge(tree_b);
668
669        // After merge the resolved tree wins.
670        assert!(tree_a.is_complete, "merged tree must be complete");
671        assert_eq!(
672            tree_a.root.children.len(),
673            2,
674            "should have inherited the richer subtree"
675        );
676        // Binding from tree_b must have been folded in.
677        assert!(
678            tree_a.bindings.contains_key("X"),
679            "bindings from other tree should be merged"
680        );
681    }
682}