Skip to main content

ipfrs_tensorlogic/remote_reasoning/
session.rs

1//! Session management for distributed backward-chaining inference (v2).
2//!
3//! This sub-module contains:
4//! - Wire-format types (`RemoteResult`, `InferenceRequest`, `InferenceResponse`)
5//! - Session tracking (`DistributedInferenceSession`)
6//! - High-level reasoner (`DistributedReasonerV2`) with caching and metrics
7//! - Streaming result delivery (`InferenceResultStream`, `PartialResult`)
8
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12// ─── Wire types ─────────────────────────────────────────────────────────────
13
14/// A single result contributed by a remote peer during a distributed inference
15/// session.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RemoteResult {
18    /// The peer that produced this result.
19    pub peer_id: String,
20    /// Variable-to-value bindings returned by the remote engine.
21    pub bindings: HashMap<String, String>,
22    /// Depth of the proof tree the remote engine explored.
23    pub proof_depth: u32,
24    /// Round-trip latency in milliseconds.
25    pub latency_ms: u64,
26}
27
28/// Wire format sent to a remote peer asking it to prove a goal.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct InferenceRequest {
31    /// Correlates the request with its reply.
32    pub request_id: String,
33    /// The goal to prove (human-readable Datalog string).
34    pub goal: String,
35    /// Maximum proof depth the remote engine should explore.
36    pub max_depth: u32,
37    /// PeerId (as a string) of the node that issued this request.
38    pub requester_peer_id: String,
39}
40
41/// Wire format sent back by a remote peer in response to an [`InferenceRequest`].
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct InferenceResponse {
44    /// Matches the [`InferenceRequest::request_id`].
45    pub request_id: String,
46    /// Each element is one complete set of variable bindings that satisfy the
47    /// goal.
48    pub bindings: Vec<HashMap<String, String>>,
49    /// `true` when at least one proof was found.
50    pub proof_found: bool,
51    /// Non-`None` when the remote engine encountered an error.
52    pub error: Option<String>,
53}
54
55// ─── Session ────────────────────────────────────────────────────────────────
56
57/// Tracks a single distributed inference run from start to finish.
58#[derive(Debug)]
59pub struct DistributedInferenceSession {
60    /// Unique identifier for this session (UUID v4).
61    pub session_id: String,
62    /// The goal being proved (Datalog string).
63    pub goal: String,
64    /// Bindings resolved locally.
65    pub local_results: Vec<String>,
66    /// Results contributed by remote peers.
67    pub remote_results: Vec<RemoteResult>,
68    /// Peers that have been queried but have not yet replied.
69    pub pending_peers: HashSet<String>,
70    /// Peers that have already replied (or been declared timed-out).
71    pub completed_peers: HashSet<String>,
72    /// Wall-clock instant at which the session was created.
73    pub started_at: std::time::Instant,
74    /// How long to wait for all peers before declaring the session timed-out.
75    pub timeout: std::time::Duration,
76}
77
78impl DistributedInferenceSession {
79    /// Construct a new session for `goal` with the given `timeout`.
80    pub fn new(goal: &str, timeout: std::time::Duration) -> Self {
81        Self {
82            session_id: uuid::Uuid::new_v4().to_string(),
83            goal: goal.to_string(),
84            local_results: Vec::new(),
85            remote_results: Vec::new(),
86            pending_peers: HashSet::new(),
87            completed_peers: HashSet::new(),
88            started_at: std::time::Instant::now(),
89            timeout,
90        }
91    }
92
93    /// Returns `true` once all registered peers have replied **or** the
94    /// timeout has elapsed.
95    ///
96    /// A session that has had no peers registered is **not** considered
97    /// complete (there is no outstanding work to declare done).  It becomes
98    /// complete either when every registered peer has replied or when the
99    /// timeout fires.
100    pub fn is_complete(&self) -> bool {
101        let any_peers_registered =
102            !self.pending_peers.is_empty() || !self.completed_peers.is_empty();
103
104        if self.started_at.elapsed() >= self.timeout {
105            return true; // timed out regardless of peer state
106        }
107
108        // No peers were ever registered → not complete yet.
109        if !any_peers_registered {
110            return false;
111        }
112
113        // All registered peers have replied.
114        self.pending_peers.is_empty()
115    }
116
117    /// Returns `true` when the session has exceeded its timeout.
118    pub fn is_expired(&self) -> bool {
119        self.started_at.elapsed() >= self.timeout
120    }
121}
122
123// ─── Error type ─────────────────────────────────────────────────────────────
124
125/// Errors specific to [`DistributedReasonerV2`] operations.
126#[derive(Debug, thiserror::Error)]
127pub enum ReasoningError {
128    #[error("Session not found: {0}")]
129    SessionNotFound(String),
130
131    #[error("Peer already registered: {0}")]
132    PeerAlreadyRegistered(String),
133
134    #[error("Peer not registered in session: {0}")]
135    PeerNotRegistered(String),
136
137    #[error("Session has already expired")]
138    SessionExpired,
139}
140
141// ─── Config ──────────────────────────────────────────────────────────────────
142
143/// Tuning parameters for [`DistributedReasonerV2`].
144#[derive(Debug, Clone)]
145pub struct DistributedReasonerConfig {
146    /// Maximum proof depth per query (default `10`).
147    pub max_depth: usize,
148    /// Per-session timeout (default `30 s`).
149    pub timeout: std::time::Duration,
150    /// Maximum number of peers to query per session (default `5`).
151    pub max_peers: usize,
152    /// How long a cached result remains valid (default `5 min`).
153    pub cache_ttl: std::time::Duration,
154    /// Number of in-flight peer queries to allow concurrently (default `3`).
155    pub parallel_queries: usize,
156}
157
158impl Default for DistributedReasonerConfig {
159    fn default() -> Self {
160        Self {
161            max_depth: 10,
162            timeout: std::time::Duration::from_secs(30),
163            max_peers: 5,
164            cache_ttl: std::time::Duration::from_secs(300),
165            parallel_queries: 3,
166        }
167    }
168}
169
170// ─── Stats ───────────────────────────────────────────────────────────────────
171
172/// Aggregate statistics over all live sessions managed by a
173/// [`DistributedReasonerV2`].
174#[derive(Debug, Default)]
175pub struct SessionStats {
176    /// Number of sessions that have not yet completed (or expired).
177    pub active_sessions: usize,
178    /// Total [`RemoteResult`] objects accumulated across all sessions.
179    pub total_results: usize,
180    /// Average latency in milliseconds across every known remote result.
181    pub avg_latency_ms: f64,
182    /// Fraction of `get_session_results` calls that were served from the
183    /// result cache (0.0 – 1.0).
184    pub cache_hit_rate: f64,
185}
186
187// ─── DistributedReasonerV2 ───────────────────────────────────────────────────
188
189/// Enhanced distributed reasoner with real session management, result caching,
190/// and cycle-safe peer tracking.
191///
192/// # Design
193///
194/// Each call to [`start_session`](DistributedReasonerV2::start_session) creates
195/// a [`DistributedInferenceSession`] that tracks which peers have been queried
196/// and which have replied.  Results stream in via
197/// [`record_remote_result`](DistributedReasonerV2::record_remote_result).  A
198/// short-lived LRU-style cache keyed on the goal string avoids redundant
199/// network round-trips for recently seen goals.
200pub struct DistributedReasonerV2 {
201    /// Memoised local backward-chaining engine (reserved for future local
202    /// inference integration).
203    #[allow(dead_code)]
204    local_reasoner: crate::reasoning::MemoizedInferenceEngine,
205    /// All live (and recently completed) inference sessions, keyed by
206    /// `session_id`.
207    pub(super) sessions: HashMap<String, DistributedInferenceSession>,
208    /// Goal-string → remote results cache.
209    cache: HashMap<String, (std::time::Instant, Vec<RemoteResult>)>,
210    /// Tuning parameters.
211    config: DistributedReasonerConfig,
212    /// Total cache lookups (for hit-rate tracking).
213    cache_lookups: u64,
214    /// Cache lookups that returned a live entry.
215    cache_hits: u64,
216}
217
218impl DistributedReasonerV2 {
219    /// Construct a new [`DistributedReasonerV2`] with the provided config.
220    pub fn new(config: DistributedReasonerConfig) -> Self {
221        let cache_mgr = std::sync::Arc::new(crate::cache::CacheManager::new());
222        Self {
223            local_reasoner: crate::reasoning::MemoizedInferenceEngine::new(cache_mgr),
224            sessions: HashMap::new(),
225            cache: HashMap::new(),
226            config,
227            cache_lookups: 0,
228            cache_hits: 0,
229        }
230    }
231
232    // ── Session lifecycle ────────────────────────────────────────────────────
233
234    /// Create a new inference session for `goal` and return its `session_id`.
235    ///
236    /// The timeout is taken from [`DistributedReasonerConfig::timeout`].
237    pub fn start_session(&mut self, goal: &str) -> String {
238        let session = DistributedInferenceSession::new(goal, self.config.timeout);
239        let id = session.session_id.clone();
240        self.sessions.insert(id.clone(), session);
241        id
242    }
243
244    /// Create a new inference session with a caller-supplied `session_id`.
245    ///
246    /// Unlike [`start_session`](Self::start_session) the UUID is provided by
247    /// the caller so it can be correlated with an outgoing
248    /// [`InferenceRequest`].  If a session with the same ID already exists it
249    /// is silently replaced.
250    pub fn start_session_with_id(&mut self, goal: &str, session_id: &str) {
251        let mut session = DistributedInferenceSession::new(goal, self.config.timeout);
252        session.session_id = session_id.to_string();
253        self.sessions.insert(session_id.to_string(), session);
254    }
255
256    /// Register `peer_id` as a query target for the given session.
257    ///
258    /// Returns [`ReasoningError::SessionNotFound`] when `session_id` is
259    /// unknown, [`ReasoningError::PeerAlreadyRegistered`] when the peer was
260    /// already added, and [`ReasoningError::SessionExpired`] when the session
261    /// has timed out.
262    pub fn add_session_peer(
263        &mut self,
264        session_id: &str,
265        peer_id: &str,
266    ) -> Result<(), ReasoningError> {
267        let session = self
268            .sessions
269            .get_mut(session_id)
270            .ok_or_else(|| ReasoningError::SessionNotFound(session_id.to_string()))?;
271
272        if session.pending_peers.contains(peer_id) || session.completed_peers.contains(peer_id) {
273            return Err(ReasoningError::PeerAlreadyRegistered(peer_id.to_string()));
274        }
275        session.pending_peers.insert(peer_id.to_string());
276        Ok(())
277    }
278
279    /// Record a [`RemoteResult`] for an in-progress session.
280    ///
281    /// The peer is automatically moved from `pending_peers` to
282    /// `completed_peers` if it was registered.  Unknown peers are accepted
283    /// anyway (unsolicited partial results are common in gossip networks).
284    pub fn record_remote_result(
285        &mut self,
286        session_id: &str,
287        result: RemoteResult,
288    ) -> Result<(), ReasoningError> {
289        let session = self
290            .sessions
291            .get_mut(session_id)
292            .ok_or_else(|| ReasoningError::SessionNotFound(session_id.to_string()))?;
293
294        // Move the peer from pending → completed regardless of whether it was
295        // pre-registered (gossip networks may send unsolicited replies).
296        let peer = result.peer_id.clone();
297        session.pending_peers.remove(&peer);
298        session.completed_peers.insert(peer);
299        session.remote_results.push(result);
300        Ok(())
301    }
302
303    /// Mark `peer_id` as having responded (without attaching a result).
304    ///
305    /// Useful for signalling that a peer replied with "no solution".
306    pub fn mark_peer_responded(
307        &mut self,
308        session_id: &str,
309        peer_id: &str,
310    ) -> Result<(), ReasoningError> {
311        let session = self
312            .sessions
313            .get_mut(session_id)
314            .ok_or_else(|| ReasoningError::SessionNotFound(session_id.to_string()))?;
315
316        if !session.pending_peers.contains(peer_id) && !session.completed_peers.contains(peer_id) {
317            return Err(ReasoningError::PeerNotRegistered(peer_id.to_string()));
318        }
319        session.pending_peers.remove(peer_id);
320        session.completed_peers.insert(peer_id.to_string());
321        Ok(())
322    }
323
324    // ── Completion / result retrieval ────────────────────────────────────────
325
326    /// Returns `true` when the session has received responses from all
327    /// registered peers or has exceeded its timeout.
328    pub fn is_session_complete(&self, session_id: &str) -> bool {
329        self.sessions
330            .get(session_id)
331            .map(|s| s.is_complete())
332            .unwrap_or(false)
333    }
334
335    /// Return all remote results collected for `session_id`, or `None` when
336    /// the session is unknown.
337    ///
338    /// Also consults the goal cache so that repeated queries benefit from
339    /// previously gathered results.
340    pub fn get_session_results(&mut self, session_id: &str) -> Option<Vec<RemoteResult>> {
341        self.cache_lookups += 1;
342
343        let session = self.sessions.get(session_id)?;
344        let goal = session.goal.clone();
345        let ttl = self.config.cache_ttl;
346
347        // Check goal-level cache.
348        if let Some((cached_at, cached)) = self.cache.get(&goal) {
349            if cached_at.elapsed() < ttl {
350                self.cache_hits += 1;
351                return Some(cached.clone());
352            }
353        }
354
355        // Build result list from the live session.
356        let results = session.remote_results.clone();
357        if !results.is_empty() {
358            self.cache
359                .insert(goal, (std::time::Instant::now(), results.clone()));
360        }
361        Some(results)
362    }
363
364    // ── Maintenance ──────────────────────────────────────────────────────────
365
366    /// Remove all sessions that have exceeded their timeout.
367    ///
368    /// Returns the number of sessions that were cleaned up.
369    pub fn cleanup_expired_sessions(&mut self) -> usize {
370        let expired: Vec<String> = self
371            .sessions
372            .iter()
373            .filter(|(_, s)| s.is_expired())
374            .map(|(id, _)| id.clone())
375            .collect();
376
377        let count = expired.len();
378        for id in expired {
379            self.sessions.remove(&id);
380        }
381        count
382    }
383
384    /// Evict goal-cache entries whose TTL has elapsed.
385    ///
386    /// Called automatically by `cleanup_expired_sessions` but also available
387    /// for manual invocation.
388    pub fn evict_stale_cache(&mut self) -> usize {
389        let ttl = self.config.cache_ttl;
390        let before = self.cache.len();
391        self.cache
392            .retain(|_, (cached_at, _)| cached_at.elapsed() < ttl);
393        before - self.cache.len()
394    }
395
396    // ── Statistics ───────────────────────────────────────────────────────────
397
398    /// Produce a snapshot of aggregate statistics over all live sessions.
399    pub fn session_stats(&self) -> SessionStats {
400        let active_sessions = self.sessions.values().filter(|s| !s.is_complete()).count();
401
402        let all_results: Vec<&RemoteResult> = self
403            .sessions
404            .values()
405            .flat_map(|s| s.remote_results.iter())
406            .collect();
407
408        let total_results = all_results.len();
409
410        let avg_latency_ms = if total_results == 0 {
411            0.0
412        } else {
413            let sum: u64 = all_results.iter().map(|r| r.latency_ms).sum();
414            sum as f64 / total_results as f64
415        };
416
417        let cache_hit_rate = if self.cache_lookups == 0 {
418            0.0
419        } else {
420            self.cache_hits as f64 / self.cache_lookups as f64
421        };
422
423        SessionStats {
424            active_sessions,
425            total_results,
426            avg_latency_ms,
427            cache_hit_rate,
428        }
429    }
430
431    /// Number of currently tracked sessions (active + recently completed).
432    #[inline]
433    pub fn session_count(&self) -> usize {
434        self.sessions.len()
435    }
436
437    // ── Phase 2: session lifecycle management ────────────────────────────────
438
439    /// Garbage-collect sessions older than `max_age_secs`.
440    ///
441    /// A session is considered "old" when the wall-clock time since it was
442    /// started exceeds `max_age_secs`, regardless of whether it completed
443    /// normally.  Returns the number of sessions that were removed.
444    pub fn gc_sessions(&mut self, max_age_secs: u64) -> usize {
445        let max_age = std::time::Duration::from_secs(max_age_secs);
446        let expired: Vec<String> = self
447            .sessions
448            .iter()
449            .filter(|(_, s)| s.started_at.elapsed() >= max_age)
450            .map(|(id, _)| id.clone())
451            .collect();
452
453        let count = expired.len();
454        for id in expired {
455            self.sessions.remove(&id);
456        }
457        count
458    }
459
460    /// Return a [`SessionMetrics`] snapshot for monitoring purposes.
461    ///
462    /// - `active_sessions`  — sessions that have not yet completed.
463    /// - `completed_sessions` — sessions where all peers replied or timed-out.
464    /// - `expired_sessions`  — sessions that exceeded their per-session timeout.
465    /// - `avg_peers_per_session` — mean number of peers registered per session.
466    /// - `avg_latency_ms`   — mean `latency_ms` across all `RemoteResult`s.
467    pub fn session_metrics(&self) -> SessionMetrics {
468        let mut active_sessions: usize = 0;
469        let mut completed_sessions: usize = 0;
470        let mut expired_sessions: usize = 0;
471        let mut total_peers: usize = 0;
472        let mut latency_sum: u64 = 0;
473        let mut result_count: usize = 0;
474
475        for session in self.sessions.values() {
476            let is_expired = session.is_expired();
477            let is_complete = session.is_complete();
478
479            if is_expired {
480                expired_sessions += 1;
481            }
482            if is_complete {
483                completed_sessions += 1;
484            } else {
485                active_sessions += 1;
486            }
487
488            total_peers += session.pending_peers.len() + session.completed_peers.len();
489
490            for result in &session.remote_results {
491                latency_sum += result.latency_ms;
492                result_count += 1;
493            }
494        }
495
496        let session_count = self.sessions.len();
497        let avg_peers_per_session = if session_count == 0 {
498            0.0
499        } else {
500            total_peers as f64 / session_count as f64
501        };
502
503        let avg_latency_ms = if result_count == 0 {
504            0.0
505        } else {
506            latency_sum as f64 / result_count as f64
507        };
508
509        SessionMetrics {
510            active_sessions,
511            completed_sessions,
512            expired_sessions,
513            avg_peers_per_session,
514            avg_latency_ms,
515        }
516    }
517}
518
519/// Metrics snapshot for [`DistributedReasonerV2`] monitoring.
520#[derive(Debug, Clone)]
521pub struct SessionMetrics {
522    /// Sessions that have not yet completed or expired.
523    pub active_sessions: usize,
524    /// Sessions where all peers replied or the timeout fired.
525    pub completed_sessions: usize,
526    /// Sessions that exceeded their per-session timeout.
527    pub expired_sessions: usize,
528    /// Average number of peers registered across all sessions.
529    pub avg_peers_per_session: f64,
530    /// Average round-trip latency in milliseconds across all remote results.
531    pub avg_latency_ms: f64,
532}
533
534// ─────────────────────────────────────────────────────────────────────────────
535// Phase 2 Feature 1: Incremental result streaming
536// ─────────────────────────────────────────────────────────────────────────────
537
538/// A partial result produced by a single peer during a streaming inference session.
539#[derive(Debug, Clone)]
540pub struct PartialResult {
541    /// Peer that produced this batch of bindings.
542    pub peer_id: String,
543    /// The new binding maps contributed by this peer in this batch.
544    pub new_bindings: Vec<HashMap<String, String>>,
545    /// Total number of binding sets accumulated so far across all peers.
546    pub total_so_far: usize,
547    /// `true` when the deadline has passed or all peers have replied, meaning
548    /// no further results will arrive.
549    pub is_final: bool,
550}
551
552/// A streaming view of an incremental distributed inference session.
553///
554/// Results arrive asynchronously via an internal `mpsc` channel as each peer
555/// responds.  The caller polls `next_partial` to receive one
556/// [`PartialResult`] per peer response until the session is complete or the
557/// deadline expires.
558///
559/// # Example
560///
561/// ```no_run
562/// # async fn example() {
563/// # use ipfrs_tensorlogic::InferenceResultStream;
564/// // (obtained from Node::infer_streaming)
565/// let mut stream: InferenceResultStream = todo!();
566/// while let Some(partial) = stream.next_partial().await {
567///     println!("peer {}: {} new bindings", partial.peer_id, partial.new_bindings.len());
568///     if partial.is_final { break; }
569/// }
570/// # }
571/// ```
572pub struct InferenceResultStream {
573    /// Session identifier for correlating with the originating request.
574    session_id: String,
575    /// Channel on which peer responses are delivered.
576    rx: tokio::sync::mpsc::Receiver<InferenceResponse>,
577    /// All binding maps accumulated so far.
578    accumulated: Vec<HashMap<String, String>>,
579    /// Absolute instant after which the stream is declared finished.
580    deadline: tokio::time::Instant,
581}
582
583impl InferenceResultStream {
584    /// Construct a new stream from a channel receiver and a deadline.
585    ///
586    /// This is used internally by `Node::infer_streaming`; callers should not
587    /// normally construct this directly.
588    pub fn new(
589        session_id: String,
590        rx: tokio::sync::mpsc::Receiver<InferenceResponse>,
591        deadline: tokio::time::Instant,
592    ) -> Self {
593        Self {
594            session_id,
595            rx,
596            accumulated: Vec::new(),
597            deadline,
598        }
599    }
600
601    /// The session identifier.
602    pub fn session_id(&self) -> &str {
603        &self.session_id
604    }
605
606    /// Number of binding sets accumulated so far.
607    pub fn result_count(&self) -> usize {
608        self.accumulated.len()
609    }
610
611    /// Poll for the next partial result.
612    ///
613    /// Returns `None` when the deadline has passed or the channel is closed,
614    /// indicating that no further results will arrive.
615    pub async fn next_partial(&mut self) -> Option<PartialResult> {
616        let remaining = self
617            .deadline
618            .saturating_duration_since(tokio::time::Instant::now());
619        if remaining.is_zero() {
620            return None;
621        }
622
623        match tokio::time::timeout(remaining, self.rx.recv()).await {
624            Ok(Some(resp)) => {
625                let new_bindings: Vec<HashMap<String, String>> = resp.bindings;
626                self.accumulated.extend(new_bindings.clone());
627                let total_so_far = self.accumulated.len();
628
629                // We can't know if more peers will reply, so `is_final` is
630                // determined by whether the deadline is now exhausted.
631                let is_final = self
632                    .deadline
633                    .saturating_duration_since(tokio::time::Instant::now())
634                    .is_zero();
635
636                Some(PartialResult {
637                    peer_id: resp.request_id,
638                    new_bindings,
639                    total_so_far,
640                    is_final,
641                })
642            }
643            // Channel closed or timeout expired → stream is done.
644            Ok(None) | Err(_) => None,
645        }
646    }
647}
648
649// ─────────────────────────────────────────────────────────────────────────────
650// Tests for DistributedReasonerV2 and related types
651// ─────────────────────────────────────────────────────────────────────────────
652
653#[cfg(test)]
654mod distributed_v2_tests {
655    use super::*;
656
657    /// Helper that builds a reasoner with predictable, short defaults.
658    fn make_reasoner() -> DistributedReasonerV2 {
659        DistributedReasonerV2::new(DistributedReasonerConfig {
660            max_depth: 5,
661            timeout: std::time::Duration::from_secs(10),
662            max_peers: 3,
663            cache_ttl: std::time::Duration::from_secs(60),
664            parallel_queries: 2,
665        })
666    }
667
668    // ── Session lifecycle ────────────────────────────────────────────────────
669
670    #[test]
671    fn test_session_lifecycle() {
672        let mut reasoner = make_reasoner();
673
674        let session_id = reasoner.start_session("parent(alice, bob)");
675        assert!(!reasoner.is_session_complete(&session_id));
676
677        reasoner
678            .add_session_peer(&session_id, "peer1")
679            .expect("add peer1");
680        reasoner
681            .mark_peer_responded(&session_id, "peer1")
682            .expect("peer1 responded");
683
684        // All registered peers have replied → session is complete.
685        assert!(reasoner.is_session_complete(&session_id));
686    }
687
688    #[test]
689    fn test_multiple_peers_session() {
690        let mut reasoner = make_reasoner();
691        let sid = reasoner.start_session("ancestor(alice, Z)");
692
693        reasoner.add_session_peer(&sid, "peer1").expect("add peer1");
694        reasoner.add_session_peer(&sid, "peer2").expect("add peer2");
695
696        // Only one has responded yet.
697        reasoner
698            .mark_peer_responded(&sid, "peer1")
699            .expect("peer1 responded");
700        assert!(!reasoner.is_session_complete(&sid));
701
702        reasoner
703            .mark_peer_responded(&sid, "peer2")
704            .expect("peer2 responded");
705        assert!(reasoner.is_session_complete(&sid));
706    }
707
708    // ── Recording remote results ─────────────────────────────────────────────
709
710    #[test]
711    fn test_record_remote_result() {
712        let mut reasoner = make_reasoner();
713        let sid = reasoner.start_session("parent(alice, X)");
714
715        reasoner
716            .add_session_peer(&sid, "peer-alpha")
717            .expect("add peer");
718
719        let result = RemoteResult {
720            peer_id: "peer-alpha".to_string(),
721            bindings: [("X".to_string(), "bob".to_string())].into_iter().collect(),
722            proof_depth: 1,
723            latency_ms: 42,
724        };
725        reasoner
726            .record_remote_result(&sid, result)
727            .expect("record result");
728
729        // The peer should now be in completed_peers, not pending.
730        let session = reasoner.sessions.get(&sid).expect("session exists");
731        assert!(!session.pending_peers.contains("peer-alpha"));
732        assert!(session.completed_peers.contains("peer-alpha"));
733        assert_eq!(session.remote_results.len(), 1);
734
735        let results = reasoner.get_session_results(&sid).expect("get results");
736        assert_eq!(results.len(), 1);
737        assert_eq!(results[0].latency_ms, 42);
738    }
739
740    #[test]
741    fn test_record_result_unknown_session() {
742        let mut reasoner = make_reasoner();
743        let result = RemoteResult {
744            peer_id: "ghost".to_string(),
745            bindings: HashMap::new(),
746            proof_depth: 0,
747            latency_ms: 0,
748        };
749        let err = reasoner.record_remote_result("no-such-id", result);
750        assert!(matches!(err, Err(ReasoningError::SessionNotFound(_))));
751    }
752
753    // ── Timeout detection ────────────────────────────────────────────────────
754
755    #[test]
756    fn test_session_timeout_detection() {
757        let mut reasoner = DistributedReasonerV2::new(DistributedReasonerConfig {
758            max_depth: 3,
759            // Very short timeout so we can test expiry without sleeping.
760            timeout: std::time::Duration::from_nanos(1),
761            max_peers: 2,
762            cache_ttl: std::time::Duration::from_secs(60),
763            parallel_queries: 1,
764        });
765
766        let sid = reasoner.start_session("slow_predicate(X)");
767        reasoner
768            .add_session_peer(&sid, "slow-peer")
769            .expect("add peer");
770
771        // Even though "slow-peer" has not replied, the session is considered
772        // complete because the timeout (1 ns) has elapsed.
773        assert!(reasoner.is_session_complete(&sid));
774
775        let session = reasoner.sessions.get(&sid).expect("session present");
776        assert!(session.is_expired());
777    }
778
779    // ── Cleanup ──────────────────────────────────────────────────────────────
780
781    #[test]
782    fn test_cleanup_expired_sessions() {
783        let mut reasoner = DistributedReasonerV2::new(DistributedReasonerConfig {
784            max_depth: 3,
785            timeout: std::time::Duration::from_nanos(1), // instantly expired
786            max_peers: 2,
787            cache_ttl: std::time::Duration::from_secs(60),
788            parallel_queries: 1,
789        });
790
791        let _s1 = reasoner.start_session("foo(X)");
792        let _s2 = reasoner.start_session("bar(Y)");
793        assert_eq!(reasoner.session_count(), 2);
794
795        let cleaned = reasoner.cleanup_expired_sessions();
796        assert_eq!(cleaned, 2);
797        assert_eq!(reasoner.session_count(), 0);
798    }
799
800    #[test]
801    fn test_cleanup_keeps_active_sessions() {
802        let mut reasoner = make_reasoner(); // 10 s timeout → will not expire
803
804        let _sid = reasoner.start_session("live_goal(X)");
805        assert_eq!(reasoner.session_count(), 1);
806
807        let cleaned = reasoner.cleanup_expired_sessions();
808        assert_eq!(cleaned, 0);
809        assert_eq!(reasoner.session_count(), 1);
810    }
811
812    // ── Statistics ───────────────────────────────────────────────────────────
813
814    #[test]
815    fn test_session_stats_empty() {
816        let reasoner = make_reasoner();
817        let stats = reasoner.session_stats();
818        assert_eq!(stats.active_sessions, 0);
819        assert_eq!(stats.total_results, 0);
820        assert_eq!(stats.avg_latency_ms, 0.0);
821        assert_eq!(stats.cache_hit_rate, 0.0);
822    }
823
824    #[test]
825    fn test_session_stats() {
826        let mut reasoner = make_reasoner();
827        let sid = reasoner.start_session("grandparent(alice, Z)");
828
829        reasoner.add_session_peer(&sid, "p1").expect("add p1");
830        reasoner.add_session_peer(&sid, "p2").expect("add p2");
831
832        reasoner
833            .record_remote_result(
834                &sid,
835                RemoteResult {
836                    peer_id: "p1".to_string(),
837                    bindings: HashMap::new(),
838                    proof_depth: 2,
839                    latency_ms: 100,
840                },
841            )
842            .expect("record p1 result");
843
844        reasoner
845            .record_remote_result(
846                &sid,
847                RemoteResult {
848                    peer_id: "p2".to_string(),
849                    bindings: HashMap::new(),
850                    proof_depth: 1,
851                    latency_ms: 200,
852                },
853            )
854            .expect("record p2 result");
855
856        let stats = reasoner.session_stats();
857        // One session still has 0 pending peers → complete, so active == 0.
858        assert_eq!(stats.active_sessions, 0);
859        assert_eq!(stats.total_results, 2);
860        // Average of 100 and 200.
861        assert!((stats.avg_latency_ms - 150.0).abs() < f64::EPSILON);
862    }
863
864    #[test]
865    fn test_session_stats_active_count() {
866        let mut reasoner = make_reasoner();
867        let sid = reasoner.start_session("pending_goal(X)");
868        reasoner
869            .add_session_peer(&sid, "waiting-peer")
870            .expect("add peer");
871        // "waiting-peer" has NOT responded → session is active.
872        let stats = reasoner.session_stats();
873        assert_eq!(stats.active_sessions, 1);
874    }
875
876    // ── Wire-format serialization ────────────────────────────────────────────
877
878    #[test]
879    fn test_inference_request_serde() {
880        let req = InferenceRequest {
881            request_id: "req-001".to_string(),
882            goal: "ancestor(alice, X)".to_string(),
883            max_depth: 7,
884            requester_peer_id: "12D3KooW...".to_string(),
885        };
886
887        let json = serde_json::to_string(&req).expect("serialize InferenceRequest");
888        let decoded: InferenceRequest =
889            serde_json::from_str(&json).expect("deserialize InferenceRequest");
890
891        assert_eq!(req.request_id, decoded.request_id);
892        assert_eq!(req.goal, decoded.goal);
893        assert_eq!(req.max_depth, decoded.max_depth);
894        assert_eq!(req.requester_peer_id, decoded.requester_peer_id);
895    }
896
897    #[test]
898    fn test_inference_response_serde() {
899        let mut bindings = HashMap::new();
900        bindings.insert("X".to_string(), "charlie".to_string());
901
902        let resp = InferenceResponse {
903            request_id: "req-001".to_string(),
904            bindings: vec![bindings],
905            proof_found: true,
906            error: None,
907        };
908
909        let json = serde_json::to_string(&resp).expect("serialize InferenceResponse");
910        let decoded: InferenceResponse =
911            serde_json::from_str(&json).expect("deserialize InferenceResponse");
912
913        assert_eq!(resp.request_id, decoded.request_id);
914        assert!(decoded.proof_found);
915        assert!(decoded.error.is_none());
916        assert_eq!(decoded.bindings.len(), 1);
917        assert_eq!(
918            decoded.bindings[0].get("X").map(String::as_str),
919            Some("charlie")
920        );
921    }
922
923    #[test]
924    fn test_inference_response_with_error_serde() {
925        let resp = InferenceResponse {
926            request_id: "req-err".to_string(),
927            bindings: vec![],
928            proof_found: false,
929            error: Some("depth limit exceeded".to_string()),
930        };
931
932        let json = serde_json::to_string(&resp).expect("serialize");
933        let decoded: InferenceResponse = serde_json::from_str(&json).expect("deserialize");
934        assert_eq!(decoded.error.as_deref(), Some("depth limit exceeded"));
935    }
936
937    #[test]
938    fn test_remote_result_serde() {
939        let mut bindings = HashMap::new();
940        bindings.insert("Y".to_string(), "dave".to_string());
941
942        let result = RemoteResult {
943            peer_id: "QmPeer123".to_string(),
944            bindings,
945            proof_depth: 3,
946            latency_ms: 55,
947        };
948
949        let json = serde_json::to_string(&result).expect("serialize RemoteResult");
950        let decoded: RemoteResult = serde_json::from_str(&json).expect("deserialize RemoteResult");
951
952        assert_eq!(decoded.peer_id, "QmPeer123");
953        assert_eq!(decoded.proof_depth, 3);
954        assert_eq!(decoded.latency_ms, 55);
955        assert_eq!(decoded.bindings.get("Y").map(String::as_str), Some("dave"));
956    }
957
958    // ── Error handling ───────────────────────────────────────────────────────
959
960    #[test]
961    fn test_add_peer_unknown_session() {
962        let mut reasoner = make_reasoner();
963        let err = reasoner.add_session_peer("ghost-session", "peer1");
964        assert!(matches!(err, Err(ReasoningError::SessionNotFound(_))));
965    }
966
967    #[test]
968    fn test_add_duplicate_peer() {
969        let mut reasoner = make_reasoner();
970        let sid = reasoner.start_session("dup_peer_test(X)");
971        reasoner
972            .add_session_peer(&sid, "peer1")
973            .expect("first add ok");
974        let err = reasoner.add_session_peer(&sid, "peer1");
975        assert!(matches!(err, Err(ReasoningError::PeerAlreadyRegistered(_))));
976    }
977
978    #[test]
979    fn test_mark_unregistered_peer_responded() {
980        let mut reasoner = make_reasoner();
981        let sid = reasoner.start_session("unregistered(X)");
982        let err = reasoner.mark_peer_responded(&sid, "ghost-peer");
983        assert!(matches!(err, Err(ReasoningError::PeerNotRegistered(_))));
984    }
985
986    // ── Cache hit-rate tracking ──────────────────────────────────────────────
987
988    #[test]
989    fn test_cache_hit_rate() {
990        let mut reasoner = make_reasoner();
991        let sid = reasoner.start_session("cached_goal(X)");
992
993        let result = RemoteResult {
994            peer_id: "cache-peer".to_string(),
995            bindings: HashMap::new(),
996            proof_depth: 1,
997            latency_ms: 10,
998        };
999        reasoner.record_remote_result(&sid, result).expect("record");
1000
1001        // First lookup → populates cache, no hit yet.
1002        let _ = reasoner.get_session_results(&sid);
1003
1004        // Start a second session for the SAME goal to trigger the cache.
1005        let sid2 = reasoner.start_session("cached_goal(X)");
1006        let _ = reasoner.get_session_results(&sid2);
1007
1008        let stats = reasoner.session_stats();
1009        // 2 lookups, 1 hit → rate = 0.5
1010        assert!((stats.cache_hit_rate - 0.5).abs() < f64::EPSILON);
1011    }
1012
1013    // ── Phase 2: gc_sessions and session_metrics ─────────────────────────────
1014
1015    /// Verify `gc_sessions` removes sessions older than `max_age_secs` and
1016    /// returns the number pruned.
1017    #[test]
1018    fn test_session_gc() {
1019        // Use a 1-ns timeout so sessions are instantly "old".
1020        let mut reasoner = DistributedReasonerV2::new(DistributedReasonerConfig {
1021            max_depth: 3,
1022            timeout: std::time::Duration::from_nanos(1),
1023            max_peers: 2,
1024            cache_ttl: std::time::Duration::from_secs(60),
1025            parallel_queries: 1,
1026        });
1027
1028        // Create 5 sessions; all will be instantly aged.
1029        for i in 0..5 {
1030            reasoner.start_session(&format!("goal_{i}(X)"));
1031        }
1032        assert_eq!(reasoner.session_count(), 5);
1033
1034        // GC with max_age = 0 s → all 5 sessions are older than 0 s.
1035        let removed = reasoner.gc_sessions(0);
1036        assert_eq!(removed, 5, "all 5 sessions should have been gc'd");
1037        assert_eq!(reasoner.session_count(), 0);
1038    }
1039
1040    /// Verify that `gc_sessions` does not remove recently-created sessions.
1041    #[test]
1042    fn test_session_gc_keeps_recent() {
1043        let mut reasoner = make_reasoner(); // 10 s timeout
1044
1045        for i in 0..5 {
1046            reasoner.start_session(&format!("fresh_{i}(X)"));
1047        }
1048        assert_eq!(reasoner.session_count(), 5);
1049
1050        // max_age = 3600 s → none of the sessions are that old yet.
1051        let removed = reasoner.gc_sessions(3600);
1052        assert_eq!(removed, 0, "no session should have been gc'd");
1053        assert_eq!(reasoner.session_count(), 5);
1054    }
1055
1056    /// Verify `session_metrics` accurately reflects session states.
1057    #[test]
1058    fn test_session_metrics() {
1059        let mut reasoner = make_reasoner(); // 10 s timeout
1060
1061        // Session A: active (peer registered but not yet replied).
1062        let sid_a = reasoner.start_session("active_goal(X)");
1063        reasoner
1064            .add_session_peer(&sid_a, "peer-a")
1065            .expect("add peer");
1066
1067        // Session B: completed (all peers replied).
1068        let sid_b = reasoner.start_session("done_goal(Y)");
1069        reasoner
1070            .add_session_peer(&sid_b, "peer-b")
1071            .expect("add peer-b");
1072        reasoner
1073            .record_remote_result(
1074                &sid_b,
1075                RemoteResult {
1076                    peer_id: "peer-b".to_string(),
1077                    bindings: HashMap::new(),
1078                    proof_depth: 1,
1079                    latency_ms: 80,
1080                },
1081            )
1082            .expect("record peer-b");
1083
1084        let metrics = reasoner.session_metrics();
1085
1086        // Session A is active; session B is completed.
1087        assert_eq!(metrics.active_sessions, 1, "one session should be active");
1088        assert_eq!(
1089            metrics.completed_sessions, 1,
1090            "one session should be completed"
1091        );
1092        // avg_peers: session A has 1 peer (pending), session B has 1 peer
1093        // (completed) → total 2 peers across 2 sessions → avg 1.0.
1094        assert!(
1095            (metrics.avg_peers_per_session - 1.0).abs() < f64::EPSILON,
1096            "avg_peers_per_session should be 1.0, got {}",
1097            metrics.avg_peers_per_session
1098        );
1099        // Only one remote result recorded (peer-b, 80 ms).
1100        assert!(
1101            (metrics.avg_latency_ms - 80.0).abs() < f64::EPSILON,
1102            "avg_latency_ms should be 80.0, got {}",
1103            metrics.avg_latency_ms
1104        );
1105    }
1106
1107    // ── Phase 2: InferenceResultStream ───────────────────────────────────────
1108
1109    /// Push 3 mock responses through the channel; verify that `next_partial`
1110    /// delivers exactly 3 partial results with the correct binding counts.
1111    #[tokio::test]
1112    async fn test_inference_result_stream_collects() {
1113        let (tx, rx) = tokio::sync::mpsc::channel::<InferenceResponse>(16);
1114
1115        // Deadline 10 seconds from now — plenty of time.
1116        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
1117        let mut stream = InferenceResultStream::new("session-123".to_string(), rx, deadline);
1118
1119        // Send 3 mock responses before polling.
1120        for i in 0u32..3 {
1121            let mut b = HashMap::new();
1122            b.insert("X".to_string(), format!("val{i}"));
1123            tx.send(InferenceResponse {
1124                request_id: format!("peer-{i}"),
1125                bindings: vec![b],
1126                proof_found: true,
1127                error: None,
1128            })
1129            .await
1130            .expect("send");
1131        }
1132        // Drop the sender so the channel closes after the 3 messages.
1133        drop(tx);
1134
1135        assert_eq!(stream.session_id(), "session-123");
1136
1137        let mut received_count = 0usize;
1138        while let Some(partial) = stream.next_partial().await {
1139            received_count += 1;
1140            assert_eq!(
1141                partial.new_bindings.len(),
1142                1,
1143                "each peer sends exactly one binding set"
1144            );
1145            assert_eq!(
1146                partial.total_so_far, received_count,
1147                "accumulated count must grow monotonically"
1148            );
1149        }
1150
1151        assert_eq!(
1152            received_count, 3,
1153            "must have received exactly 3 partial results"
1154        );
1155        assert_eq!(stream.result_count(), 3);
1156    }
1157}