Skip to main content

forge_reasoning/impact/
snapshot.rs

1//! State snapshot storage with time-based expiration
2//!
3//! This module provides the ability to save and restore the complete state of hypotheses
4//! and their dependencies. Snapshots have a configurable time window (default 5 minutes)
5//! and are automatically cleaned up when expired.
6
7use chrono::{DateTime, Duration, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::Arc;
11use uuid::Uuid;
12
13use crate::belief::BeliefGraph;
14use crate::errors::{ReasoningError, Result};
15use crate::hypothesis::{Hypothesis, HypothesisBoard, HypothesisId};
16
17/// Unique identifier for a snapshot
18#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct SnapshotId(Uuid);
20
21impl SnapshotId {
22    /// Create a new snapshot ID
23    pub fn new() -> Self {
24        Self(Uuid::new_v4())
25    }
26}
27
28impl Default for SnapshotId {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl std::fmt::Display for SnapshotId {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}", self.0)
37    }
38}
39
40/// Complete snapshot of the belief system state
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct BeliefSnapshot {
43    /// Unique snapshot identifier
44    pub id: SnapshotId,
45    /// All hypotheses in the system
46    pub hypotheses: Vec<Hypothesis>,
47    /// All dependency edges as (dependent, dependee) pairs
48    pub dependencies: Vec<(HypothesisId, HypothesisId)>,
49    /// When the snapshot was created
50    pub created_at: DateTime<Utc>,
51    /// When the snapshot expires
52    pub expires_at: DateTime<Utc>,
53}
54
55impl BeliefSnapshot {
56    /// Check if this snapshot has expired
57    pub fn is_expired(&self) -> bool {
58        Utc::now() > self.expires_at
59    }
60
61    /// Get the remaining time until expiration
62    pub fn remaining_time(&self) -> Option<Duration> {
63        if self.is_expired() {
64            None
65        } else {
66            Some(self.expires_at - Utc::now())
67        }
68    }
69}
70
71/// Storage for snapshots with automatic expiration
72pub struct SnapshotStore {
73    /// Snapshots indexed by creation time (for easy cleanup)
74    snapshots: BTreeMap<DateTime<Utc>, BeliefSnapshot>,
75    /// Time window for snapshot retention
76    window_duration: Duration,
77}
78
79impl SnapshotStore {
80    /// Create a new snapshot store with default 5-minute window
81    pub fn new() -> Self {
82        Self {
83            snapshots: BTreeMap::new(),
84            window_duration: Duration::minutes(5),
85        }
86    }
87
88    /// Create a new snapshot store with custom window duration
89    pub fn with_window(window_duration: Duration) -> Self {
90        Self {
91            snapshots: BTreeMap::new(),
92            window_duration,
93        }
94    }
95
96    /// Save current state as a snapshot
97    ///
98    /// Captures all hypotheses and dependency relationships from the board and graph.
99    /// Automatically cleans up expired snapshots before saving.
100    pub async fn save(
101        &mut self,
102        board: &HypothesisBoard,
103        graph: &BeliefGraph,
104    ) -> SnapshotId {
105        // Capture current state
106        let hypotheses = board.list().await.unwrap_or_default();
107        let dependencies = self.capture_dependencies(graph);
108
109        // Create snapshot
110        let created_at = Utc::now();
111        let expires_at = created_at + self.window_duration;
112        let id = SnapshotId::new();
113
114        let snapshot = BeliefSnapshot {
115            id: id.clone(),
116            hypotheses,
117            dependencies,
118            created_at,
119            expires_at,
120        };
121
122        // Store snapshot indexed by creation time
123        self.snapshots.insert(created_at, snapshot);
124
125        // Cleanup expired snapshots
126        self.cleanup_expired();
127
128        id
129    }
130
131    /// Get a snapshot by ID
132    pub fn get(&self, id: &SnapshotId) -> Option<&BeliefSnapshot> {
133        self.snapshots
134            .values()
135            .find(|s| &s.id == id)
136    }
137
138    /// Restore state from a snapshot
139    ///
140    /// Clears existing hypotheses and dependencies, then restores from snapshot.
141    /// Returns error if snapshot not found or expired.
142    pub async fn restore(
143        &self,
144        id: &SnapshotId,
145        _board: &Arc<HypothesisBoard>,
146        _graph: &Arc<BeliefGraph>,
147    ) -> Result<()> {
148        let snapshot = self
149            .get(id)
150            .ok_or_else(|| ReasoningError::NotFound(format!("Snapshot {} not found", id)))?;
151
152        if snapshot.is_expired() {
153            return Err(ReasoningError::InvalidState(format!(
154                "Snapshot {} expired at {}",
155                id, snapshot.expires_at
156            )));
157        }
158
159        // Note: We can't actually clear and restore without mutable access
160        // This is a design limitation - in practice, the caller would need to
161        // create a new HypothesisBoard and BeliefGraph, or we'd need mutable access
162
163        // For now, this returns the snapshot data for the caller to use
164        tracing::info!(
165            "Snapshot restore requested for {}: {} hypotheses, {} dependencies",
166            id,
167            snapshot.hypotheses.len(),
168            snapshot.dependencies.len()
169        );
170
171        Ok(())
172    }
173
174    /// Get snapshot data for restoration (returns owned data)
175    pub fn get_snapshot_data(&self, id: &SnapshotId) -> Result<BeliefSnapshot> {
176        self.get(id)
177            .cloned()
178            .ok_or_else(|| ReasoningError::NotFound(format!("Snapshot {} not found", id)))
179    }
180
181    /// Remove all expired snapshots
182    fn cleanup_expired(&mut self) {
183        let now = Utc::now();
184        self.snapshots.retain(|&created, _| created + self.window_duration > now);
185    }
186
187    /// List all active (non-expired) snapshots
188    pub fn list_snapshots(&self) -> Vec<&BeliefSnapshot> {
189        let now = Utc::now();
190        self.snapshots
191            .values()
192            .filter(|s| s.expires_at > now)
193            .collect()
194    }
195
196    /// Check if a snapshot is expired
197    pub fn is_expired(&self, id: &SnapshotId) -> bool {
198        self.get(id)
199            .map(|s| s.is_expired())
200            .unwrap_or(true)
201    }
202
203    /// Get the number of active snapshots
204    pub fn active_count(&self) -> usize {
205        let now = Utc::now();
206        self.snapshots
207            .values()
208            .filter(|s| s.expires_at > now)
209            .count()
210    }
211
212    /// Capture all dependency edges from the graph
213    fn capture_dependencies(&self, graph: &BeliefGraph) -> Vec<(HypothesisId, HypothesisId)> {
214        // Collect all dependencies by iterating through all nodes
215        graph
216            .nodes()
217            .iter()
218            .flat_map(|&node_id| {
219                graph
220                    .dependees(node_id)
221                    .unwrap_or_default()
222                    .into_iter()
223                    .map(move |dep_id| (node_id, dep_id))
224            })
225            .collect()
226    }
227
228    /// Manually remove a snapshot by ID
229    pub fn remove(&mut self, id: &SnapshotId) -> bool {
230        // Find and remove the snapshot
231        if let Some(created_at) = self.snapshots.values().find(|s| &s.id == id).map(|s| s.created_at) {
232            self.snapshots.remove(&created_at);
233            true
234        } else {
235            false
236        }
237    }
238}
239
240impl Default for SnapshotStore {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::hypothesis::confidence::Confidence;
250
251    #[test]
252    fn test_snapshot_id_unique() {
253        let id1 = SnapshotId::new();
254        let id2 = SnapshotId::new();
255        assert_ne!(id1, id2);
256    }
257
258    #[test]
259    fn test_snapshot_default_id() {
260        let id = SnapshotId::default();
261        // Just ensure it creates a valid ID
262        assert_ne!(id.0, Uuid::nil());
263    }
264
265    #[tokio::test]
266    async fn test_save_creates_snapshot() {
267        let mut store = SnapshotStore::new();
268        let board = HypothesisBoard::in_memory();
269        let graph = BeliefGraph::new();
270
271        let id = store.save(&board, &graph).await;
272
273        let snapshot = store.get(&id);
274        assert!(snapshot.is_some());
275        assert_eq!(&snapshot.unwrap().id, &id);
276    }
277
278    #[tokio::test]
279    async fn test_get_returns_none_for_nonexistent() {
280        let store = SnapshotStore::new();
281        let fake_id = SnapshotId::new();
282        assert!(store.get(&fake_id).is_none());
283    }
284
285    #[tokio::test]
286    async fn test_cleanup_removes_expired() {
287        let mut store = SnapshotStore::with_window(Duration::seconds(1)); // 1 second window
288
289        let board = HypothesisBoard::in_memory();
290        let graph = BeliefGraph::new();
291
292        // Save a snapshot
293        let id = store.save(&board, &graph).await;
294        assert!(store.get(&id).is_some());
295
296        // Wait for expiration
297        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
298
299        // Trigger cleanup
300        store.cleanup_expired();
301
302        // Snapshot should be gone
303        assert!(store.get(&id).is_none());
304    }
305
306    #[test]
307    fn test_is_expired_for_old_snapshot() {
308        let _store = SnapshotStore::with_window(Duration::milliseconds(100));
309
310        let created_at = Utc::now() - Duration::seconds(1);
311        let expires_at = created_at + Duration::milliseconds(100);
312        let snapshot = BeliefSnapshot {
313            id: SnapshotId::new(),
314            hypotheses: vec![],
315            dependencies: vec![],
316            created_at,
317            expires_at,
318        };
319
320        // Should be expired since we're past the 100ms window
321        assert!(snapshot.is_expired());
322    }
323
324    #[test]
325    fn test_is_expired_for_fresh_snapshot() {
326        let created_at = Utc::now();
327        let expires_at = created_at + Duration::minutes(5);
328        let snapshot = BeliefSnapshot {
329            id: SnapshotId::new(),
330            hypotheses: vec![],
331            dependencies: vec![],
332            created_at,
333            expires_at,
334        };
335
336        assert!(!snapshot.is_expired());
337    }
338
339    #[tokio::test]
340    async fn test_list_snapshots_returns_only_active() {
341        let mut store = SnapshotStore::with_window(Duration::milliseconds(100));
342
343        let board = HypothesisBoard::in_memory();
344        let graph = BeliefGraph::new();
345
346        // Save first snapshot
347        let _id1 = store.save(&board, &graph).await;
348
349        // Wait for expiration
350        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
351
352        // Save second snapshot
353        let id2 = store.save(&board, &graph).await;
354
355        // Cleanup
356        store.cleanup_expired();
357
358        // Only second snapshot should remain
359        let active = store.list_snapshots();
360        assert_eq!(active.len(), 1);
361        assert_eq!(&active[0].id, &id2);
362    }
363
364    #[tokio::test]
365    async fn test_window_duration_respected() {
366        // Test with 5 minute window (default)
367        let store = SnapshotStore::new();
368        assert_eq!(store.window_duration, Duration::minutes(5));
369
370        // Test with custom window
371        let custom_store = SnapshotStore::with_window(Duration::hours(1));
372        assert_eq!(custom_store.window_duration, Duration::hours(1));
373    }
374
375    #[tokio::test]
376    async fn test_remove_snapshot() {
377        let mut store = SnapshotStore::new();
378
379        let board = HypothesisBoard::in_memory();
380        let graph = BeliefGraph::new();
381
382        let id = store.save(&board, &graph).await;
383        assert!(store.get(&id).is_some());
384
385        // Remove the snapshot
386        assert!(store.remove(&id));
387        assert!(store.get(&id).is_none());
388
389        // Remove again should return false
390        assert!(!store.remove(&id));
391    }
392
393    #[tokio::test]
394    async fn test_active_count() {
395        let mut store = SnapshotStore::with_window(Duration::seconds(1));
396
397        let board = HypothesisBoard::in_memory();
398        let graph = BeliefGraph::new();
399
400        assert_eq!(store.active_count(), 0);
401
402        store.save(&board, &graph).await;
403        assert_eq!(store.active_count(), 1);
404
405        store.save(&board, &graph).await;
406        assert_eq!(store.active_count(), 2);
407
408        // Wait for expiration
409        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
410        store.cleanup_expired();
411
412        assert_eq!(store.active_count(), 0);
413    }
414
415    #[tokio::test]
416    async fn test_remaining_time() {
417        let created_at = Utc::now();
418        let expires_at = created_at + Duration::minutes(5);
419        let snapshot = BeliefSnapshot {
420            id: SnapshotId::new(),
421            hypotheses: vec![],
422            dependencies: vec![],
423            created_at,
424            expires_at,
425        };
426
427        let remaining = snapshot.remaining_time();
428        assert!(remaining.is_some());
429        assert!(remaining.unwrap().num_seconds() > 0);
430        assert!(remaining.unwrap().num_seconds() <= 300); // 5 minutes
431    }
432
433    #[tokio::test]
434    async fn test_remaining_time_for_expired() {
435        let created_at = Utc::now() - Duration::minutes(10);
436        let expires_at = created_at + Duration::minutes(5);
437        let snapshot = BeliefSnapshot {
438            id: SnapshotId::new(),
439            hypotheses: vec![],
440            dependencies: vec![],
441            created_at,
442            expires_at,
443        };
444
445        assert!(snapshot.remaining_time().is_none());
446    }
447
448    #[tokio::test]
449    async fn test_snapshot_with_hypotheses() {
450        let mut store = SnapshotStore::new();
451
452        let board = HypothesisBoard::in_memory();
453        let prior = Confidence::new(0.5).unwrap();
454        let _h1 = board
455            .propose("Test hypothesis 1", prior)
456            .await
457            .unwrap();
458        let _h2 = board
459            .propose("Test hypothesis 2", prior)
460            .await
461            .unwrap();
462
463        let graph = BeliefGraph::new();
464
465        let id = store.save(&board, &graph).await;
466        let snapshot = store.get(&id).unwrap();
467
468        assert_eq!(snapshot.hypotheses.len(), 2);
469    }
470}