Skip to main content

datasynth_runtime/
intervention_manager.rs

1//! Intervention validation, conflict resolution, and config path resolution.
2
3use crate::causal_engine::ValidatedIntervention;
4use datasynth_config::GeneratorConfig;
5use datasynth_core::{Intervention, InterventionTiming, InterventionType};
6use thiserror::Error;
7
8/// Errors during intervention validation.
9#[derive(Debug, Error)]
10pub enum InterventionError {
11    #[error("invalid target: {0}")]
12    InvalidTarget(String),
13    #[error(
14        "timing out of range: intervention start_month {start} exceeds period_months {period}"
15    )]
16    TimingOutOfRange { start: u32, period: u32 },
17    #[error("timing invalid: start_month must be >= 1, got {0}")]
18    TimingInvalid(u32),
19    #[error("conflict detected: interventions at priority {0} overlap on path '{1}'")]
20    ConflictDetected(u32, String),
21    #[error("bounds violation: {0}")]
22    BoundsViolation(String),
23}
24
25/// Validates, resolves conflicts, and normalizes interventions.
26pub struct InterventionManager;
27
28impl InterventionManager {
29    /// Validate a set of interventions against the config.
30    pub fn validate(
31        interventions: &[Intervention],
32        config: &GeneratorConfig,
33    ) -> Result<Vec<ValidatedIntervention>, InterventionError> {
34        let mut validated = Vec::new();
35
36        for intervention in interventions {
37            Self::validate_timing(&intervention.timing, config)?;
38            Self::validate_bounds(&intervention.intervention_type)?;
39
40            let paths = Self::resolve_config_paths(&intervention.intervention_type);
41
42            validated.push(ValidatedIntervention {
43                intervention: intervention.clone(),
44                affected_config_paths: paths,
45            });
46        }
47
48        Self::check_conflicts(&validated)?;
49        Ok(validated)
50    }
51
52    /// Validate timing is within generation period.
53    fn validate_timing(
54        timing: &InterventionTiming,
55        config: &GeneratorConfig,
56    ) -> Result<(), InterventionError> {
57        if timing.start_month < 1 {
58            return Err(InterventionError::TimingInvalid(timing.start_month));
59        }
60        if timing.start_month > config.global.period_months {
61            return Err(InterventionError::TimingOutOfRange {
62                start: timing.start_month,
63                period: config.global.period_months,
64            });
65        }
66        Ok(())
67    }
68
69    /// Validate intervention-specific bounds.
70    fn validate_bounds(intervention_type: &InterventionType) -> Result<(), InterventionError> {
71        match intervention_type {
72            InterventionType::ControlFailure(cf) => {
73                if !(0.0..=1.0).contains(&cf.severity) {
74                    return Err(InterventionError::BoundsViolation(format!(
75                        "control failure severity must be between 0.0 and 1.0, got {}",
76                        cf.severity
77                    )));
78                }
79            }
80            InterventionType::MacroShock(ms) => {
81                if ms.severity < 0.0 {
82                    return Err(InterventionError::BoundsViolation(format!(
83                        "macro shock severity must be >= 0.0, got {}",
84                        ms.severity
85                    )));
86                }
87            }
88            _ => {}
89        }
90        Ok(())
91    }
92
93    /// Resolve which config paths an intervention affects.
94    fn resolve_config_paths(intervention_type: &InterventionType) -> Vec<String> {
95        match intervention_type {
96            InterventionType::ParameterShift(ps) => vec![ps.target.clone()],
97            InterventionType::ControlFailure(_) => {
98                vec![
99                    "internal_controls.exception_rate".to_string(),
100                    "internal_controls.sod_violation_rate".to_string(),
101                ]
102            }
103            InterventionType::MacroShock(_) => {
104                vec![
105                    "distributions.drift.economic_cycle.amplitude".to_string(),
106                    "transactions.volume_multiplier".to_string(),
107                ]
108            }
109            InterventionType::EntityEvent(ee) => {
110                use datasynth_core::InterventionEntityEvent;
111                match ee.subtype {
112                    InterventionEntityEvent::VendorDefault => {
113                        vec![
114                            "vendor_network.dependencies.max_single_vendor_concentration"
115                                .to_string(),
116                        ]
117                    }
118                    InterventionEntityEvent::CustomerChurn => {
119                        vec!["customer_segmentation.lifecycle.churned_rate".to_string()]
120                    }
121                    _ => vec![],
122                }
123            }
124            InterventionType::ProcessChange(_) => {
125                vec!["approval.thresholds".to_string()]
126            }
127            InterventionType::RegulatoryChange(_) => {
128                vec!["accounting_standards".to_string()]
129            }
130            InterventionType::Custom(ci) => ci.config_overrides.keys().cloned().collect(),
131            InterventionType::Composite(comp) => {
132                let mut paths = Vec::new();
133                for child in &comp.children {
134                    paths.extend(Self::resolve_config_paths(child));
135                }
136                paths.sort();
137                paths.dedup();
138                paths
139            }
140        }
141    }
142
143    /// Check for conflicting interventions on the same config paths.
144    fn check_conflicts(validated: &[ValidatedIntervention]) -> Result<(), InterventionError> {
145        for i in 0..validated.len() {
146            for j in (i + 1)..validated.len() {
147                let a = &validated[i];
148                let b = &validated[j];
149
150                // Check for overlapping config paths
151                for path_a in &a.affected_config_paths {
152                    for path_b in &b.affected_config_paths {
153                        if path_a == path_b
154                            && Self::timing_overlaps(&a.intervention.timing, &b.intervention.timing)
155                        {
156                            // Same priority = conflict
157                            if a.intervention.priority == b.intervention.priority {
158                                return Err(InterventionError::ConflictDetected(
159                                    a.intervention.priority,
160                                    path_a.clone(),
161                                ));
162                            }
163                            // Different priorities: higher wins, no error
164                        }
165                    }
166                }
167            }
168        }
169        Ok(())
170    }
171
172    /// Check if two intervention timings overlap.
173    fn timing_overlaps(a: &InterventionTiming, b: &InterventionTiming) -> bool {
174        let a_end = a.start_month + a.duration_months.unwrap_or(u32::MAX - a.start_month);
175        let b_end = b.start_month + b.duration_months.unwrap_or(u32::MAX - b.start_month);
176        a.start_month < b_end && b.start_month < a_end
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use datasynth_core::{
184        ControlFailureIntervention, ControlFailureType, ControlTarget, OnsetType,
185        ParameterShiftIntervention,
186    };
187    use datasynth_test_utils::fixtures::minimal_config;
188    use uuid::Uuid;
189
190    fn make_intervention(
191        intervention_type: InterventionType,
192        start_month: u32,
193        priority: u32,
194    ) -> Intervention {
195        Intervention {
196            id: Uuid::new_v4(),
197            intervention_type,
198            timing: InterventionTiming {
199                start_month,
200                duration_months: None,
201                onset: OnsetType::Sudden,
202                ramp_months: None,
203            },
204            label: None,
205            priority,
206        }
207    }
208
209    #[test]
210    fn test_validate_timing_out_of_range() {
211        let config = minimal_config();
212        let intervention = make_intervention(
213            InterventionType::ParameterShift(ParameterShiftIntervention {
214                target: "test.path".to_string(),
215                from: None,
216                to: serde_json::json!(100),
217                interpolation: Default::default(),
218            }),
219            999, // way beyond period_months
220            0,
221        );
222        let result = InterventionManager::validate(&[intervention], &config);
223        assert!(matches!(
224            result,
225            Err(InterventionError::TimingOutOfRange { .. })
226        ));
227    }
228
229    #[test]
230    fn test_validate_empty_interventions() {
231        let config = minimal_config();
232        let result = InterventionManager::validate(&[], &config);
233        assert!(result.is_ok());
234        assert!(result.expect("should be ok").is_empty());
235    }
236
237    #[test]
238    fn test_validate_parameter_shift() {
239        let config = minimal_config();
240        let intervention = make_intervention(
241            InterventionType::ParameterShift(ParameterShiftIntervention {
242                target: "transactions.count".to_string(),
243                from: None,
244                to: serde_json::json!(2000),
245                interpolation: Default::default(),
246            }),
247            1,
248            0,
249        );
250        let result = InterventionManager::validate(&[intervention], &config);
251        assert!(result.is_ok());
252    }
253
254    #[test]
255    fn test_conflict_detection() {
256        let config = minimal_config();
257        let a = make_intervention(
258            InterventionType::ParameterShift(ParameterShiftIntervention {
259                target: "transactions.count".to_string(),
260                from: None,
261                to: serde_json::json!(2000),
262                interpolation: Default::default(),
263            }),
264            1,
265            0, // same priority
266        );
267        let b = make_intervention(
268            InterventionType::ParameterShift(ParameterShiftIntervention {
269                target: "transactions.count".to_string(),
270                from: None,
271                to: serde_json::json!(3000),
272                interpolation: Default::default(),
273            }),
274            1,
275            0, // same priority → conflict
276        );
277        let result = InterventionManager::validate(&[a, b], &config);
278        assert!(matches!(
279            result,
280            Err(InterventionError::ConflictDetected(_, _))
281        ));
282    }
283
284    #[test]
285    fn test_conflict_resolution_by_priority() {
286        let config = minimal_config();
287        let a = make_intervention(
288            InterventionType::ParameterShift(ParameterShiftIntervention {
289                target: "transactions.count".to_string(),
290                from: None,
291                to: serde_json::json!(2000),
292                interpolation: Default::default(),
293            }),
294            1,
295            1, // lower priority
296        );
297        let b = make_intervention(
298            InterventionType::ParameterShift(ParameterShiftIntervention {
299                target: "transactions.count".to_string(),
300                from: None,
301                to: serde_json::json!(3000),
302                interpolation: Default::default(),
303            }),
304            1,
305            2, // higher priority → no conflict
306        );
307        let result = InterventionManager::validate(&[a, b], &config);
308        assert!(result.is_ok());
309    }
310
311    #[test]
312    fn test_validate_bounds_control_failure() {
313        let config = minimal_config();
314        let intervention = make_intervention(
315            InterventionType::ControlFailure(ControlFailureIntervention {
316                subtype: ControlFailureType::EffectivenessReduction,
317                control_target: ControlTarget::ById {
318                    control_id: "C001".to_string(),
319                },
320                severity: 1.5, // out of bounds
321                detectable: true,
322            }),
323            1,
324            0,
325        );
326        let result = InterventionManager::validate(&[intervention], &config);
327        assert!(matches!(result, Err(InterventionError::BoundsViolation(_))));
328    }
329}