Skip to main content

datasynth_eval/
scenario_diff.rs

1//! Scenario diff types for baseline vs counterfactual comparison.
2
3use serde::{Deserialize, Serialize};
4
5/// Complete diff result for a scenario comparison.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ScenarioDiff {
8    pub summary: Option<ImpactSummary>,
9    pub record_level: Option<Vec<RecordLevelDiff>>,
10    pub aggregate: Option<AggregateComparison>,
11    pub intervention_trace: Option<InterventionTrace>,
12}
13
14/// High-level impact summary.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ImpactSummary {
17    pub scenario_name: String,
18    pub generation_timestamp: String,
19    pub interventions_applied: usize,
20    pub kpi_impacts: Vec<KpiImpact>,
21    pub financial_statement_impacts: Option<FinancialStatementImpact>,
22    pub anomaly_impact: Option<AnomalyImpact>,
23    pub control_impact: Option<ControlImpact>,
24}
25
26/// Impact on a single KPI.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct KpiImpact {
29    pub kpi_name: String,
30    pub baseline_value: f64,
31    pub counterfactual_value: f64,
32    pub absolute_change: f64,
33    pub percent_change: f64,
34    pub direction: ChangeDirection,
35}
36
37/// Direction of change.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum ChangeDirection {
40    Increase,
41    Decrease,
42    Unchanged,
43}
44
45/// Financial statement level impacts.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FinancialStatementImpact {
48    pub revenue_change_pct: f64,
49    pub cogs_change_pct: f64,
50    pub margin_change_pct: f64,
51    pub net_income_change_pct: f64,
52    pub total_assets_change_pct: f64,
53    pub total_liabilities_change_pct: f64,
54    pub cash_flow_change_pct: f64,
55    pub top_changed_line_items: Vec<LineItemImpact>,
56}
57
58/// Impact on a single line item.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct LineItemImpact {
61    pub line_item: String,
62    pub baseline: f64,
63    pub counterfactual: f64,
64    pub change_pct: f64,
65}
66
67/// Impact on anomaly counts.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AnomalyImpact {
70    pub baseline_count: usize,
71    pub counterfactual_count: usize,
72    pub new_types: Vec<String>,
73    pub removed_types: Vec<String>,
74    pub rate_change_pct: f64,
75}
76
77/// Impact on internal controls.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ControlImpact {
80    pub controls_affected: usize,
81    pub new_deficiencies: Vec<ControlDeficiency>,
82    pub material_weakness_risk: bool,
83}
84
85/// A control deficiency resulting from intervention.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ControlDeficiency {
88    pub control_id: String,
89    pub name: String,
90    pub baseline_effectiveness: f64,
91    pub counterfactual_effectiveness: f64,
92    pub classification: DeficiencyClassification,
93}
94
95/// Classification of a control deficiency.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub enum DeficiencyClassification {
98    Deficiency,
99    SignificantDeficiency,
100    MaterialWeakness,
101}
102
103/// Record-level diff for a single output file.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RecordLevelDiff {
106    pub file_name: String,
107    pub records_added: usize,
108    pub records_removed: usize,
109    pub records_modified: usize,
110    pub records_unchanged: usize,
111    pub sample_changes: Vec<RecordChange>,
112}
113
114/// A single record-level change.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RecordChange {
117    pub record_id: String,
118    pub change_type: RecordChangeType,
119    pub field_changes: Vec<FieldChange>,
120}
121
122/// Type of record-level change.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub enum RecordChangeType {
125    Added,
126    Removed,
127    Modified,
128}
129
130/// A field-level change within a record.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct FieldChange {
133    pub field_name: String,
134    pub baseline_value: String,
135    pub counterfactual_value: String,
136}
137
138/// Aggregate comparison across files.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AggregateComparison {
141    pub metrics: Vec<MetricComparison>,
142    pub period_comparisons: Vec<PeriodComparison>,
143}
144
145/// Comparison of a single metric.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct MetricComparison {
148    pub metric_name: String,
149    pub baseline: f64,
150    pub counterfactual: f64,
151    pub change_pct: f64,
152}
153
154/// Period-level comparison.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct PeriodComparison {
157    pub period: String,
158    pub metrics: Vec<MetricComparison>,
159}
160
161/// Trace of how interventions propagated.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct InterventionTrace {
164    pub traces: Vec<InterventionEffect>,
165}
166
167/// Effect of a single intervention.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct InterventionEffect {
170    pub intervention_label: String,
171    pub intervention_type: String,
172    pub causal_path: Vec<CausalPathStep>,
173    pub ultimate_impacts: Vec<KpiImpact>,
174}
175
176/// A step in the causal propagation path.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CausalPathStep {
179    pub node_id: String,
180    pub node_label: String,
181    pub input_delta: f64,
182    pub output_delta: f64,
183    pub transfer_function: String,
184}
185
186#[cfg(test)]
187#[allow(clippy::unwrap_used)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_scenario_diff_serde_roundtrip() {
193        let diff = ScenarioDiff {
194            summary: Some(ImpactSummary {
195                scenario_name: "test".to_string(),
196                generation_timestamp: "2024-01-01T00:00:00Z".to_string(),
197                interventions_applied: 1,
198                kpi_impacts: vec![KpiImpact {
199                    kpi_name: "total_transactions".to_string(),
200                    baseline_value: 1000.0,
201                    counterfactual_value: 800.0,
202                    absolute_change: -200.0,
203                    percent_change: -20.0,
204                    direction: ChangeDirection::Decrease,
205                }],
206                financial_statement_impacts: None,
207                anomaly_impact: None,
208                control_impact: None,
209            }),
210            record_level: None,
211            aggregate: None,
212            intervention_trace: None,
213        };
214
215        let json = serde_json::to_string(&diff).expect("serialize");
216        let deserialized: ScenarioDiff = serde_json::from_str(&json).expect("deserialize");
217        assert_eq!(
218            deserialized
219                .summary
220                .as_ref()
221                .expect("has summary")
222                .scenario_name,
223            "test"
224        );
225    }
226
227    #[test]
228    fn test_change_direction_variants() {
229        assert_eq!(ChangeDirection::Increase, ChangeDirection::Increase);
230        assert_eq!(ChangeDirection::Decrease, ChangeDirection::Decrease);
231        assert_eq!(ChangeDirection::Unchanged, ChangeDirection::Unchanged);
232    }
233
234    #[test]
235    fn test_record_level_diff_serde() {
236        let diff = RecordLevelDiff {
237            file_name: "journal_entries.csv".to_string(),
238            records_added: 10,
239            records_removed: 0,
240            records_modified: 50,
241            records_unchanged: 940,
242            sample_changes: vec![RecordChange {
243                record_id: "JE-001".to_string(),
244                change_type: RecordChangeType::Modified,
245                field_changes: vec![FieldChange {
246                    field_name: "amount".to_string(),
247                    baseline_value: "1000.00".to_string(),
248                    counterfactual_value: "800.00".to_string(),
249                }],
250            }],
251        };
252
253        let json = serde_json::to_string(&diff).expect("serialize");
254        let deserialized: RecordLevelDiff = serde_json::from_str(&json).expect("deserialize");
255        assert_eq!(deserialized.file_name, "journal_entries.csv");
256        assert_eq!(deserialized.records_modified, 50);
257    }
258
259    #[test]
260    fn test_aggregate_comparison_serde() {
261        let agg = AggregateComparison {
262            metrics: vec![MetricComparison {
263                metric_name: "total_amount".to_string(),
264                baseline: 1_000_000.0,
265                counterfactual: 850_000.0,
266                change_pct: -15.0,
267            }],
268            period_comparisons: vec![PeriodComparison {
269                period: "2024-01".to_string(),
270                metrics: vec![MetricComparison {
271                    metric_name: "transaction_count".to_string(),
272                    baseline: 100.0,
273                    counterfactual: 80.0,
274                    change_pct: -20.0,
275                }],
276            }],
277        };
278
279        let json = serde_json::to_string(&agg).expect("serialize");
280        let deserialized: AggregateComparison = serde_json::from_str(&json).expect("deserialize");
281        assert_eq!(deserialized.metrics.len(), 1);
282        assert_eq!(deserialized.period_comparisons.len(), 1);
283    }
284}