datasynth_eval/coherence/
financial_reporting.rs1use crate::error::EvalResult;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FinancialReportingThresholds {
13 pub min_statement_tb_tie_rate: f64,
15 pub min_kpi_accuracy: f64,
17 pub max_budget_variance_std: f64,
19 pub balance_tolerance: f64,
21}
22
23impl Default for FinancialReportingThresholds {
24 fn default() -> Self {
25 Self {
26 min_statement_tb_tie_rate: 0.99,
27 min_kpi_accuracy: 0.95,
28 max_budget_variance_std: 0.50,
29 balance_tolerance: 0.01,
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct FinancialStatementData {
37 pub period: String,
39 pub total_assets: f64,
41 pub total_liabilities: f64,
43 pub total_equity: f64,
45 pub line_item_totals: Vec<(String, f64)>,
47 pub trial_balance_totals: Vec<(String, f64)>,
49 pub cash_flow_operating: f64,
51 pub cash_flow_investing: f64,
53 pub cash_flow_financing: f64,
55 pub cash_beginning: f64,
57 pub cash_ending: f64,
59}
60
61#[derive(Debug, Clone)]
63pub struct KpiData {
64 pub name: String,
66 pub reported_value: f64,
68 pub computed_value: f64,
70}
71
72#[derive(Debug, Clone)]
74pub struct BudgetVarianceData {
75 pub line_item: String,
77 pub budget_amount: f64,
79 pub actual_amount: f64,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PeriodBsResult {
86 pub period: String,
88 pub balanced: bool,
90 pub imbalance: f64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CashFlowResult {
97 pub period: String,
99 pub reconciled: bool,
101 pub discrepancy: f64,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FinancialReportingEvaluation {
108 pub bs_equation_balanced: bool,
110 pub period_bs_results: Vec<PeriodBsResult>,
112 pub statement_tb_tie_rate: f64,
114 pub tie_back_mismatches: usize,
116 pub cash_flow_reconciled: bool,
118 pub period_cf_results: Vec<CashFlowResult>,
120 pub kpi_derivation_accuracy: f64,
122 pub kpi_mismatches: usize,
124 pub budget_variance_std: f64,
126 pub budget_variance_within_bounds: bool,
128 pub passes: bool,
130 pub issues: Vec<String>,
132}
133
134pub struct FinancialReportingEvaluator {
136 thresholds: FinancialReportingThresholds,
137}
138
139impl FinancialReportingEvaluator {
140 pub fn new() -> Self {
142 Self {
143 thresholds: FinancialReportingThresholds::default(),
144 }
145 }
146
147 pub fn with_thresholds(thresholds: FinancialReportingThresholds) -> Self {
149 Self { thresholds }
150 }
151
152 pub fn evaluate(
154 &self,
155 statements: &[FinancialStatementData],
156 kpis: &[KpiData],
157 budget_variances: &[BudgetVarianceData],
158 ) -> EvalResult<FinancialReportingEvaluation> {
159 let mut issues = Vec::new();
160
161 let mut period_bs_results = Vec::new();
163 let mut all_balanced = true;
164 for stmt in statements {
165 let imbalance = stmt.total_assets - (stmt.total_liabilities + stmt.total_equity);
166 let balanced = imbalance.abs() <= self.thresholds.balance_tolerance;
167 if !balanced {
168 all_balanced = false;
169 issues.push(format!(
170 "BS imbalance in {}: {:.2} (A={:.2}, L={:.2}, E={:.2})",
171 stmt.period,
172 imbalance,
173 stmt.total_assets,
174 stmt.total_liabilities,
175 stmt.total_equity
176 ));
177 }
178 period_bs_results.push(PeriodBsResult {
179 period: stmt.period.clone(),
180 balanced,
181 imbalance,
182 });
183 }
184
185 let mut total_line_items = 0usize;
187 let mut matched_line_items = 0usize;
188 for stmt in statements {
189 let tb_map: std::collections::HashMap<&str, f64> = stmt
190 .trial_balance_totals
191 .iter()
192 .map(|(k, v)| (k.as_str(), *v))
193 .collect();
194 for (account, amount) in &stmt.line_item_totals {
195 total_line_items += 1;
196 if let Some(&tb_amount) = tb_map.get(account.as_str()) {
197 if (amount - tb_amount).abs() <= self.thresholds.balance_tolerance {
198 matched_line_items += 1;
199 }
200 }
201 }
202 }
203 let statement_tb_tie_rate = if total_line_items > 0 {
204 matched_line_items as f64 / total_line_items as f64
205 } else {
206 1.0
207 };
208 let tie_back_mismatches = total_line_items - matched_line_items;
209 if statement_tb_tie_rate < self.thresholds.min_statement_tb_tie_rate {
210 issues.push(format!(
211 "Statement-TB tie rate {:.3} < {:.3} threshold ({} mismatches)",
212 statement_tb_tie_rate,
213 self.thresholds.min_statement_tb_tie_rate,
214 tie_back_mismatches
215 ));
216 }
217
218 let mut period_cf_results = Vec::new();
220 let mut all_reconciled = true;
221 for stmt in statements {
222 let computed_ending = stmt.cash_beginning
223 + stmt.cash_flow_operating
224 + stmt.cash_flow_investing
225 + stmt.cash_flow_financing;
226 let discrepancy = (stmt.cash_ending - computed_ending).abs();
227 let reconciled = discrepancy <= self.thresholds.balance_tolerance;
228 if !reconciled {
229 all_reconciled = false;
230 issues.push(format!(
231 "Cash flow not reconciled in {}: discrepancy {:.2}",
232 stmt.period, discrepancy
233 ));
234 }
235 period_cf_results.push(CashFlowResult {
236 period: stmt.period.clone(),
237 reconciled,
238 discrepancy,
239 });
240 }
241
242 let mut kpi_matches = 0usize;
244 for kpi in kpis {
245 let denominator = if kpi.computed_value.abs() > f64::EPSILON {
246 kpi.computed_value.abs()
247 } else {
248 1.0
249 };
250 let error = (kpi.reported_value - kpi.computed_value).abs() / denominator;
251 if error <= 0.05 {
252 kpi_matches += 1;
253 }
254 }
255 let kpi_derivation_accuracy = if kpis.is_empty() {
256 1.0
257 } else {
258 kpi_matches as f64 / kpis.len() as f64
259 };
260 let kpi_mismatches = kpis.len() - kpi_matches;
261 if kpi_derivation_accuracy < self.thresholds.min_kpi_accuracy {
262 issues.push(format!(
263 "KPI derivation accuracy {:.3} < {:.3} threshold ({} mismatches)",
264 kpi_derivation_accuracy, self.thresholds.min_kpi_accuracy, kpi_mismatches
265 ));
266 }
267
268 let variance_ratios: Vec<f64> = budget_variances
270 .iter()
271 .filter(|bv| bv.budget_amount.abs() > f64::EPSILON)
272 .map(|bv| (bv.actual_amount - bv.budget_amount) / bv.budget_amount)
273 .collect();
274
275 let budget_variance_std = if variance_ratios.len() >= 2 {
276 let mean = variance_ratios.iter().sum::<f64>() / variance_ratios.len() as f64;
277 let variance = variance_ratios
278 .iter()
279 .map(|v| (v - mean).powi(2))
280 .sum::<f64>()
281 / (variance_ratios.len() - 1) as f64;
282 variance.sqrt()
283 } else {
284 0.0
285 };
286
287 let budget_variance_within_bounds =
288 budget_variance_std <= self.thresholds.max_budget_variance_std;
289 if !budget_variance_within_bounds && !variance_ratios.is_empty() {
290 issues.push(format!(
291 "Budget variance std {:.3} > {:.3} threshold",
292 budget_variance_std, self.thresholds.max_budget_variance_std
293 ));
294 }
295
296 let passes = issues.is_empty();
297
298 Ok(FinancialReportingEvaluation {
299 bs_equation_balanced: all_balanced,
300 period_bs_results,
301 statement_tb_tie_rate,
302 tie_back_mismatches,
303 cash_flow_reconciled: all_reconciled,
304 period_cf_results,
305 kpi_derivation_accuracy,
306 kpi_mismatches,
307 budget_variance_std,
308 budget_variance_within_bounds,
309 passes,
310 issues,
311 })
312 }
313}
314
315impl Default for FinancialReportingEvaluator {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used)]
323mod tests {
324 use super::*;
325
326 fn valid_statement() -> FinancialStatementData {
327 FinancialStatementData {
328 period: "2024-Q1".to_string(),
329 total_assets: 1_000_000.0,
330 total_liabilities: 600_000.0,
331 total_equity: 400_000.0,
332 line_item_totals: vec![
333 ("1100".to_string(), 500_000.0),
334 ("2000".to_string(), 300_000.0),
335 ],
336 trial_balance_totals: vec![
337 ("1100".to_string(), 500_000.0),
338 ("2000".to_string(), 300_000.0),
339 ],
340 cash_flow_operating: 50_000.0,
341 cash_flow_investing: -20_000.0,
342 cash_flow_financing: -10_000.0,
343 cash_beginning: 100_000.0,
344 cash_ending: 120_000.0,
345 }
346 }
347
348 #[test]
349 fn test_valid_financial_reporting() {
350 let evaluator = FinancialReportingEvaluator::new();
351 let stmts = vec![valid_statement()];
352 let kpis = vec![KpiData {
353 name: "ROA".to_string(),
354 reported_value: 0.05,
355 computed_value: 0.05,
356 }];
357 let budgets = vec![
358 BudgetVarianceData {
359 line_item: "Revenue".to_string(),
360 budget_amount: 100_000.0,
361 actual_amount: 105_000.0,
362 },
363 BudgetVarianceData {
364 line_item: "COGS".to_string(),
365 budget_amount: 60_000.0,
366 actual_amount: 58_000.0,
367 },
368 ];
369
370 let result = evaluator.evaluate(&stmts, &kpis, &budgets).unwrap();
371 assert!(result.passes);
372 assert!(result.bs_equation_balanced);
373 assert!(result.cash_flow_reconciled);
374 assert_eq!(result.statement_tb_tie_rate, 1.0);
375 assert_eq!(result.kpi_derivation_accuracy, 1.0);
376 }
377
378 #[test]
379 fn test_imbalanced_balance_sheet() {
380 let evaluator = FinancialReportingEvaluator::new();
381 let mut stmt = valid_statement();
382 stmt.total_assets = 1_000_000.0;
383 stmt.total_liabilities = 500_000.0;
384 stmt.total_equity = 400_000.0; let result = evaluator.evaluate(&[stmt], &[], &[]).unwrap();
387 assert!(!result.bs_equation_balanced);
388 assert!(!result.passes);
389 }
390
391 #[test]
392 fn test_cash_flow_mismatch() {
393 let evaluator = FinancialReportingEvaluator::new();
394 let mut stmt = valid_statement();
395 stmt.cash_ending = 200_000.0; let result = evaluator.evaluate(&[stmt], &[], &[]).unwrap();
398 assert!(!result.cash_flow_reconciled);
399 assert!(!result.passes);
400 }
401
402 #[test]
403 fn test_empty_data() {
404 let evaluator = FinancialReportingEvaluator::new();
405 let result = evaluator.evaluate(&[], &[], &[]).unwrap();
406 assert!(result.passes);
407 assert_eq!(result.kpi_derivation_accuracy, 1.0);
408 }
409
410 #[test]
411 fn test_kpi_mismatch() {
412 let evaluator = FinancialReportingEvaluator::new();
413 let kpis = vec![
414 KpiData {
415 name: "ROA".to_string(),
416 reported_value: 0.10,
417 computed_value: 0.05, },
419 KpiData {
420 name: "ROE".to_string(),
421 reported_value: 0.15,
422 computed_value: 0.15, },
424 ];
425
426 let result = evaluator.evaluate(&[], &kpis, &[]).unwrap();
427 assert_eq!(result.kpi_derivation_accuracy, 0.5);
428 assert_eq!(result.kpi_mismatches, 1);
429 }
430}