Skip to main content

datasynth_eval/coherence/
financial_reporting.rs

1//! Financial reporting evaluator.
2//!
3//! Validates financial statement coherence including balance sheet equation,
4//! statement-to-trial-balance tie-back, cash flow reconciliation,
5//! KPI derivation accuracy, and budget variance realism.
6
7use crate::error::EvalResult;
8use serde::{Deserialize, Serialize};
9
10/// Thresholds for financial reporting evaluation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FinancialReportingThresholds {
13    /// Minimum rate at which statement line items tie back to trial balance.
14    pub min_statement_tb_tie_rate: f64,
15    /// Minimum KPI derivation accuracy.
16    pub min_kpi_accuracy: f64,
17    /// Maximum budget variance standard deviation (as fraction of budget).
18    pub max_budget_variance_std: f64,
19    /// Tolerance for balance equation checks.
20    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/// Input data for a financial statement period.
35#[derive(Debug, Clone)]
36pub struct FinancialStatementData {
37    /// Period identifier (e.g., "2024-Q1").
38    pub period: String,
39    /// Total assets from balance sheet.
40    pub total_assets: f64,
41    /// Total liabilities from balance sheet.
42    pub total_liabilities: f64,
43    /// Total equity from balance sheet.
44    pub total_equity: f64,
45    /// Statement line item totals by GL account.
46    pub line_item_totals: Vec<(String, f64)>,
47    /// Trial balance totals by GL account for the same period.
48    pub trial_balance_totals: Vec<(String, f64)>,
49    /// Operating cash flow.
50    pub cash_flow_operating: f64,
51    /// Investing cash flow.
52    pub cash_flow_investing: f64,
53    /// Financing cash flow.
54    pub cash_flow_financing: f64,
55    /// Beginning cash balance.
56    pub cash_beginning: f64,
57    /// Ending cash balance.
58    pub cash_ending: f64,
59}
60
61/// Input data for KPI validation.
62#[derive(Debug, Clone)]
63pub struct KpiData {
64    /// KPI name.
65    pub name: String,
66    /// Reported KPI value.
67    pub reported_value: f64,
68    /// Computed KPI value from underlying GL data.
69    pub computed_value: f64,
70}
71
72/// Input data for budget variance.
73#[derive(Debug, Clone)]
74pub struct BudgetVarianceData {
75    /// Budget line item name.
76    pub line_item: String,
77    /// Budgeted amount.
78    pub budget_amount: f64,
79    /// Actual amount.
80    pub actual_amount: f64,
81}
82
83/// Per-period balance sheet result.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PeriodBsResult {
86    /// Period identifier.
87    pub period: String,
88    /// Whether A = L + E within tolerance.
89    pub balanced: bool,
90    /// Imbalance amount.
91    pub imbalance: f64,
92}
93
94/// Per-period cash flow result.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CashFlowResult {
97    /// Period identifier.
98    pub period: String,
99    /// Whether cash flow reconciles.
100    pub reconciled: bool,
101    /// Discrepancy amount.
102    pub discrepancy: f64,
103}
104
105/// Results of financial reporting evaluation.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FinancialReportingEvaluation {
108    /// Whether all balance sheets are balanced (A = L + E).
109    pub bs_equation_balanced: bool,
110    /// Per-period balance sheet results.
111    pub period_bs_results: Vec<PeriodBsResult>,
112    /// Rate at which statement line items tie to trial balance.
113    pub statement_tb_tie_rate: f64,
114    /// Number of tie-back mismatches.
115    pub tie_back_mismatches: usize,
116    /// Whether all cash flow statements reconcile.
117    pub cash_flow_reconciled: bool,
118    /// Per-period cash flow results.
119    pub period_cf_results: Vec<CashFlowResult>,
120    /// KPI derivation accuracy (fraction of KPIs within tolerance).
121    pub kpi_derivation_accuracy: f64,
122    /// Number of KPI derivation mismatches.
123    pub kpi_mismatches: usize,
124    /// Budget variance standard deviation (normalized).
125    pub budget_variance_std: f64,
126    /// Whether budget variance is within bounds.
127    pub budget_variance_within_bounds: bool,
128    /// Overall pass/fail.
129    pub passes: bool,
130    /// Issues found.
131    pub issues: Vec<String>,
132}
133
134/// Evaluator for financial reporting coherence.
135pub struct FinancialReportingEvaluator {
136    thresholds: FinancialReportingThresholds,
137}
138
139impl FinancialReportingEvaluator {
140    /// Create a new evaluator with default thresholds.
141    pub fn new() -> Self {
142        Self {
143            thresholds: FinancialReportingThresholds::default(),
144        }
145    }
146
147    /// Create with custom thresholds.
148    pub fn with_thresholds(thresholds: FinancialReportingThresholds) -> Self {
149        Self { thresholds }
150    }
151
152    /// Evaluate financial reporting data.
153    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        // 1. Balance sheet equation: A = L + E
162        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        // 2. Statement-to-trial-balance tie-back
186        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        // 3. Cash flow reconciliation
219        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        // 4. KPI derivation accuracy
243        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        // 5. Budget variance realism
269        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; // 100k gap
385
386        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; // Wrong
396
397        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, // 100% error
418            },
419            KpiData {
420                name: "ROE".to_string(),
421                reported_value: 0.15,
422                computed_value: 0.15, // exact match
423            },
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}