1use crate::causal_engine::ValidatedIntervention;
4use datasynth_config::GeneratorConfig;
5use datasynth_core::{Intervention, InterventionTiming, InterventionType};
6use thiserror::Error;
7
8#[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
25pub struct InterventionManager;
27
28impl InterventionManager {
29 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 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 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 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 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 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 if a.intervention.priority == b.intervention.priority {
158 return Err(InterventionError::ConflictDetected(
159 a.intervention.priority,
160 path_a.clone(),
161 ));
162 }
163 }
165 }
166 }
167 }
168 }
169 Ok(())
170 }
171
172 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, 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, );
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, );
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, );
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, );
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, detectable: true,
322 }),
323 1,
324 0,
325 );
326 let result = InterventionManager::validate(&[intervention], &config);
327 assert!(matches!(result, Err(InterventionError::BoundsViolation(_))));
328 }
329}