1use sdivi_patterns::compute_entropy;
4use serde::{Deserialize, Serialize};
5
6use crate::snapshot::Snapshot;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct DivergenceSummary {
32 pub pattern_entropy_delta: Option<f64>,
34
35 pub convention_drift_delta: Option<f64>,
39
40 pub coupling_delta: Option<f64>,
42
43 pub community_count_delta: Option<i64>,
45
46 pub boundary_violation_delta: Option<i64>,
50
51 #[serde(default)]
56 pub pattern_entropy_per_category_delta: Option<std::collections::BTreeMap<String, f64>>,
57
58 #[serde(default)]
63 pub convention_drift_per_category_delta: Option<std::collections::BTreeMap<String, f64>>,
64}
65
66pub 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
95pub 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
167fn 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}