Skip to main content

ipfrs_tensorlogic/
state_snapshot.rs

1//! TensorStateSnapshot — captures and restores complete TensorLogic runtime state
2//! for migration, debugging, and distributed coordination purposes.
3
4use std::collections::HashMap;
5
6// ---------------------------------------------------------------------------
7// FNV-1a helper
8// ---------------------------------------------------------------------------
9
10/// Compute FNV-1a 64-bit hash of a byte slice.
11pub fn fnv1a_u64(bytes: &[u8]) -> u64 {
12    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037_u64;
13    const PRIME: u64 = 1_099_511_628_211_u64;
14    let mut hash = OFFSET_BASIS;
15    for &b in bytes {
16        hash ^= b as u64;
17        hash = hash.wrapping_mul(PRIME);
18    }
19    hash
20}
21
22// ---------------------------------------------------------------------------
23// SnapshotField
24// ---------------------------------------------------------------------------
25
26/// A logical field (partition) that may be included in a snapshot.
27#[derive(Clone, Debug, PartialEq, Eq, Hash)]
28pub enum SnapshotField {
29    /// Rule set included.
30    Rules,
31    /// Fact base included.
32    Facts,
33    /// Raw tensor data included.
34    TensorValues,
35    /// System metadata included.
36    Metadata,
37    /// Active session data included.
38    Sessions,
39}
40
41// ---------------------------------------------------------------------------
42// FieldData
43// ---------------------------------------------------------------------------
44
45/// Metadata describing one field captured inside a [`StateSnapshot`].
46#[derive(Clone, Debug, PartialEq)]
47pub struct FieldData {
48    /// Which logical field this record describes.
49    pub field: SnapshotField,
50    /// Serialised byte size of the field payload.
51    pub size_bytes: u64,
52    /// FNV-1a checksum of the field-name bytes.
53    pub checksum: u64,
54    /// Number of logical records in the field (rules, facts, tensors, …).
55    pub record_count: usize,
56}
57
58impl FieldData {
59    /// Construct a [`FieldData`], computing the checksum automatically from
60    /// the canonical string name of the field.
61    pub fn new(field: SnapshotField, size_bytes: u64, record_count: usize) -> Self {
62        let checksum = fnv1a_u64(field_name_bytes(&field));
63        Self {
64            field,
65            size_bytes,
66            checksum,
67            record_count,
68        }
69    }
70}
71
72/// Return the canonical ASCII name bytes for a [`SnapshotField`].
73fn field_name_bytes(field: &SnapshotField) -> &'static [u8] {
74    match field {
75        SnapshotField::Rules => b"Rules",
76        SnapshotField::Facts => b"Facts",
77        SnapshotField::TensorValues => b"TensorValues",
78        SnapshotField::Metadata => b"Metadata",
79        SnapshotField::Sessions => b"Sessions",
80    }
81}
82
83// ---------------------------------------------------------------------------
84// StateSnapshot
85// ---------------------------------------------------------------------------
86
87/// A complete, point-in-time snapshot of TensorLogic runtime state.
88#[derive(Clone, Debug)]
89pub struct StateSnapshot {
90    /// Unique, monotonically increasing identifier.
91    pub snapshot_id: u64,
92    /// Unix epoch seconds when the snapshot was taken.
93    pub created_at_secs: u64,
94    /// Identifier of the node that created the snapshot.
95    pub node_id: String,
96    /// Fields captured in this snapshot.
97    pub fields: Vec<FieldData>,
98    /// Schema / format version (default 1).
99    pub format_version: u32,
100}
101
102impl StateSnapshot {
103    /// Sum of all field payload sizes in bytes.
104    pub fn total_size(&self) -> u64 {
105        self.fields.iter().map(|f| f.size_bytes).sum()
106    }
107
108    /// Returns `true` when the snapshot contains `field`.
109    pub fn has_field(&self, field: &SnapshotField) -> bool {
110        self.fields.iter().any(|f| &f.field == field)
111    }
112
113    /// Number of distinct fields present in the snapshot.
114    pub fn field_count(&self) -> usize {
115        self.fields.len()
116    }
117}
118
119// ---------------------------------------------------------------------------
120// SnapshotDelta
121// ---------------------------------------------------------------------------
122
123/// Difference between two [`StateSnapshot`]s.
124#[derive(Clone, Debug)]
125pub struct SnapshotDelta {
126    /// Snapshot used as the baseline.
127    pub base_snapshot_id: u64,
128    /// Snapshot that was compared against the baseline.
129    pub new_snapshot_id: u64,
130    /// Fields present in the newer snapshot but absent from the baseline.
131    pub added_fields: Vec<SnapshotField>,
132    /// Fields present in the baseline but absent from the newer snapshot.
133    pub removed_fields: Vec<SnapshotField>,
134    /// Signed byte-size difference (`new.total_size() - old.total_size()`).
135    pub size_delta_bytes: i64,
136}
137
138impl SnapshotDelta {
139    /// Returns `true` when there are no added or removed fields.
140    pub fn is_empty(&self) -> bool {
141        self.added_fields.is_empty() && self.removed_fields.is_empty()
142    }
143}
144
145// ---------------------------------------------------------------------------
146// StateSnapshotStats
147// ---------------------------------------------------------------------------
148
149/// Aggregate statistics for a [`TensorStateSnapshot`] manager.
150///
151/// Named `StateSnapshotStats` to avoid collision with `SnapshotManagerStats`
152/// in the storage module.
153#[derive(Clone, Debug)]
154pub struct StateSnapshotStats {
155    /// Total number of snapshots currently retained.
156    pub total_snapshots: usize,
157    /// Combined byte size across all retained snapshots.
158    pub total_size_bytes: u64,
159    /// `snapshot_id` of the oldest retained snapshot, or `None` if empty.
160    pub oldest_snapshot_id: Option<u64>,
161    /// `snapshot_id` of the most-recently-captured snapshot, or `None` if empty.
162    pub newest_snapshot_id: Option<u64>,
163}
164
165// ---------------------------------------------------------------------------
166// TensorStateSnapshot
167// ---------------------------------------------------------------------------
168
169/// Manages captures and retrieval of [`StateSnapshot`]s.
170pub struct TensorStateSnapshot {
171    /// All retained snapshots keyed by their `snapshot_id`.
172    pub snapshots: HashMap<u64, StateSnapshot>,
173    /// Counter used to assign the next unique id.
174    pub next_id: u64,
175}
176
177impl TensorStateSnapshot {
178    /// Create a new, empty manager.
179    pub fn new() -> Self {
180        Self {
181            snapshots: HashMap::new(),
182            next_id: 1,
183        }
184    }
185
186    /// Capture a snapshot for `node_id` containing `fields` at `now_secs`.
187    ///
188    /// Returns the assigned `snapshot_id`.
189    pub fn capture(&mut self, node_id: &str, fields: Vec<FieldData>, now_secs: u64) -> u64 {
190        let id = self.next_id;
191        self.next_id += 1;
192        let snapshot = StateSnapshot {
193            snapshot_id: id,
194            created_at_secs: now_secs,
195            node_id: node_id.to_owned(),
196            fields,
197            format_version: 1,
198        };
199        self.snapshots.insert(id, snapshot);
200        id
201    }
202
203    /// Retrieve a snapshot by id.
204    pub fn get(&self, id: u64) -> Option<&StateSnapshot> {
205        self.snapshots.get(&id)
206    }
207
208    /// Delete a snapshot by id.  Returns `true` when the snapshot existed.
209    pub fn delete(&mut self, id: u64) -> bool {
210        self.snapshots.remove(&id).is_some()
211    }
212
213    /// Compute the difference between snapshot `old_id` and snapshot `new_id`.
214    ///
215    /// Returns `None` if either id is not found.
216    pub fn diff(&self, old_id: u64, new_id: u64) -> Option<SnapshotDelta> {
217        let old = self.snapshots.get(&old_id)?;
218        let new = self.snapshots.get(&new_id)?;
219
220        let old_fields: std::collections::HashSet<&SnapshotField> =
221            old.fields.iter().map(|f| &f.field).collect();
222        let new_fields: std::collections::HashSet<&SnapshotField> =
223            new.fields.iter().map(|f| &f.field).collect();
224
225        let added_fields: Vec<SnapshotField> = new_fields
226            .difference(&old_fields)
227            .map(|f| (*f).clone())
228            .collect();
229        let removed_fields: Vec<SnapshotField> = old_fields
230            .difference(&new_fields)
231            .map(|f| (*f).clone())
232            .collect();
233
234        let size_delta_bytes = new.total_size() as i64 - old.total_size() as i64;
235
236        Some(SnapshotDelta {
237            base_snapshot_id: old_id,
238            new_snapshot_id: new_id,
239            added_fields,
240            removed_fields,
241            size_delta_bytes,
242        })
243    }
244
245    /// Return a reference to the snapshot with the highest `snapshot_id`.
246    pub fn latest(&self) -> Option<&StateSnapshot> {
247        self.snapshots.values().max_by_key(|s| s.snapshot_id)
248    }
249
250    /// Aggregate statistics over all retained snapshots.
251    pub fn stats(&self) -> StateSnapshotStats {
252        let total_snapshots = self.snapshots.len();
253        let total_size_bytes = self.snapshots.values().map(|s| s.total_size()).sum();
254        let oldest_snapshot_id = self.snapshots.keys().copied().min();
255        let newest_snapshot_id = self.snapshots.keys().copied().max();
256        StateSnapshotStats {
257            total_snapshots,
258            total_size_bytes,
259            oldest_snapshot_id,
260            newest_snapshot_id,
261        }
262    }
263}
264
265impl Default for TensorStateSnapshot {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Tests
273// ---------------------------------------------------------------------------
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // ------------------------------------------------------------------
280    // Helpers
281    // ------------------------------------------------------------------
282
283    fn rules_field(size: u64) -> FieldData {
284        FieldData::new(SnapshotField::Rules, size, 10)
285    }
286
287    fn facts_field(size: u64) -> FieldData {
288        FieldData::new(SnapshotField::Facts, size, 20)
289    }
290
291    fn tensor_field(size: u64) -> FieldData {
292        FieldData::new(SnapshotField::TensorValues, size, 5)
293    }
294
295    fn metadata_field(size: u64) -> FieldData {
296        FieldData::new(SnapshotField::Metadata, size, 3)
297    }
298
299    fn sessions_field(size: u64) -> FieldData {
300        FieldData::new(SnapshotField::Sessions, size, 2)
301    }
302
303    // ------------------------------------------------------------------
304    // TensorStateSnapshot::new
305    // ------------------------------------------------------------------
306
307    #[test]
308    fn test_new_starts_empty() {
309        let mgr = TensorStateSnapshot::new();
310        assert!(mgr.snapshots.is_empty());
311        assert_eq!(mgr.next_id, 1);
312    }
313
314    // ------------------------------------------------------------------
315    // capture / get
316    // ------------------------------------------------------------------
317
318    #[test]
319    fn test_capture_stores_snapshot() {
320        let mut mgr = TensorStateSnapshot::new();
321        let id = mgr.capture("node-a", vec![rules_field(512)], 1_000);
322        let snap = mgr.get(id).expect("snapshot must exist after capture");
323        assert_eq!(snap.snapshot_id, id);
324        assert_eq!(snap.node_id, "node-a");
325        assert_eq!(snap.created_at_secs, 1_000);
326    }
327
328    #[test]
329    fn test_capture_returns_incrementing_ids() {
330        let mut mgr = TensorStateSnapshot::new();
331        let id1 = mgr.capture("node-a", vec![rules_field(100)], 1);
332        let id2 = mgr.capture("node-a", vec![rules_field(200)], 2);
333        let id3 = mgr.capture("node-a", vec![rules_field(300)], 3);
334        assert!(id1 < id2);
335        assert!(id2 < id3);
336    }
337
338    #[test]
339    fn test_get_some() {
340        let mut mgr = TensorStateSnapshot::new();
341        let id = mgr.capture("node-b", vec![facts_field(256)], 42);
342        assert!(mgr.get(id).is_some());
343    }
344
345    #[test]
346    fn test_get_none_for_unknown_id() {
347        let mgr = TensorStateSnapshot::new();
348        assert!(mgr.get(999).is_none());
349    }
350
351    // ------------------------------------------------------------------
352    // delete
353    // ------------------------------------------------------------------
354
355    #[test]
356    fn test_delete_returns_true_when_exists() {
357        let mut mgr = TensorStateSnapshot::new();
358        let id = mgr.capture("node-c", vec![rules_field(64)], 10);
359        assert!(mgr.delete(id));
360    }
361
362    #[test]
363    fn test_delete_returns_false_when_missing() {
364        let mut mgr = TensorStateSnapshot::new();
365        assert!(!mgr.delete(777));
366    }
367
368    #[test]
369    fn test_delete_removes_snapshot() {
370        let mut mgr = TensorStateSnapshot::new();
371        let id = mgr.capture("node-d", vec![rules_field(128)], 20);
372        mgr.delete(id);
373        assert!(mgr.get(id).is_none());
374    }
375
376    // ------------------------------------------------------------------
377    // has_field
378    // ------------------------------------------------------------------
379
380    #[test]
381    fn test_has_field_true() {
382        let mut mgr = TensorStateSnapshot::new();
383        let id = mgr.capture("node-e", vec![facts_field(100)], 5);
384        let snap = mgr.get(id).expect("test: should succeed");
385        assert!(snap.has_field(&SnapshotField::Facts));
386    }
387
388    #[test]
389    fn test_has_field_false() {
390        let mut mgr = TensorStateSnapshot::new();
391        let id = mgr.capture("node-e", vec![facts_field(100)], 5);
392        let snap = mgr.get(id).expect("test: should succeed");
393        assert!(!snap.has_field(&SnapshotField::Rules));
394    }
395
396    // ------------------------------------------------------------------
397    // total_size / field_count
398    // ------------------------------------------------------------------
399
400    #[test]
401    fn test_total_size_sum_of_fields() {
402        let mut mgr = TensorStateSnapshot::new();
403        let fields = vec![rules_field(100), facts_field(200), tensor_field(50)];
404        let id = mgr.capture("node-f", fields, 0);
405        let snap = mgr.get(id).expect("test: should succeed");
406        assert_eq!(snap.total_size(), 350);
407    }
408
409    #[test]
410    fn test_field_count_correct() {
411        let mut mgr = TensorStateSnapshot::new();
412        let fields = vec![rules_field(10), facts_field(20), metadata_field(5)];
413        let id = mgr.capture("node-g", fields, 0);
414        let snap = mgr.get(id).expect("test: should succeed");
415        assert_eq!(snap.field_count(), 3);
416    }
417
418    // ------------------------------------------------------------------
419    // diff
420    // ------------------------------------------------------------------
421
422    #[test]
423    fn test_diff_added_fields_detected() {
424        let mut mgr = TensorStateSnapshot::new();
425        let id1 = mgr.capture("n", vec![rules_field(100)], 1);
426        let id2 = mgr.capture("n", vec![rules_field(100), facts_field(50)], 2);
427        let delta = mgr.diff(id1, id2).expect("diff must return Some");
428        assert!(delta.added_fields.contains(&SnapshotField::Facts));
429        assert!(delta.removed_fields.is_empty());
430    }
431
432    #[test]
433    fn test_diff_removed_fields_detected() {
434        let mut mgr = TensorStateSnapshot::new();
435        let id1 = mgr.capture("n", vec![rules_field(100), facts_field(50)], 1);
436        let id2 = mgr.capture("n", vec![rules_field(100)], 2);
437        let delta = mgr.diff(id1, id2).expect("diff must return Some");
438        assert!(delta.removed_fields.contains(&SnapshotField::Facts));
439        assert!(delta.added_fields.is_empty());
440    }
441
442    #[test]
443    fn test_diff_size_delta_positive() {
444        let mut mgr = TensorStateSnapshot::new();
445        let id1 = mgr.capture("n", vec![rules_field(100)], 1);
446        let id2 = mgr.capture("n", vec![rules_field(100), facts_field(200)], 2);
447        let delta = mgr.diff(id1, id2).expect("test: should succeed");
448        assert_eq!(delta.size_delta_bytes, 200);
449    }
450
451    #[test]
452    fn test_diff_size_delta_negative() {
453        let mut mgr = TensorStateSnapshot::new();
454        let id1 = mgr.capture("n", vec![rules_field(300)], 1);
455        let id2 = mgr.capture("n", vec![rules_field(100)], 2);
456        let delta = mgr.diff(id1, id2).expect("test: should succeed");
457        assert_eq!(delta.size_delta_bytes, -200);
458    }
459
460    #[test]
461    fn test_diff_returns_none_for_unknown_old_id() {
462        let mut mgr = TensorStateSnapshot::new();
463        let id = mgr.capture("n", vec![rules_field(10)], 1);
464        assert!(mgr.diff(999, id).is_none());
465    }
466
467    #[test]
468    fn test_diff_returns_none_for_unknown_new_id() {
469        let mut mgr = TensorStateSnapshot::new();
470        let id = mgr.capture("n", vec![rules_field(10)], 1);
471        assert!(mgr.diff(id, 999).is_none());
472    }
473
474    #[test]
475    fn test_diff_is_empty_when_no_changes() {
476        let mut mgr = TensorStateSnapshot::new();
477        let id1 = mgr.capture("n", vec![rules_field(100)], 1);
478        let id2 = mgr.capture("n", vec![rules_field(100)], 2);
479        let delta = mgr.diff(id1, id2).expect("test: should succeed");
480        assert!(delta.is_empty());
481    }
482
483    #[test]
484    fn test_diff_is_empty_false_when_changes() {
485        let mut mgr = TensorStateSnapshot::new();
486        let id1 = mgr.capture("n", vec![rules_field(100)], 1);
487        let id2 = mgr.capture("n", vec![facts_field(100)], 2);
488        let delta = mgr.diff(id1, id2).expect("test: should succeed");
489        assert!(!delta.is_empty());
490    }
491
492    // ------------------------------------------------------------------
493    // latest
494    // ------------------------------------------------------------------
495
496    #[test]
497    fn test_latest_returns_highest_id() {
498        let mut mgr = TensorStateSnapshot::new();
499        mgr.capture("n", vec![rules_field(10)], 1);
500        mgr.capture("n", vec![rules_field(20)], 2);
501        let id3 = mgr.capture("n", vec![rules_field(30)], 3);
502        let latest = mgr.latest().expect("latest must be Some");
503        assert_eq!(latest.snapshot_id, id3);
504    }
505
506    #[test]
507    fn test_latest_none_when_empty() {
508        let mgr = TensorStateSnapshot::new();
509        assert!(mgr.latest().is_none());
510    }
511
512    // ------------------------------------------------------------------
513    // stats
514    // ------------------------------------------------------------------
515
516    #[test]
517    fn test_stats_total_snapshots() {
518        let mut mgr = TensorStateSnapshot::new();
519        mgr.capture("n", vec![rules_field(10)], 1);
520        mgr.capture("n", vec![rules_field(10)], 2);
521        assert_eq!(mgr.stats().total_snapshots, 2);
522    }
523
524    #[test]
525    fn test_stats_total_size_bytes() {
526        let mut mgr = TensorStateSnapshot::new();
527        mgr.capture("n", vec![rules_field(100)], 1);
528        mgr.capture("n", vec![facts_field(200)], 2);
529        assert_eq!(mgr.stats().total_size_bytes, 300);
530    }
531
532    #[test]
533    fn test_stats_oldest_newest_snapshot_id() {
534        let mut mgr = TensorStateSnapshot::new();
535        let id1 = mgr.capture("n", vec![rules_field(10)], 1);
536        mgr.capture("n", vec![rules_field(10)], 2);
537        let id3 = mgr.capture("n", vec![rules_field(10)], 3);
538        let stats = mgr.stats();
539        assert_eq!(stats.oldest_snapshot_id, Some(id1));
540        assert_eq!(stats.newest_snapshot_id, Some(id3));
541    }
542
543    // ------------------------------------------------------------------
544    // fnv1a_u64
545    // ------------------------------------------------------------------
546
547    #[test]
548    fn test_fnv1a_u64_deterministic() {
549        let a = fnv1a_u64(b"Rules");
550        let b = fnv1a_u64(b"Rules");
551        assert_eq!(a, b);
552    }
553
554    #[test]
555    fn test_fnv1a_u64_different_inputs_differ() {
556        let a = fnv1a_u64(b"Rules");
557        let b = fnv1a_u64(b"Facts");
558        assert_ne!(a, b);
559    }
560
561    // ------------------------------------------------------------------
562    // FieldData checksum
563    // ------------------------------------------------------------------
564
565    #[test]
566    fn test_field_data_checksum_matches_fnv1a_of_name() {
567        let fd = FieldData::new(SnapshotField::Rules, 512, 10);
568        assert_eq!(fd.checksum, fnv1a_u64(b"Rules"));
569    }
570
571    #[test]
572    fn test_all_fields_have_distinct_checksums() {
573        let fields = [
574            SnapshotField::Rules,
575            SnapshotField::Facts,
576            SnapshotField::TensorValues,
577            SnapshotField::Metadata,
578            SnapshotField::Sessions,
579        ];
580        let checksums: Vec<u64> = fields
581            .iter()
582            .map(|f| FieldData::new(f.clone(), 0, 0).checksum)
583            .collect();
584        let unique: std::collections::HashSet<u64> = checksums.iter().copied().collect();
585        assert_eq!(
586            unique.len(),
587            fields.len(),
588            "all field checksums must be distinct"
589        );
590    }
591
592    // ------------------------------------------------------------------
593    // format_version default
594    // ------------------------------------------------------------------
595
596    #[test]
597    fn test_snapshot_format_version_default_one() {
598        let mut mgr = TensorStateSnapshot::new();
599        let id = mgr.capture("n", vec![sessions_field(64)], 0);
600        assert_eq!(mgr.get(id).expect("test: should succeed").format_version, 1);
601    }
602}