Skip to main content

datasynth_core/models/
scenario.rs

1use rust_decimal::Decimal;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use uuid::Uuid;
5
6use super::intervention::InterventionType;
7
8/// A named, self-contained scenario definition.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Scenario {
11    pub id: Uuid,
12    pub name: String,
13    pub description: String,
14    #[serde(default)]
15    pub tags: Vec<String>,
16    /// Reference to base scenario (None = default config).
17    pub base: Option<String>,
18    /// For IFRS 9-style probability-weighted outcomes.
19    pub probability_weight: Option<f64>,
20    pub interventions: Vec<Intervention>,
21    #[serde(default)]
22    pub constraints: ScenarioConstraints,
23    #[serde(default)]
24    pub output: ScenarioOutputConfig,
25    #[serde(default)]
26    pub metadata: HashMap<String, String>,
27}
28
29/// A single intervention that modifies the generation.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Intervention {
32    pub id: Uuid,
33    pub intervention_type: InterventionType,
34    pub timing: InterventionTiming,
35    /// Human-readable label for UI display.
36    pub label: Option<String>,
37    /// Priority for conflict resolution (higher wins).
38    #[serde(default)]
39    pub priority: u32,
40}
41
42/// When the intervention takes effect.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct InterventionTiming {
45    /// Month offset from generation start (1-indexed).
46    pub start_month: u32,
47    /// Duration in months (None = permanent from start_month).
48    pub duration_months: Option<u32>,
49    /// How the intervention ramps in.
50    #[serde(default)]
51    pub onset: OnsetType,
52    /// Ramp-in period in months (for gradual/oscillating onset).
53    pub ramp_months: Option<u32>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum OnsetType {
59    /// Full effect immediately.
60    #[default]
61    Sudden,
62    /// Linear ramp over ramp_months.
63    Gradual,
64    /// Sinusoidal oscillation.
65    Oscillating,
66    /// Custom easing curve.
67    Custom { easing: EasingFunction },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum EasingFunction {
73    Linear,
74    EaseIn,
75    EaseOut,
76    EaseInOut,
77    Step { steps: u32 },
78}
79
80/// What invariants must hold in the counterfactual.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ScenarioConstraints {
83    /// Debits = Credits for all journal entries.
84    #[serde(default = "default_true")]
85    pub preserve_accounting_identity: bool,
86    /// Document chain references remain valid.
87    #[serde(default = "default_true")]
88    pub preserve_document_chains: bool,
89    /// Period close still executes.
90    #[serde(default = "default_true")]
91    pub preserve_period_close: bool,
92    /// Balance sheet still balances at each period.
93    #[serde(default = "default_true")]
94    pub preserve_balance_coherence: bool,
95    /// Custom constraints (config path -> value range).
96    #[serde(default)]
97    pub custom: Vec<CustomConstraint>,
98}
99
100impl Default for ScenarioConstraints {
101    fn default() -> Self {
102        Self {
103            preserve_accounting_identity: true,
104            preserve_document_chains: true,
105            preserve_period_close: true,
106            preserve_balance_coherence: true,
107            custom: Vec::new(),
108        }
109    }
110}
111
112fn default_true() -> bool {
113    true
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CustomConstraint {
118    pub config_path: String,
119    pub min: Option<Decimal>,
120    pub max: Option<Decimal>,
121    pub description: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ScenarioOutputConfig {
126    /// Generate baseline alongside counterfactual.
127    #[serde(default = "default_true")]
128    pub paired: bool,
129    /// Which diff formats to produce.
130    #[serde(default = "default_diff_formats")]
131    pub diff_formats: Vec<DiffFormat>,
132    /// Which output files to include in diff (empty = all).
133    #[serde(default)]
134    pub diff_scope: Vec<String>,
135}
136
137impl Default for ScenarioOutputConfig {
138    fn default() -> Self {
139        Self {
140            paired: true,
141            diff_formats: default_diff_formats(),
142            diff_scope: Vec::new(),
143        }
144    }
145}
146
147fn default_diff_formats() -> Vec<DiffFormat> {
148    vec![DiffFormat::Summary, DiffFormat::Aggregate]
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum DiffFormat {
154    /// High-level KPI impact summary.
155    Summary,
156    /// Record-by-record comparison.
157    RecordLevel,
158    /// Aggregated metric comparison.
159    Aggregate,
160    /// Which interventions caused which changes.
161    InterventionTrace,
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_scenario_constraints_default_all_true() {
170        let constraints = ScenarioConstraints::default();
171        assert!(constraints.preserve_accounting_identity);
172        assert!(constraints.preserve_document_chains);
173        assert!(constraints.preserve_period_close);
174        assert!(constraints.preserve_balance_coherence);
175        assert!(constraints.custom.is_empty());
176    }
177
178    #[test]
179    fn test_onset_type_variants() {
180        // Sudden is default
181        let onset: OnsetType = serde_json::from_str(r#""sudden""#).unwrap();
182        assert!(matches!(onset, OnsetType::Sudden));
183
184        let onset: OnsetType = serde_json::from_str(r#""gradual""#).unwrap();
185        assert!(matches!(onset, OnsetType::Gradual));
186
187        let onset: OnsetType = serde_json::from_str(r#""oscillating""#).unwrap();
188        assert!(matches!(onset, OnsetType::Oscillating));
189
190        // Custom with easing
191        let onset: OnsetType = serde_json::from_str(r#"{"custom":{"easing":"ease_in"}}"#).unwrap();
192        assert!(matches!(onset, OnsetType::Custom { .. }));
193    }
194
195    #[test]
196    fn test_scenario_serde_roundtrip() {
197        let scenario = Scenario {
198            id: Uuid::new_v4(),
199            name: "test_scenario".to_string(),
200            description: "A test scenario".to_string(),
201            tags: vec!["test".to_string()],
202            base: None,
203            probability_weight: Some(0.5),
204            interventions: vec![],
205            constraints: ScenarioConstraints::default(),
206            output: ScenarioOutputConfig::default(),
207            metadata: HashMap::new(),
208        };
209
210        let json = serde_json::to_string(&scenario).unwrap();
211        let deserialized: Scenario = serde_json::from_str(&json).unwrap();
212
213        assert_eq!(deserialized.name, "test_scenario");
214        assert_eq!(deserialized.probability_weight, Some(0.5));
215        assert!(deserialized.constraints.preserve_accounting_identity);
216        assert!(deserialized.output.paired);
217    }
218
219    #[test]
220    fn test_scenario_output_config_defaults() {
221        let config = ScenarioOutputConfig::default();
222        assert!(config.paired);
223        assert_eq!(config.diff_formats.len(), 2);
224        assert!(config.diff_formats.contains(&DiffFormat::Summary));
225        assert!(config.diff_formats.contains(&DiffFormat::Aggregate));
226        assert!(config.diff_scope.is_empty());
227    }
228
229    #[test]
230    fn test_diff_format_serde() {
231        let format: DiffFormat = serde_json::from_str(r#""summary""#).unwrap();
232        assert_eq!(format, DiffFormat::Summary);
233
234        let format: DiffFormat = serde_json::from_str(r#""record_level""#).unwrap();
235        assert_eq!(format, DiffFormat::RecordLevel);
236    }
237}