datasynth_eval/coherence/
multi_period.rs1use serde::{Deserialize, Serialize};
2
3#[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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MultiPeriodThresholds {
18 pub min_balance_continuity: f64,
21 pub max_volume_variance_cv: f64,
24 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#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct MultiPeriodAnalysis {
42 pub balance_continuity_rate: f64,
44 pub volume_variance_cv: f64,
46 pub periods_with_activity_rate: f64,
48 pub total_periods: usize,
50 pub passes: bool,
52 pub issues: Vec<String>,
54}
55
56pub 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 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 };
108
109 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 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 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 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 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 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 periods[2].opening_balance = 9999.0;
266 periods[0].transaction_count = 0;
268 periods[3].transaction_count = 0;
269 let result = analyzer.analyze(&periods);
270 assert!(result.passes, "issues: {:?}", result.issues);
272 }
273}