Skip to main content

ipfrs_tensorlogic/
proof_tree_streaming.rs

1//! Distributed proof tree streaming via incremental update events.
2//!
3//! This module provides [`ProofTreeStreamer`] which emits [`ProofTreeUpdate`]
4//! events as nodes of a [`crate::proof_tree::ProofTree`] resolve.  Updates are
5//! sent through an unbounded MPSC channel so consumers can process them
6//! asynchronously or aggregate them into a [`ProofTreeStreamSummary`].
7//!
8//! ## Design
9//!
10//! - Each derivation session is identified by a `session_id` string shared
11//!   across all updates.
12//! - Sequence numbers (`seq`) are assigned atomically and monotonically so
13//!   receivers can detect gaps or re-ordering.
14//! - A "final" update (`is_final = true`) signals that the session is
15//!   complete.  [`ProofTreeStreamSummary::apply`] sets `is_complete = true`
16//!   when it encounters such an update.
17
18use std::collections::VecDeque;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::Arc;
21
22use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
23
24// ─── Public update event ─────────────────────────────────────────────────────
25
26/// A single update event in an ongoing proof tree derivation.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct ProofTreeUpdate {
29    /// Session identifier shared across all updates for this derivation.
30    pub session_id: String,
31    /// The goal being proved at this update, rendered as a `Display` string.
32    pub goal: String,
33    /// Depth of this node in the proof tree.
34    pub depth: usize,
35    /// Whether this node resolved successfully.
36    pub resolved: bool,
37    /// The peer that resolved this node (`None` = local).
38    pub peer: Option<String>,
39    /// Sequence number within this session (monotonically increasing).
40    pub seq: u64,
41    /// `true` if this is the final update for the session.
42    pub is_final: bool,
43}
44
45// ─── ProofTreeUpdateSink ──────────────────────────────────────────────────────
46
47/// Receives streamed proof tree updates via an unbounded channel.
48pub struct ProofTreeUpdateSink {
49    sender: UnboundedSender<ProofTreeUpdate>,
50}
51
52impl ProofTreeUpdateSink {
53    /// Create a new sink together with the receiving end of the channel.
54    pub fn new() -> (Self, UnboundedReceiver<ProofTreeUpdate>) {
55        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
56        (Self { sender }, receiver)
57    }
58
59    /// Send an update.  Silently drops the update if the channel is closed.
60    pub fn send(&self, update: ProofTreeUpdate) {
61        // Ignore send errors — the receiver may have been dropped intentionally.
62        let _ = self.sender.send(update);
63    }
64
65    /// Returns `true` if the receiving end of the channel has been dropped.
66    pub fn is_closed(&self) -> bool {
67        self.sender.is_closed()
68    }
69}
70
71// ─── ProofTreeStreamer ────────────────────────────────────────────────────────
72
73/// Streams partial proof tree updates as nodes resolve.
74pub struct ProofTreeStreamer {
75    session_id: String,
76    seq: AtomicU64,
77    sink: Arc<ProofTreeUpdateSink>,
78}
79
80impl ProofTreeStreamer {
81    /// Create a new streamer bound to `session_id` and `sink`.
82    pub fn new(session_id: impl Into<String>, sink: Arc<ProofTreeUpdateSink>) -> Self {
83        Self {
84            session_id: session_id.into(),
85            seq: AtomicU64::new(0),
86            sink,
87        }
88    }
89
90    /// Return the session identifier for this streamer.
91    pub fn session_id(&self) -> &str {
92        &self.session_id
93    }
94
95    /// Emit an update for a successfully resolved node.
96    pub fn emit_resolved(&self, goal: &str, depth: usize, peer: Option<String>) {
97        let seq = self.next_seq();
98        self.sink.send(ProofTreeUpdate {
99            session_id: self.session_id.clone(),
100            goal: goal.to_string(),
101            depth,
102            resolved: true,
103            peer,
104            seq,
105            is_final: false,
106        });
107    }
108
109    /// Emit an update for a node that could not be resolved.
110    pub fn emit_unresolved(&self, goal: &str, depth: usize) {
111        let seq = self.next_seq();
112        self.sink.send(ProofTreeUpdate {
113            session_id: self.session_id.clone(),
114            goal: goal.to_string(),
115            depth,
116            resolved: false,
117            peer: None,
118            seq,
119            is_final: false,
120        });
121    }
122
123    /// Emit the final summary update, marking the session as complete.
124    pub fn emit_final(&self, goal: &str, depth: usize, resolved: bool, peer: Option<String>) {
125        let seq = self.next_seq();
126        self.sink.send(ProofTreeUpdate {
127            session_id: self.session_id.clone(),
128            goal: goal.to_string(),
129            depth,
130            resolved,
131            peer,
132            seq,
133            is_final: true,
134        });
135    }
136
137    /// Walk a completed [`crate::proof_tree::ProofTree`] and emit one update
138    /// per node in BFS (pre-order breadth-first) order, followed by a final
139    /// update that reflects the overall tree completion status.
140    pub fn stream_tree(&self, tree: &crate::proof_tree::ProofTree) {
141        let mut queue: VecDeque<&crate::proof_tree::ProofNode> = VecDeque::new();
142        queue.push_back(&tree.root);
143
144        while let Some(node) = queue.pop_front() {
145            let goal_str = format!("{}", node.goal);
146            if node.resolved {
147                self.emit_resolved(&goal_str, node.depth, node.peer.clone());
148            } else {
149                self.emit_unresolved(&goal_str, node.depth);
150            }
151            for child in &node.children {
152                queue.push_back(child);
153            }
154        }
155
156        // Emit the final event summarising the whole tree.
157        let root_goal = format!("{}", tree.query);
158        self.emit_final(&root_goal, 0, tree.is_complete, None);
159    }
160
161    /// Return the current sequence counter value (number of updates emitted so far).
162    pub fn seq(&self) -> u64 {
163        self.seq.load(Ordering::SeqCst)
164    }
165
166    // ── private ──────────────────────────────────────────────────────────────
167
168    fn next_seq(&self) -> u64 {
169        // fetch_add returns the *old* value, so seq() will equal the number of
170        // updates that have been emitted once the method returns.
171        self.seq.fetch_add(1, Ordering::SeqCst)
172    }
173}
174
175// ─── ProofTreeStreamSummary ───────────────────────────────────────────────────
176
177/// Aggregated statistics collected by folding a stream of [`ProofTreeUpdate`]s.
178#[derive(Debug, Clone)]
179pub struct ProofTreeStreamSummary {
180    /// Session this summary belongs to.
181    pub session_id: String,
182    /// Total number of updates applied so far.
183    pub total_updates: u64,
184    /// Number of updates with `resolved = true`.
185    pub resolved_count: u64,
186    /// Number of updates with `resolved = false`.
187    pub unresolved_count: u64,
188    /// Unique peer IDs seen in updates (deduplicated on demand via
189    /// [`ProofTreeStreamSummary::dedup_peers`]).
190    pub contributing_peers: Vec<String>,
191    /// Maximum `depth` value seen across all updates.
192    pub max_depth: usize,
193    /// `true` once an update with `is_final = true` has been applied.
194    pub is_complete: bool,
195}
196
197impl ProofTreeStreamSummary {
198    /// Construct an empty summary for `session_id`.
199    pub fn new(session_id: impl Into<String>) -> Self {
200        Self {
201            session_id: session_id.into(),
202            total_updates: 0,
203            resolved_count: 0,
204            unresolved_count: 0,
205            contributing_peers: Vec::new(),
206            max_depth: 0,
207            is_complete: false,
208        }
209    }
210
211    /// Fold a single [`ProofTreeUpdate`] into this summary.
212    pub fn apply(&mut self, update: &ProofTreeUpdate) {
213        self.total_updates += 1;
214
215        if update.resolved {
216            self.resolved_count += 1;
217        } else {
218            self.unresolved_count += 1;
219        }
220
221        if let Some(ref peer) = update.peer {
222            self.contributing_peers.push(peer.clone());
223        }
224
225        if update.depth > self.max_depth {
226            self.max_depth = update.depth;
227        }
228
229        if update.is_final {
230            self.is_complete = true;
231        }
232    }
233
234    /// Sort and deduplicate `contributing_peers` in place.
235    pub fn dedup_peers(&mut self) {
236        self.contributing_peers.sort_unstable();
237        self.contributing_peers.dedup();
238    }
239}
240
241// ─── Tests ────────────────────────────────────────────────────────────────────
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::ir::Term;
247    use crate::proof_tree::{ProofNode, ProofTree};
248    use std::collections::HashMap;
249
250    // ── helpers ───────────────────────────────────────────────────────────────
251
252    fn make_streamer(id: &str) -> (ProofTreeStreamer, UnboundedReceiver<ProofTreeUpdate>) {
253        let (sink, rx) = ProofTreeUpdateSink::new();
254        let streamer = ProofTreeStreamer::new(id, Arc::new(sink));
255        (streamer, rx)
256    }
257
258    fn atom(s: &str) -> Term {
259        Term::Const(crate::ir::Constant::String(s.to_string()))
260    }
261
262    /// Drain all currently pending messages from an unbounded receiver without
263    /// blocking.
264    fn drain(rx: &mut UnboundedReceiver<ProofTreeUpdate>) -> Vec<ProofTreeUpdate> {
265        let mut updates = Vec::new();
266        while let Ok(u) = rx.try_recv() {
267            updates.push(u);
268        }
269        updates
270    }
271
272    // ── individual emit tests ─────────────────────────────────────────────────
273
274    #[test]
275    fn test_emit_resolved_increments_seq() {
276        let (streamer, mut rx) = make_streamer("session-1");
277        streamer.emit_resolved("a(X)", 0, None);
278        streamer.emit_resolved("b(X)", 1, None);
279        streamer.emit_resolved("c(X)", 2, None);
280
281        let updates = drain(&mut rx);
282        assert_eq!(updates.len(), 3);
283        assert_eq!(streamer.seq(), 3);
284        assert_eq!(updates[0].seq, 0);
285        assert_eq!(updates[1].seq, 1);
286        assert_eq!(updates[2].seq, 2);
287    }
288
289    #[test]
290    fn test_emit_unresolved() {
291        let (streamer, mut rx) = make_streamer("session-2");
292        streamer.emit_unresolved("fail(X)", 1);
293
294        let updates = drain(&mut rx);
295        assert_eq!(updates.len(), 1);
296        assert!(
297            !updates[0].resolved,
298            "unresolved update should have resolved=false"
299        );
300        assert!(updates[0].peer.is_none());
301        assert!(!updates[0].is_final);
302    }
303
304    #[test]
305    fn test_emit_final_is_final() {
306        let (streamer, mut rx) = make_streamer("session-3");
307        streamer.emit_final("query(a)", 0, true, None);
308
309        let updates = drain(&mut rx);
310        assert_eq!(updates.len(), 1);
311        assert!(updates[0].is_final, "final update must have is_final=true");
312        assert!(updates[0].resolved);
313    }
314
315    // ── stream_tree tests ─────────────────────────────────────────────────────
316
317    #[test]
318    fn test_stream_tree_bfs_order() {
319        // Build: root(goal=A) -> child1(B), child2(C)
320        let child1 = ProofNode::fact(atom("B"), 1, None);
321        let child2 = ProofNode::fact(atom("C"), 1, None);
322        let root = ProofNode {
323            goal: atom("A"),
324            rule_cid: None,
325            peer: None,
326            children: vec![child1, child2],
327            resolved: true,
328            depth: 0,
329        };
330        let tree = ProofTree {
331            root,
332            query: atom("A"),
333            bindings: HashMap::new(),
334            is_complete: true,
335        };
336
337        let (streamer, mut rx) = make_streamer("session-4");
338        streamer.stream_tree(&tree);
339
340        let updates = drain(&mut rx);
341        // 3 nodes + 1 final = 4 updates
342        assert_eq!(
343            updates.len(),
344            4,
345            "expected 3 node updates + 1 final, got {}",
346            updates.len()
347        );
348
349        // First three should not be final
350        for u in &updates[..3] {
351            assert!(!u.is_final, "only the 4th update should be final");
352        }
353        // Last must be final
354        assert!(updates[3].is_final, "last update must be final");
355    }
356
357    // ── summary tests ─────────────────────────────────────────────────────────
358
359    #[test]
360    fn test_summary_apply_counts() {
361        let mut summary = ProofTreeStreamSummary::new("session-5");
362
363        let make = |resolved: bool, is_final: bool| ProofTreeUpdate {
364            session_id: "session-5".to_string(),
365            goal: "g".to_string(),
366            depth: 0,
367            resolved,
368            peer: None,
369            seq: 0,
370            is_final,
371        };
372
373        for _ in 0..3 {
374            summary.apply(&make(true, false));
375        }
376        for _ in 0..2 {
377            summary.apply(&make(false, false));
378        }
379
380        assert_eq!(summary.total_updates, 5);
381        assert_eq!(summary.resolved_count, 3);
382        assert_eq!(summary.unresolved_count, 2);
383        assert!(!summary.is_complete, "no final update yet");
384    }
385
386    #[test]
387    fn test_summary_contributing_peers() {
388        let mut summary = ProofTreeStreamSummary::new("session-6");
389
390        let peers = ["peer-A", "peer-B", "peer-A", "peer-C", "peer-B"];
391        for peer in &peers {
392            summary.apply(&ProofTreeUpdate {
393                session_id: "session-6".to_string(),
394                goal: "g".to_string(),
395                depth: 0,
396                resolved: true,
397                peer: Some(peer.to_string()),
398                seq: 0,
399                is_final: false,
400            });
401        }
402
403        summary.dedup_peers();
404        assert_eq!(
405            summary.contributing_peers.len(),
406            3,
407            "dedup should leave 3 unique peers"
408        );
409        assert_eq!(
410            summary.contributing_peers,
411            vec!["peer-A", "peer-B", "peer-C"]
412        );
413    }
414
415    #[test]
416    fn test_summary_is_complete_after_final() {
417        let mut summary = ProofTreeStreamSummary::new("session-7");
418
419        let non_final = ProofTreeUpdate {
420            session_id: "session-7".to_string(),
421            goal: "g".to_string(),
422            depth: 0,
423            resolved: true,
424            peer: None,
425            seq: 0,
426            is_final: false,
427        };
428        summary.apply(&non_final);
429        assert!(!summary.is_complete, "should not be complete before final");
430
431        let final_update = ProofTreeUpdate {
432            is_final: true,
433            seq: 1,
434            ..non_final
435        };
436        summary.apply(&final_update);
437        assert!(summary.is_complete, "should be complete after final update");
438    }
439
440    #[test]
441    fn test_sink_closed_no_panic() {
442        let (sink, rx) = ProofTreeUpdateSink::new();
443        // Drop the receiver to close the channel.
444        drop(rx);
445
446        let streamer = ProofTreeStreamer::new("session-8", Arc::new(sink));
447        // Sending after close must not panic.
448        streamer.emit_resolved("goal", 0, None);
449        streamer.emit_unresolved("goal", 0);
450        streamer.emit_final("goal", 0, true, None);
451        // If we get here without panicking the test passes.
452    }
453
454    #[test]
455    fn test_session_id_propagated() {
456        let session = "my-unique-session-id";
457        let (streamer, mut rx) = make_streamer(session);
458
459        streamer.emit_resolved("a", 0, None);
460        streamer.emit_unresolved("b", 1);
461        streamer.emit_final("c", 0, false, None);
462
463        let updates = drain(&mut rx);
464        for u in &updates {
465            assert_eq!(
466                u.session_id, session,
467                "all updates must carry the session_id '{}'",
468                session
469            );
470        }
471    }
472
473    #[test]
474    fn test_max_depth_tracking() {
475        let mut summary = ProofTreeStreamSummary::new("session-9");
476
477        let depths = [0usize, 3, 7, 2, 5];
478        for (i, &d) in depths.iter().enumerate() {
479            summary.apply(&ProofTreeUpdate {
480                session_id: "session-9".to_string(),
481                goal: "g".to_string(),
482                depth: d,
483                resolved: true,
484                peer: None,
485                seq: i as u64,
486                is_final: false,
487            });
488        }
489
490        assert_eq!(summary.max_depth, 7, "max depth should be 7");
491    }
492}