Skip to main content

ipfrs_tensorlogic/
tensor_diff.rs

1//! TensorDiffEngine — structural and numeric diff between tensor snapshots.
2//!
3//! Used for change-detection in federated learning checkpoints: given two sets of
4//! named tensors (old and new), compute per-tensor diffs covering additions,
5//! removals, shape changes, and numeric value changes.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// DiffKind
11// ---------------------------------------------------------------------------
12
13/// Describes what changed (or didn't) for a single named tensor.
14#[derive(Debug, Clone, PartialEq)]
15pub enum DiffKind {
16    /// Key present in new snapshot, absent in old.
17    Added,
18    /// Key present in old snapshot, absent in new.
19    Removed,
20    /// Shapes differ between old and new.
21    ShapeChanged {
22        old_shape: Vec<usize>,
23        new_shape: Vec<usize>,
24    },
25    /// Shapes match but numeric values differ beyond threshold.
26    ValueChanged {
27        max_abs_diff: f32,
28        mean_abs_diff: f32,
29        changed_elements: usize,
30    },
31    /// Shapes match and all values are within threshold.
32    Unchanged,
33}
34
35// ---------------------------------------------------------------------------
36// TensorSnapshot
37// ---------------------------------------------------------------------------
38
39/// A named, flat snapshot of a single tensor.
40#[derive(Debug, Clone)]
41pub struct TensorSnapshot {
42    /// Tensor name / checkpoint key.
43    pub name: String,
44    /// Shape dimensions, e.g. `[128, 256]` for a 2-D tensor.
45    pub shape: Vec<usize>,
46    /// Flattened row-major element data.
47    pub data: Vec<f32>,
48}
49
50impl TensorSnapshot {
51    /// Create a new snapshot.
52    pub fn new(name: impl Into<String>, shape: Vec<usize>, data: Vec<f32>) -> Self {
53        Self {
54            name: name.into(),
55            shape,
56            data,
57        }
58    }
59
60    /// Number of elements: product of shape dimensions, or `data.len()` when
61    /// `shape` is empty (scalar / rank-0 tensors).
62    pub fn numel(&self) -> usize {
63        if self.shape.is_empty() {
64            self.data.len()
65        } else {
66            self.shape.iter().product()
67        }
68    }
69
70    /// Returns `true` when `self` and `other` have identical shapes and can
71    /// therefore be compared element-wise.
72    pub fn is_compatible(&self, other: &TensorSnapshot) -> bool {
73        self.shape == other.shape
74    }
75}
76
77// ---------------------------------------------------------------------------
78// TensorDiff
79// ---------------------------------------------------------------------------
80
81/// Diff result for one named tensor.
82#[derive(Debug, Clone)]
83pub struct TensorDiff {
84    /// Tensor name.
85    pub name: String,
86    /// Nature of the change.
87    pub kind: DiffKind,
88}
89
90impl TensorDiff {
91    /// Returns `true` when this diff is considered "significant" relative to
92    /// `threshold`.
93    ///
94    /// - `ValueChanged` → significant when `max_abs_diff > threshold`
95    /// - `ShapeChanged`, `Added`, `Removed` → always significant
96    /// - `Unchanged` → never significant
97    pub fn is_significant(&self, threshold: f32) -> bool {
98        match &self.kind {
99            DiffKind::ValueChanged { max_abs_diff, .. } => *max_abs_diff > threshold,
100            DiffKind::ShapeChanged { .. } | DiffKind::Added | DiffKind::Removed => true,
101            DiffKind::Unchanged => false,
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// DiffSummary
108// ---------------------------------------------------------------------------
109
110/// Aggregated statistics over a slice of [`TensorDiff`] values.
111#[derive(Debug, Clone, Default)]
112pub struct DiffSummary {
113    pub added: usize,
114    pub removed: usize,
115    pub shape_changed: usize,
116    pub value_changed: usize,
117    pub unchanged: usize,
118    pub total_changed_elements: usize,
119}
120
121impl DiffSummary {
122    /// Returns `true` when at least one tensor was added, removed, or changed.
123    pub fn has_changes(&self) -> bool {
124        self.added > 0 || self.removed > 0 || self.shape_changed > 0 || self.value_changed > 0
125    }
126}
127
128// ---------------------------------------------------------------------------
129// TensorDiffEngine
130// ---------------------------------------------------------------------------
131
132/// Engine that computes structural and numeric diffs between tensor snapshots.
133pub struct TensorDiffEngine {
134    /// Element-wise absolute difference threshold below which a value is
135    /// considered unchanged.
136    pub threshold: f32,
137}
138
139impl TensorDiffEngine {
140    /// Create a new engine with the given numeric threshold.
141    pub fn new(threshold: f32) -> Self {
142        Self { threshold }
143    }
144
145    /// Diff two individual tensors that share the same name.
146    ///
147    /// Shape mismatch → `ShapeChanged`.
148    /// Element-wise: count elements where `|new[i] - old[i]| > threshold`,
149    /// accumulate max and sum.  If count == 0 → `Unchanged`, else `ValueChanged`.
150    pub fn diff_tensors(&self, old: &TensorSnapshot, new: &TensorSnapshot) -> TensorDiff {
151        if old.shape != new.shape {
152            return TensorDiff {
153                name: new.name.clone(),
154                kind: DiffKind::ShapeChanged {
155                    old_shape: old.shape.clone(),
156                    new_shape: new.shape.clone(),
157                },
158            };
159        }
160
161        let len = old.data.len().max(new.data.len());
162        let mut max_abs: f32 = 0.0;
163        let mut sum_abs: f32 = 0.0;
164        let mut changed: usize = 0;
165
166        for i in 0..len {
167            let o = old.data.get(i).copied().unwrap_or(0.0);
168            let n = new.data.get(i).copied().unwrap_or(0.0);
169            let d = (n - o).abs();
170            if d > max_abs {
171                max_abs = d;
172            }
173            sum_abs += d;
174            if d > self.threshold {
175                changed += 1;
176            }
177        }
178
179        let kind = if changed == 0 {
180            DiffKind::Unchanged
181        } else {
182            let mean_abs = if len > 0 { sum_abs / len as f32 } else { 0.0 };
183            DiffKind::ValueChanged {
184                max_abs_diff: max_abs,
185                mean_abs_diff: mean_abs,
186                changed_elements: changed,
187            }
188        };
189
190        TensorDiff {
191            name: new.name.clone(),
192            kind,
193        }
194    }
195
196    /// Diff two full checkpoint snapshots (slices of tensors).
197    ///
198    /// Results are sorted by name for deterministic output.
199    pub fn diff_snapshots(
200        &self,
201        old_set: &[TensorSnapshot],
202        new_set: &[TensorSnapshot],
203    ) -> Vec<TensorDiff> {
204        let old_map: HashMap<&str, &TensorSnapshot> =
205            old_set.iter().map(|t| (t.name.as_str(), t)).collect();
206        let new_map: HashMap<&str, &TensorSnapshot> =
207            new_set.iter().map(|t| (t.name.as_str(), t)).collect();
208
209        let mut diffs: Vec<TensorDiff> = Vec::new();
210
211        // Process tensors present in old
212        for (name, old_tensor) in &old_map {
213            match new_map.get(name) {
214                None => diffs.push(TensorDiff {
215                    name: (*name).to_string(),
216                    kind: DiffKind::Removed,
217                }),
218                Some(new_tensor) => {
219                    diffs.push(self.diff_tensors(old_tensor, new_tensor));
220                }
221            }
222        }
223
224        // Tensors only in new
225        for name in new_map.keys() {
226            if !old_map.contains_key(name) {
227                diffs.push(TensorDiff {
228                    name: (*name).to_string(),
229                    kind: DiffKind::Added,
230                });
231            }
232        }
233
234        diffs.sort_by(|a, b| a.name.cmp(&b.name));
235        diffs
236    }
237
238    /// Aggregate a slice of diffs into a [`DiffSummary`].
239    pub fn summarize(&self, diffs: &[TensorDiff]) -> DiffSummary {
240        let mut summary = DiffSummary::default();
241        for diff in diffs {
242            match &diff.kind {
243                DiffKind::Added => summary.added += 1,
244                DiffKind::Removed => summary.removed += 1,
245                DiffKind::ShapeChanged { .. } => summary.shape_changed += 1,
246                DiffKind::ValueChanged {
247                    changed_elements, ..
248                } => {
249                    summary.value_changed += 1;
250                    summary.total_changed_elements += changed_elements;
251                }
252                DiffKind::Unchanged => summary.unchanged += 1,
253            }
254        }
255        summary
256    }
257
258    /// Filter diffs to only those that are significant at `self.threshold`.
259    pub fn significant_diffs<'a>(&self, diffs: &'a [TensorDiff]) -> Vec<&'a TensorDiff> {
260        diffs
261            .iter()
262            .filter(|d| d.is_significant(self.threshold))
263            .collect()
264    }
265}
266
267// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn make_snap(name: &str, shape: Vec<usize>, data: Vec<f32>) -> TensorSnapshot {
276        TensorSnapshot::new(name, shape, data)
277    }
278
279    // 1. new() stores threshold
280    #[test]
281    fn test_engine_new_threshold() {
282        let engine = TensorDiffEngine::new(1e-4);
283        assert!((engine.threshold - 1e-4_f32).abs() < f32::EPSILON);
284    }
285
286    // 2. numel() for 2-D shape
287    #[test]
288    fn test_numel_2d() {
289        let snap = make_snap("w", vec![128, 256], vec![0.0; 128 * 256]);
290        assert_eq!(snap.numel(), 128 * 256);
291    }
292
293    // 3. numel() empty shape falls back to data.len()
294    #[test]
295    fn test_numel_empty_shape() {
296        let snap = make_snap("scalar", vec![], vec![1.0, 2.0, 3.0]);
297        assert_eq!(snap.numel(), 3);
298    }
299
300    // 4. is_compatible: matching shapes → true
301    #[test]
302    fn test_is_compatible_matching() {
303        let a = make_snap("a", vec![4, 4], vec![0.0; 16]);
304        let b = make_snap("b", vec![4, 4], vec![1.0; 16]);
305        assert!(a.is_compatible(&b));
306    }
307
308    // 5. is_compatible: mismatched → false
309    #[test]
310    fn test_is_compatible_mismatch() {
311        let a = make_snap("a", vec![4, 4], vec![0.0; 16]);
312        let b = make_snap("b", vec![4, 8], vec![0.0; 32]);
313        assert!(!a.is_compatible(&b));
314    }
315
316    // 6. diff_tensors: identical → Unchanged
317    #[test]
318    fn test_diff_tensors_identical_unchanged() {
319        let engine = TensorDiffEngine::new(1e-6);
320        let data = vec![1.0, 2.0, 3.0, 4.0];
321        let old = make_snap("t", vec![4], data.clone());
322        let new = make_snap("t", vec![4], data);
323        let diff = engine.diff_tensors(&old, &new);
324        assert_eq!(diff.kind, DiffKind::Unchanged);
325    }
326
327    // 7. diff_tensors: small delta below threshold → Unchanged
328    #[test]
329    fn test_diff_tensors_small_delta_unchanged() {
330        let threshold = 1e-5_f32;
331        let engine = TensorDiffEngine::new(threshold);
332        let old = make_snap("t", vec![3], vec![1.0, 2.0, 3.0]);
333        let new = make_snap("t", vec![3], vec![1.0 + 1e-7, 2.0, 3.0]);
334        let diff = engine.diff_tensors(&old, &new);
335        assert_eq!(diff.kind, DiffKind::Unchanged);
336    }
337
338    // 8. diff_tensors: large delta above threshold → ValueChanged with correct max/mean
339    #[test]
340    fn test_diff_tensors_value_changed_max_mean() {
341        let engine = TensorDiffEngine::new(1e-6);
342        let old = make_snap("t", vec![2], vec![0.0, 0.0]);
343        let new = make_snap("t", vec![2], vec![1.0, 3.0]);
344        let diff = engine.diff_tensors(&old, &new);
345        match diff.kind {
346            DiffKind::ValueChanged {
347                max_abs_diff,
348                mean_abs_diff,
349                changed_elements,
350            } => {
351                assert!((max_abs_diff - 3.0).abs() < 1e-5, "max={max_abs_diff}");
352                assert!((mean_abs_diff - 2.0).abs() < 1e-5, "mean={mean_abs_diff}");
353                assert_eq!(changed_elements, 2);
354            }
355            other => panic!("Expected ValueChanged, got {other:?}"),
356        }
357    }
358
359    // 9. diff_tensors: shape mismatch → ShapeChanged
360    #[test]
361    fn test_diff_tensors_shape_mismatch() {
362        let engine = TensorDiffEngine::new(1e-6);
363        let old = make_snap("t", vec![2, 2], vec![0.0; 4]);
364        let new = make_snap("t", vec![4], vec![0.0; 4]);
365        let diff = engine.diff_tensors(&old, &new);
366        match diff.kind {
367            DiffKind::ShapeChanged {
368                old_shape,
369                new_shape,
370            } => {
371                assert_eq!(old_shape, vec![2, 2]);
372                assert_eq!(new_shape, vec![4]);
373            }
374            other => panic!("Expected ShapeChanged, got {other:?}"),
375        }
376    }
377
378    // 10. diff_tensors: mean_abs_diff computed correctly for 4 elements
379    #[test]
380    fn test_diff_tensors_mean_abs_diff_four_elements() {
381        let engine = TensorDiffEngine::new(1e-6);
382        // diffs: 1, 2, 3, 4 → mean = 2.5
383        let old = make_snap("t", vec![4], vec![0.0, 0.0, 0.0, 0.0]);
384        let new = make_snap("t", vec![4], vec![1.0, 2.0, 3.0, 4.0]);
385        let diff = engine.diff_tensors(&old, &new);
386        match diff.kind {
387            DiffKind::ValueChanged { mean_abs_diff, .. } => {
388                assert!((mean_abs_diff - 2.5).abs() < 1e-5, "mean={mean_abs_diff}");
389            }
390            other => panic!("Expected ValueChanged, got {other:?}"),
391        }
392    }
393
394    // 11. diff_snapshots: added tensor detected
395    #[test]
396    fn test_diff_snapshots_added() {
397        let engine = TensorDiffEngine::new(1e-6);
398        let old_set: Vec<TensorSnapshot> = vec![];
399        let new_set = vec![make_snap("layer.weight", vec![2], vec![1.0, 2.0])];
400        let diffs = engine.diff_snapshots(&old_set, &new_set);
401        assert_eq!(diffs.len(), 1);
402        assert_eq!(diffs[0].name, "layer.weight");
403        assert_eq!(diffs[0].kind, DiffKind::Added);
404    }
405
406    // 12. diff_snapshots: removed tensor detected
407    #[test]
408    fn test_diff_snapshots_removed() {
409        let engine = TensorDiffEngine::new(1e-6);
410        let old_set = vec![make_snap("layer.weight", vec![2], vec![1.0, 2.0])];
411        let new_set: Vec<TensorSnapshot> = vec![];
412        let diffs = engine.diff_snapshots(&old_set, &new_set);
413        assert_eq!(diffs.len(), 1);
414        assert_eq!(diffs[0].name, "layer.weight");
415        assert_eq!(diffs[0].kind, DiffKind::Removed);
416    }
417
418    // 13. diff_snapshots: unchanged tensor detected
419    #[test]
420    fn test_diff_snapshots_unchanged() {
421        let engine = TensorDiffEngine::new(1e-6);
422        let snap = make_snap("w", vec![3], vec![1.0, 2.0, 3.0]);
423        let old_set = vec![snap.clone()];
424        let new_set = vec![snap];
425        let diffs = engine.diff_snapshots(&old_set, &new_set);
426        assert_eq!(diffs.len(), 1);
427        assert_eq!(diffs[0].kind, DiffKind::Unchanged);
428    }
429
430    // 14. diff_snapshots: changed tensor detected
431    #[test]
432    fn test_diff_snapshots_changed() {
433        let engine = TensorDiffEngine::new(1e-6);
434        let old_set = vec![make_snap("w", vec![2], vec![0.0, 0.0])];
435        let new_set = vec![make_snap("w", vec![2], vec![1.0, 1.0])];
436        let diffs = engine.diff_snapshots(&old_set, &new_set);
437        assert_eq!(diffs.len(), 1);
438        assert!(matches!(diffs[0].kind, DiffKind::ValueChanged { .. }));
439    }
440
441    // 15. diff_snapshots: output sorted by name
442    #[test]
443    fn test_diff_snapshots_sorted_by_name() {
444        let engine = TensorDiffEngine::new(1e-6);
445        let old_set = vec![
446            make_snap("zebra", vec![1], vec![1.0]),
447            make_snap("apple", vec![1], vec![2.0]),
448            make_snap("mango", vec![1], vec![3.0]),
449        ];
450        let new_set = vec![
451            make_snap("mango", vec![1], vec![3.0]),
452            make_snap("zebra", vec![1], vec![1.0]),
453            make_snap("apple", vec![1], vec![2.0]),
454        ];
455        let diffs = engine.diff_snapshots(&old_set, &new_set);
456        let names: Vec<&str> = diffs.iter().map(|d| d.name.as_str()).collect();
457        assert_eq!(names, vec!["apple", "mango", "zebra"]);
458    }
459
460    // 16. summarize: counts correct
461    #[test]
462    fn test_summarize_counts() {
463        let engine = TensorDiffEngine::new(1e-6);
464        let diffs = vec![
465            TensorDiff {
466                name: "a".into(),
467                kind: DiffKind::Added,
468            },
469            TensorDiff {
470                name: "b".into(),
471                kind: DiffKind::Removed,
472            },
473            TensorDiff {
474                name: "c".into(),
475                kind: DiffKind::ShapeChanged {
476                    old_shape: vec![2],
477                    new_shape: vec![4],
478                },
479            },
480            TensorDiff {
481                name: "d".into(),
482                kind: DiffKind::ValueChanged {
483                    max_abs_diff: 0.5,
484                    mean_abs_diff: 0.25,
485                    changed_elements: 7,
486                },
487            },
488            TensorDiff {
489                name: "e".into(),
490                kind: DiffKind::Unchanged,
491            },
492        ];
493        let summary = engine.summarize(&diffs);
494        assert_eq!(summary.added, 1);
495        assert_eq!(summary.removed, 1);
496        assert_eq!(summary.shape_changed, 1);
497        assert_eq!(summary.value_changed, 1);
498        assert_eq!(summary.unchanged, 1);
499        assert_eq!(summary.total_changed_elements, 7);
500        assert!(summary.has_changes());
501    }
502
503    // 17. significant_diffs filters by threshold
504    #[test]
505    fn test_significant_diffs_filters() {
506        let engine = TensorDiffEngine::new(0.1);
507        let diffs = vec![
508            TensorDiff {
509                name: "big".into(),
510                kind: DiffKind::ValueChanged {
511                    max_abs_diff: 0.5,
512                    mean_abs_diff: 0.3,
513                    changed_elements: 3,
514                },
515            },
516            TensorDiff {
517                name: "small".into(),
518                kind: DiffKind::ValueChanged {
519                    max_abs_diff: 0.05,
520                    mean_abs_diff: 0.02,
521                    changed_elements: 1,
522                },
523            },
524            TensorDiff {
525                name: "added".into(),
526                kind: DiffKind::Added,
527            },
528        ];
529        let sig = engine.significant_diffs(&diffs);
530        // "big" (0.5 > 0.1) and "added" are significant; "small" (0.05 <= 0.1) is not
531        let names: Vec<&str> = sig.iter().map(|d| d.name.as_str()).collect();
532        assert!(names.contains(&"big"));
533        assert!(names.contains(&"added"));
534        assert!(!names.contains(&"small"));
535    }
536
537    // 18. is_significant: Unchanged always false
538    #[test]
539    fn test_is_significant_unchanged_false() {
540        let diff = TensorDiff {
541            name: "t".into(),
542            kind: DiffKind::Unchanged,
543        };
544        assert!(!diff.is_significant(0.0));
545        assert!(!diff.is_significant(1e-10));
546        assert!(!diff.is_significant(f32::MAX));
547    }
548
549    // Bonus: has_changes() false when all unchanged
550    #[test]
551    fn test_has_changes_false_when_all_unchanged() {
552        let engine = TensorDiffEngine::new(1e-6);
553        let diffs = vec![TensorDiff {
554            name: "t".into(),
555            kind: DiffKind::Unchanged,
556        }];
557        let summary = engine.summarize(&diffs);
558        assert!(!summary.has_changes());
559    }
560}