Skip to main content

datasynth_eval/coherence/
multi_period.rs

1use serde::{Deserialize, Serialize};
2
3/// Data for a single fiscal period.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct PeriodData {
6    pub period_index: usize,
7    pub opening_balance: f64,
8    pub closing_balance: f64,
9    pub total_debits: f64,
10    pub total_credits: f64,
11    pub transaction_count: usize,
12    pub anomaly_count: usize,
13}
14
15/// Configurable thresholds for multi-period coherence checks.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MultiPeriodThresholds {
18    /// Minimum acceptable balance continuity rate (opening[i] == closing[i-1]).
19    /// Default: 1.0 (exact match).
20    pub min_balance_continuity: f64,
21    /// Maximum coefficient of variation for transaction volumes across periods.
22    /// Default: 0.50.
23    pub max_volume_variance_cv: f64,
24    /// Minimum fraction of periods that must have activity (transaction_count > 0).
25    /// Default: 0.90.
26    pub min_periods_with_activity: f64,
27}
28
29impl Default for MultiPeriodThresholds {
30    fn default() -> Self {
31        Self {
32            min_balance_continuity: 1.0,
33            max_volume_variance_cv: 0.50,
34            min_periods_with_activity: 0.90,
35        }
36    }
37}
38
39/// Result of multi-period coherence analysis.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct MultiPeriodAnalysis {
42    /// Fraction of consecutive period pairs where opening_balance[i] == closing_balance[i-1].
43    pub balance_continuity_rate: f64,
44    /// Coefficient of variation of transaction counts across periods.
45    pub volume_variance_cv: f64,
46    /// Fraction of periods with at least one transaction.
47    pub periods_with_activity_rate: f64,
48    /// Total number of periods analyzed.
49    pub total_periods: usize,
50    /// Whether all thresholds were met.
51    pub passes: bool,
52    /// Human-readable issues found.
53    pub issues: Vec<String>,
54}
55
56/// Analyzes multi-period coherence.
57pub struct MultiPeriodAnalyzer {
58    thresholds: MultiPeriodThresholds,
59}
60
61impl MultiPeriodAnalyzer {
62    pub fn new(thresholds: MultiPeriodThresholds) -> Self {
63        Self { thresholds }
64    }
65
66    pub fn with_defaults() -> Self {
67        Self::new(MultiPeriodThresholds::default())
68    }
69
70    pub fn analyze(&self, periods: &[PeriodData]) -> MultiPeriodAnalysis {
71        let total_periods = periods.len();
72        let mut issues = Vec::new();
73
74        if total_periods == 0 {
75            return MultiPeriodAnalysis {
76                balance_continuity_rate: 0.0,
77                volume_variance_cv: 0.0,
78                periods_with_activity_rate: 0.0,
79                total_periods: 0,
80                passes: false,
81                issues: vec!["No periods provided".into()],
82            };
83        }
84
85        // Balance continuity: check opening[i] == closing[i-1] for consecutive pairs
86        let continuity_pairs = total_periods.saturating_sub(1);
87        let mut continuity_matches = 0usize;
88        for i in 1..total_periods {
89            let prev_closing = periods[i - 1].closing_balance;
90            let curr_opening = periods[i].opening_balance;
91            if (prev_closing - curr_opening).abs() < 1e-6 {
92                continuity_matches += 1;
93            } else {
94                issues.push(format!(
95                    "Period {} opening ({:.2}) != period {} closing ({:.2})",
96                    i,
97                    curr_opening,
98                    i - 1,
99                    prev_closing
100                ));
101            }
102        }
103        let balance_continuity_rate = if continuity_pairs > 0 {
104            continuity_matches as f64 / continuity_pairs as f64
105        } else {
106            1.0 // Single period is trivially continuous
107        };
108
109        // Volume variance: CV of transaction counts
110        let counts: Vec<f64> = periods.iter().map(|p| p.transaction_count as f64).collect();
111        let mean = counts.iter().sum::<f64>() / counts.len() as f64;
112        let volume_variance_cv = if mean > 0.0 {
113            let variance =
114                counts.iter().map(|c| (c - mean).powi(2)).sum::<f64>() / counts.len() as f64;
115            variance.sqrt() / mean
116        } else {
117            0.0
118        };
119
120        // Activity rate: fraction of periods with transactions
121        let active_periods = periods.iter().filter(|p| p.transaction_count > 0).count();
122        let periods_with_activity_rate = active_periods as f64 / total_periods as f64;
123
124        // Check thresholds
125        if balance_continuity_rate < self.thresholds.min_balance_continuity {
126            issues.push(format!(
127                "Balance continuity {:.2} < threshold {:.2}",
128                balance_continuity_rate, self.thresholds.min_balance_continuity
129            ));
130        }
131        if volume_variance_cv > self.thresholds.max_volume_variance_cv {
132            issues.push(format!(
133                "Volume CV {:.3} > threshold {:.3}",
134                volume_variance_cv, self.thresholds.max_volume_variance_cv
135            ));
136        }
137        if periods_with_activity_rate < self.thresholds.min_periods_with_activity {
138            issues.push(format!(
139                "Activity rate {:.2} < threshold {:.2}",
140                periods_with_activity_rate, self.thresholds.min_periods_with_activity
141            ));
142        }
143
144        let passes = balance_continuity_rate >= self.thresholds.min_balance_continuity
145            && volume_variance_cv <= self.thresholds.max_volume_variance_cv
146            && periods_with_activity_rate >= self.thresholds.min_periods_with_activity;
147
148        MultiPeriodAnalysis {
149            balance_continuity_rate,
150            volume_variance_cv,
151            periods_with_activity_rate,
152            total_periods,
153            passes,
154            issues,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn make_periods(count: usize) -> Vec<PeriodData> {
164        let mut periods = Vec::new();
165        let mut balance = 1000.0;
166        for i in 0..count {
167            let debits = 500.0 + (i as f64) * 10.0;
168            let credits = 480.0 + (i as f64) * 10.0;
169            let closing = balance + debits - credits;
170            periods.push(PeriodData {
171                period_index: i,
172                opening_balance: balance,
173                closing_balance: closing,
174                total_debits: debits,
175                total_credits: credits,
176                transaction_count: 100 + i * 5,
177                anomaly_count: 2,
178            });
179            balance = closing;
180        }
181        periods
182    }
183
184    #[test]
185    fn test_multi_period_coherent_data_passes() {
186        let analyzer = MultiPeriodAnalyzer::with_defaults();
187        let periods = make_periods(12);
188        let result = analyzer.analyze(&periods);
189        assert!(result.passes, "issues: {:?}", result.issues);
190        assert_eq!(result.balance_continuity_rate, 1.0);
191        assert_eq!(result.total_periods, 12);
192        assert_eq!(result.periods_with_activity_rate, 1.0);
193    }
194
195    #[test]
196    fn test_balance_discontinuity_detected() {
197        let analyzer = MultiPeriodAnalyzer::with_defaults();
198        let mut periods = make_periods(4);
199        // Break continuity at period 2
200        periods[2].opening_balance = 9999.0;
201        let result = analyzer.analyze(&periods);
202        assert!(!result.passes);
203        assert!(result.balance_continuity_rate < 1.0);
204        assert!(result.issues.iter().any(|i| i.contains("opening")));
205    }
206
207    #[test]
208    fn test_inactive_periods_detected() {
209        let analyzer = MultiPeriodAnalyzer::with_defaults();
210        let mut periods = make_periods(10);
211        // Make 3 periods inactive
212        periods[3].transaction_count = 0;
213        periods[5].transaction_count = 0;
214        periods[7].transaction_count = 0;
215        let result = analyzer.analyze(&periods);
216        assert_eq!(result.periods_with_activity_rate, 0.7);
217        assert!(!result.passes);
218        assert!(result.issues.iter().any(|i| i.contains("Activity rate")));
219    }
220
221    #[test]
222    fn test_high_volume_variance_detected() {
223        let analyzer = MultiPeriodAnalyzer::with_defaults();
224        let mut periods = make_periods(6);
225        // Make highly variable volumes
226        periods[0].transaction_count = 10;
227        periods[1].transaction_count = 1000;
228        periods[2].transaction_count = 5;
229        periods[3].transaction_count = 500;
230        periods[4].transaction_count = 20;
231        periods[5].transaction_count = 800;
232        let result = analyzer.analyze(&periods);
233        assert!(result.volume_variance_cv > 0.5);
234        assert!(!result.passes);
235    }
236
237    #[test]
238    fn test_single_period_trivially_passes() {
239        let analyzer = MultiPeriodAnalyzer::with_defaults();
240        let periods = make_periods(1);
241        let result = analyzer.analyze(&periods);
242        assert!(result.passes);
243        assert_eq!(result.balance_continuity_rate, 1.0);
244        assert_eq!(result.total_periods, 1);
245    }
246
247    #[test]
248    fn test_empty_periods_fails() {
249        let analyzer = MultiPeriodAnalyzer::with_defaults();
250        let result = analyzer.analyze(&[]);
251        assert!(!result.passes);
252        assert_eq!(result.total_periods, 0);
253    }
254
255    #[test]
256    fn test_custom_thresholds() {
257        let thresholds = MultiPeriodThresholds {
258            min_balance_continuity: 0.5,
259            max_volume_variance_cv: 2.0,
260            min_periods_with_activity: 0.5,
261        };
262        let analyzer = MultiPeriodAnalyzer::new(thresholds);
263        let mut periods = make_periods(4);
264        // Break one continuity (out of 3 pairs = 66% continuity)
265        periods[2].opening_balance = 9999.0;
266        // Make 2 of 4 periods inactive (50% activity)
267        periods[0].transaction_count = 0;
268        periods[3].transaction_count = 0;
269        let result = analyzer.analyze(&periods);
270        // With relaxed thresholds, this should pass
271        assert!(result.passes, "issues: {:?}", result.issues);
272    }
273}