1use serde::{Deserialize, Serialize};
4
5use crate::snapshot::Snapshot;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct TrendResult {
24 pub snapshot_count: usize,
26 pub pattern_entropy_slope: Option<f64>,
28 pub convention_drift_slope: Option<f64>,
30 pub coupling_slope: Option<f64>,
32 pub community_count_slope: Option<f64>,
34}
35
36pub fn compute_trend(snapshots: &[Snapshot], last_n: Option<usize>) -> TrendResult {
64 let window = match last_n {
65 None => snapshots,
66 Some(n) => {
67 let start = snapshots.len().saturating_sub(n);
68 &snapshots[start..]
69 }
70 };
71
72 let count = window.len();
73
74 if count < 2 {
75 return TrendResult {
76 snapshot_count: count,
77 pattern_entropy_slope: None,
78 convention_drift_slope: None,
79 coupling_slope: None,
80 community_count_slope: None,
81 };
82 }
83
84 use sdivi_patterns::compute_entropy;
85
86 let n_intervals = (count - 1) as f64;
87
88 let entropy_vals: Vec<f64> = window
89 .iter()
90 .map(|s| s.catalog.entries.values().map(compute_entropy).sum())
91 .collect();
92
93 let convention_vals: Vec<f64> = window
94 .iter()
95 .map(|s| s.pattern_metrics.convention_drift)
96 .collect();
97
98 let density_vals: Vec<f64> = window.iter().map(|s| s.graph.density).collect();
99
100 let community_vals: Vec<i64> = window
101 .iter()
102 .map(|s| s.partition.community_count() as i64)
103 .collect();
104
105 let entropy_slope = mean_slope(&entropy_vals);
106 let drift_slope = mean_slope(&convention_vals);
107 let coupling_slope = mean_slope(&density_vals);
108 let community_slope: f64 = community_vals
109 .windows(2)
110 .map(|w| (w[1] - w[0]) as f64)
111 .sum::<f64>()
112 / n_intervals;
113
114 TrendResult {
115 snapshot_count: count,
116 pattern_entropy_slope: Some(entropy_slope),
117 convention_drift_slope: Some(drift_slope),
118 coupling_slope: Some(coupling_slope),
119 community_count_slope: Some(community_slope),
120 }
121}
122
123fn mean_slope(vals: &[f64]) -> f64 {
124 if vals.len() < 2 {
125 return 0.0;
126 }
127 let n = (vals.len() - 1) as f64;
128 vals.windows(2).map(|w| w[1] - w[0]).sum::<f64>() / n
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::snapshot::{assemble_snapshot, PatternMetricsResult};
135 use sdivi_detection::partition::LeidenPartition;
136 use sdivi_graph::metrics::GraphMetrics;
137 use sdivi_patterns::PatternCatalog;
138 use std::collections::BTreeMap;
139
140 fn make_snap(density: f64, communities: usize) -> Snapshot {
141 let mut stability = BTreeMap::new();
142 for i in 0..communities {
143 stability.insert(i, 1.0_f64);
144 }
145 let graph = GraphMetrics {
146 node_count: 2,
147 edge_count: 0,
148 density,
149 cycle_count: 0,
150 top_hubs: vec![],
151 component_count: 1,
152 };
153 let partition = LeidenPartition {
154 assignments: BTreeMap::new(),
155 stability,
156 modularity: 0.0,
157 seed: 42,
158 };
159 assemble_snapshot(
160 graph,
161 partition,
162 PatternCatalog::default(),
163 PatternMetricsResult::default(),
164 None,
165 "T",
166 None,
167 None,
168 0,
169 )
170 }
171
172 #[test]
173 fn empty_slice_returns_zero_count() {
174 let r = compute_trend(&[], None);
175 assert_eq!(r.snapshot_count, 0);
176 assert!(r.coupling_slope.is_none());
177 }
178
179 #[test]
180 fn single_snapshot_no_slopes() {
181 let snaps = vec![make_snap(0.5, 3)];
182 let r = compute_trend(&snaps, None);
183 assert_eq!(r.snapshot_count, 1);
184 assert!(r.coupling_slope.is_none());
185 }
186
187 #[test]
188 fn two_snapshots_correct_slope() {
189 let snaps = vec![make_snap(0.1, 2), make_snap(0.3, 4)];
190 let r = compute_trend(&snaps, None);
191 assert_eq!(r.snapshot_count, 2);
192 let slope = r.coupling_slope.unwrap();
193 assert!((slope - 0.2).abs() < 1e-10);
194 assert_eq!(r.community_count_slope, Some(2.0));
195 }
196
197 #[test]
198 fn last_n_clamped_to_available() {
199 let snaps = vec![make_snap(0.1, 2), make_snap(0.2, 3)];
200 let r = compute_trend(&snaps, Some(100));
202 assert_eq!(r.snapshot_count, 2);
203 }
204
205 #[test]
206 fn last_n_selects_tail() {
207 let snaps = vec![
208 make_snap(0.0, 1),
209 make_snap(0.0, 1),
210 make_snap(0.1, 2),
211 make_snap(0.3, 4),
212 ];
213 let r = compute_trend(&snaps, Some(2));
214 assert_eq!(r.snapshot_count, 2);
215 let slope = r.coupling_slope.unwrap();
216 assert!((slope - 0.2).abs() < 1e-10);
217 }
218}