Skip to main content

sdivi_snapshot/
delta.rs

1//! [`DivergenceSummary`] and [`compute_delta`] — pure delta computation.
2
3use sdivi_patterns::compute_entropy;
4use serde::{Deserialize, Serialize};
5
6use crate::snapshot::Snapshot;
7
8/// Per-dimension divergence between two consecutive [`Snapshot`]s.
9///
10/// All fields are `Option<_>`.  `None` means the dimension could not be
11/// compared (e.g. one snapshot lacked the prerequisite data).  `Some(0)`
12/// or `Some(0.0)` means the dimension was compared and no change was
13/// observed.  These two states are intentionally distinct (Rule 14 / KDD-9).
14///
15/// Null fields are serialised as explicit JSON `null` — `skip_serializing_if`
16/// is intentionally absent — so that CI consumers can distinguish "not
17/// computed" from "zero change."
18///
19/// # Examples
20///
21/// ```rust
22/// use sdivi_snapshot::delta::null_summary;
23///
24/// let s = null_summary();
25/// assert!(s.pattern_entropy_delta.is_none());
26/// assert!(s.convention_drift_delta.is_none());
27/// assert!(s.pattern_entropy_per_category_delta.is_none());
28/// assert!(s.convention_drift_per_category_delta.is_none());
29/// ```
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct DivergenceSummary {
32    /// Change in total normalized pattern entropy (curr − prev).
33    pub pattern_entropy_delta: Option<f64>,
34
35    /// Change in `convention_drift` (curr − prev).
36    ///
37    /// `None` when either snapshot has no catalog entries.
38    pub convention_drift_delta: Option<f64>,
39
40    /// Change in graph density (curr − prev).
41    pub coupling_delta: Option<f64>,
42
43    /// Change in community count (curr − prev), as a signed integer.
44    pub community_count_delta: Option<i64>,
45
46    /// Change in boundary violation count (curr − prev).
47    ///
48    /// `None` when either snapshot is missing `intent_divergence`.
49    pub boundary_violation_delta: Option<i64>,
50
51    /// Per-category Shannon entropy delta (curr − prev), keyed by category name.
52    ///
53    /// `None` on the first-snapshot path (KDD-9).  Map keys are the union of
54    /// categories in `prev` and `curr`; missing-side values are treated as `0.0`.
55    #[serde(default)]
56    pub pattern_entropy_per_category_delta: Option<std::collections::BTreeMap<String, f64>>,
57
58    /// Per-category convention-drift delta (curr − prev), keyed by category name.
59    ///
60    /// `None` on the first-snapshot path (KDD-9).  Same union-of-keys semantics as
61    /// `pattern_entropy_per_category_delta`.
62    #[serde(default)]
63    pub convention_drift_per_category_delta: Option<std::collections::BTreeMap<String, f64>>,
64}
65
66/// Returns a [`DivergenceSummary`] with all fields `None`.
67///
68/// Used for the first-snapshot case where no previous snapshot exists.
69/// Callers must never substitute `0` for `None` here (Rule 14).
70///
71/// # Examples
72///
73/// ```rust
74/// use sdivi_snapshot::delta::null_summary;
75///
76/// let s = null_summary();
77/// assert_eq!(s.pattern_entropy_delta, None);
78/// assert_eq!(s.convention_drift_delta, None);
79/// assert_eq!(s.coupling_delta, None);
80/// assert_eq!(s.community_count_delta, None);
81/// assert_eq!(s.boundary_violation_delta, None);
82/// ```
83pub fn null_summary() -> DivergenceSummary {
84    DivergenceSummary {
85        pattern_entropy_delta: None,
86        convention_drift_delta: None,
87        coupling_delta: None,
88        community_count_delta: None,
89        boundary_violation_delta: None,
90        pattern_entropy_per_category_delta: None,
91        convention_drift_per_category_delta: None,
92    }
93}
94
95/// Computes the per-dimension divergence between `prev` and `curr`.
96///
97/// This function is **referentially transparent**: same inputs always produce
98/// the same output.  It performs no I/O, reads no globals, and uses no clock.
99///
100/// # Examples
101///
102/// ```rust
103/// use std::collections::BTreeMap;
104/// use sdivi_snapshot::snapshot::{assemble_snapshot, PatternMetricsResult, Snapshot};
105/// use sdivi_snapshot::delta::compute_delta;
106/// use sdivi_graph::metrics::GraphMetrics;
107/// use sdivi_detection::partition::LeidenPartition;
108/// use sdivi_patterns::PatternCatalog;
109///
110/// fn make_snap(density: f64) -> Snapshot {
111///     let graph = GraphMetrics {
112///         node_count: 2, edge_count: 0, density,
113///         cycle_count: 0, top_hubs: vec![], component_count: 1,
114///     };
115///     let partition = LeidenPartition {
116///         assignments: BTreeMap::new(), stability: BTreeMap::new(),
117///         modularity: 0.0, seed: 42,
118///     };
119///     assemble_snapshot(graph, partition, PatternCatalog::default(),
120///         PatternMetricsResult::default(), None, "T", None, None, 0)
121/// }
122///
123/// let delta = compute_delta(&make_snap(0.1), &make_snap(0.3));
124/// assert!((delta.coupling_delta.unwrap() - 0.2).abs() < 1e-10);
125/// ```
126pub fn compute_delta(prev: &Snapshot, curr: &Snapshot) -> DivergenceSummary {
127    let pattern_entropy_delta = Some({
128        let prev_entropy: f64 = prev.catalog.entries.values().map(compute_entropy).sum();
129        let curr_entropy: f64 = curr.catalog.entries.values().map(compute_entropy).sum();
130        curr_entropy - prev_entropy
131    });
132
133    let convention_drift_delta =
134        Some(curr.pattern_metrics.convention_drift - prev.pattern_metrics.convention_drift);
135
136    let coupling_delta = Some(curr.graph.density - prev.graph.density);
137
138    let community_count_delta =
139        Some(curr.partition.community_count() as i64 - prev.partition.community_count() as i64);
140
141    let boundary_violation_delta = match (&prev.intent_divergence, &curr.intent_divergence) {
142        (Some(p), Some(c)) => Some(i64::from(c.violation_count) - i64::from(p.violation_count)),
143        _ => None,
144    };
145
146    let pattern_entropy_per_category_delta = Some(delta_per_category(
147        &prev.pattern_metrics.entropy_per_category,
148        &curr.pattern_metrics.entropy_per_category,
149    ));
150
151    let convention_drift_per_category_delta = Some(delta_per_category(
152        &prev.pattern_metrics.convention_drift_per_category,
153        &curr.pattern_metrics.convention_drift_per_category,
154    ));
155
156    DivergenceSummary {
157        pattern_entropy_delta,
158        convention_drift_delta,
159        coupling_delta,
160        community_count_delta,
161        boundary_violation_delta,
162        pattern_entropy_per_category_delta,
163        convention_drift_per_category_delta,
164    }
165}
166
167/// Computes the per-category delta as `curr − prev` over the union of keys.
168///
169/// Categories present in only one snapshot are treated as `0.0` on the
170/// missing side, so a newly-introduced category surfaces as a positive delta.
171fn delta_per_category(
172    prev: &std::collections::BTreeMap<String, f64>,
173    curr: &std::collections::BTreeMap<String, f64>,
174) -> std::collections::BTreeMap<String, f64> {
175    let mut result = std::collections::BTreeMap::new();
176    for key in prev.keys().chain(curr.keys()) {
177        if !result.contains_key(key) {
178            let p = prev.get(key).copied().unwrap_or(0.0);
179            let c = curr.get(key).copied().unwrap_or(0.0);
180            result.insert(key.clone(), c - p);
181        }
182    }
183    result
184}
185
186#[cfg(test)]
187mod tests {
188    use std::collections::BTreeMap;
189
190    use sdivi_detection::partition::LeidenPartition;
191    use sdivi_graph::metrics::GraphMetrics;
192    use sdivi_patterns::PatternCatalog;
193
194    use super::*;
195    use crate::snapshot::{assemble_snapshot, IntentDivergenceInfo, PatternMetricsResult};
196
197    fn make_snap(density: f64, communities: usize) -> Snapshot {
198        let mut stability = BTreeMap::new();
199        for i in 0..communities {
200            stability.insert(i, 1.0_f64);
201        }
202        let graph = GraphMetrics {
203            node_count: 2,
204            edge_count: 0,
205            density,
206            cycle_count: 0,
207            top_hubs: vec![],
208            component_count: 1,
209        };
210        let partition = LeidenPartition {
211            assignments: BTreeMap::new(),
212            stability,
213            modularity: 0.0,
214            seed: 42,
215        };
216        assemble_snapshot(
217            graph,
218            partition,
219            PatternCatalog::default(),
220            PatternMetricsResult::default(),
221            None,
222            "T",
223            None,
224            None,
225            0,
226        )
227    }
228
229    #[test]
230    fn null_summary_all_none() {
231        let s = null_summary();
232        assert!(s.pattern_entropy_delta.is_none());
233        assert!(s.convention_drift_delta.is_none());
234        assert!(s.coupling_delta.is_none());
235        assert!(s.community_count_delta.is_none());
236        assert!(s.boundary_violation_delta.is_none());
237        assert!(s.pattern_entropy_per_category_delta.is_none());
238        assert!(s.convention_drift_per_category_delta.is_none());
239    }
240
241    #[test]
242    fn coupling_delta_correct() {
243        let d = compute_delta(&make_snap(0.1, 2), &make_snap(0.3, 2));
244        let v = d.coupling_delta.unwrap();
245        assert!((v - 0.2).abs() < 1e-10, "expected ~0.2, got {v}");
246    }
247
248    #[test]
249    fn community_count_delta_correct() {
250        let d = compute_delta(&make_snap(0.0, 3), &make_snap(0.0, 5));
251        assert_eq!(d.community_count_delta, Some(2));
252    }
253
254    #[test]
255    fn convention_drift_delta_zero_for_equal_snapshots() {
256        let snap = make_snap(0.0, 1);
257        let d = compute_delta(&snap, &snap);
258        assert_eq!(d.convention_drift_delta, Some(0.0));
259    }
260
261    #[test]
262    fn boundary_violation_delta_none_when_both_missing() {
263        let d = compute_delta(&make_snap(0.0, 1), &make_snap(0.0, 1));
264        assert!(d.boundary_violation_delta.is_none());
265    }
266
267    #[test]
268    fn null_summary_serde_produces_explicit_nulls() {
269        let s = null_summary();
270        let json = serde_json::to_string(&s).unwrap();
271        assert!(json.contains("\"pattern_entropy_delta\":null"));
272        assert!(json.contains("\"convention_drift_delta\":null"));
273        assert!(json.contains("\"coupling_delta\":null"));
274        assert!(json.contains("\"community_count_delta\":null"));
275        assert!(json.contains("\"boundary_violation_delta\":null"));
276        assert!(json.contains("\"pattern_entropy_per_category_delta\":null"));
277        assert!(json.contains("\"convention_drift_per_category_delta\":null"));
278    }
279
280    #[test]
281    fn boundary_violation_delta_computed_when_both_present() {
282        let mut prev = make_snap(0.0, 1);
283        prev.intent_divergence = Some(IntentDivergenceInfo {
284            boundary_count: 2,
285            violation_count: 1,
286        });
287        let mut curr = make_snap(0.0, 1);
288        curr.intent_divergence = Some(IntentDivergenceInfo {
289            boundary_count: 2,
290            violation_count: 3,
291        });
292        let d = compute_delta(&prev, &curr);
293        assert_eq!(d.boundary_violation_delta, Some(2));
294    }
295}