Skip to main content

sdivi_snapshot/
trend.rs

1//! [`compute_trend`] — trend analysis over a sequence of snapshots.
2
3use serde::{Deserialize, Serialize};
4
5use crate::snapshot::Snapshot;
6
7/// Per-dimension trend data over a window of consecutive snapshots.
8///
9/// All delta fields are the slope (change per snapshot interval) of a linear
10/// regression over the window, or `None` when there are fewer than two snapshots
11/// to compare.
12///
13/// # Examples
14///
15/// ```rust
16/// use sdivi_snapshot::trend::compute_trend;
17///
18/// let result = compute_trend(&[], None);
19/// assert_eq!(result.snapshot_count, 0);
20/// assert!(result.pattern_entropy_slope.is_none());
21/// ```
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct TrendResult {
24    /// Number of snapshots in the analysis window.
25    pub snapshot_count: usize,
26    /// Slope of `pattern_entropy_delta` across the window (nats / snapshot).
27    pub pattern_entropy_slope: Option<f64>,
28    /// Slope of `convention_drift_delta` across the window.
29    pub convention_drift_slope: Option<f64>,
30    /// Slope of `coupling_delta` across the window.
31    pub coupling_slope: Option<f64>,
32    /// Slope of `community_count_delta` across the window.
33    pub community_count_slope: Option<f64>,
34}
35
36/// Computes trend statistics over a slice of [`Snapshot`]s.
37///
38/// `last_n = None` uses all snapshots; `Some(n)` clamps to the `n` most recent
39/// (an `n` larger than the slice length silently uses all).  Snapshots are
40/// assumed to be ordered oldest→newest.
41///
42/// The slope is the mean of per-interval deltas (a simple first-difference
43/// average rather than least-squares, which is sufficient for the CLI trend
44/// command).
45///
46/// Returns a [`TrendResult`] with all slope fields `None` when the window has
47/// fewer than two snapshots.
48///
49/// # Examples
50///
51/// ```rust
52/// use sdivi_snapshot::trend::compute_trend;
53///
54/// // Empty slice → zero snapshots, all slopes None.
55/// let r = compute_trend(&[], None);
56/// assert_eq!(r.snapshot_count, 0);
57/// assert!(r.coupling_slope.is_none());
58///
59/// // last_n larger than slice → silently uses all.
60/// let r2 = compute_trend(&[], Some(100));
61/// assert_eq!(r2.snapshot_count, 0);
62/// ```
63pub 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        // last_n = 100 > snaps.len() → use all 2
201        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}