1use std::sync::Arc;
6
7use antecedent_core::{
8 Intervention, InterventionSequence, MechanismOverride, StochasticPolicy, TemporalPolicy,
9 VariableId,
10};
11use antecedent_graph::DenseNodeId;
12
13use crate::compile::CompiledCausalModel;
14use crate::error::ModelError;
15
16#[derive(Clone, Debug, Default)]
21pub struct InterventionOverlay {
22 pub hard_set: Vec<Option<f64>>,
24 pub shifts: Vec<f64>,
26 pub stochastic: Vec<Option<StochasticPolicy>>,
28 pub soft: Vec<Option<MechanismOverride>>,
30 pub active: Vec<bool>,
32}
33
34impl InterventionOverlay {
35 #[must_use]
37 pub fn observational(n_nodes: usize) -> Self {
38 Self {
39 hard_set: vec![None; n_nodes],
40 shifts: vec![0.0; n_nodes],
41 stochastic: vec![None; n_nodes],
42 soft: vec![None; n_nodes],
43 active: vec![true; n_nodes],
44 }
45 }
46
47 pub fn validate(&self) -> Result<(), ModelError> {
62 for (idx, set) in self.hard_set.iter().enumerate() {
63 if set.is_some() && self.shifts.get(idx).is_some_and(|s| *s != 0.0) {
64 return Err(ModelError::Unsupported {
65 message: format!(
66 "dense node {idx} carries both a hard set and an additive shift; \
67 a variable cannot be simultaneously pinned (do(X := v)) and shifted \
68 (do(X := X + delta))"
69 ),
70 });
71 }
72 }
73 Ok(())
74 }
75
76 #[must_use]
78 pub fn is_empty(&self) -> bool {
79 self.hard_set.iter().all(Option::is_none)
80 && self.shifts.iter().all(|s| *s == 0.0)
81 && self.stochastic.iter().all(Option::is_none)
82 && self.soft.iter().all(Option::is_none)
83 && self.active.iter().all(|a| *a)
84 }
85
86 pub fn from_interventions(
93 model: &CompiledCausalModel,
94 interventions: &[Intervention],
95 ) -> Result<Self, ModelError> {
96 let mut overlay = Self::observational(model.n_nodes());
97 for iv in interventions {
98 apply_intervention(model, &mut overlay, iv, true)?;
99 }
100 overlay.validate()?;
104 Ok(overlay)
105 }
106
107 pub fn from_sequence_at(
114 model: &CompiledCausalModel,
115 seq: &InterventionSequence,
116 t: i32,
117 ) -> Result<Self, ModelError> {
118 let mut overlay = Self::observational(model.n_nodes());
119 for step in seq.steps.iter() {
120 if temporal_active(&step.temporal, t)? {
121 apply_intervention(model, &mut overlay, &step.intervention, true)?;
122 }
123 }
124 overlay.validate()?;
127 Ok(overlay)
128 }
129}
130
131fn temporal_active(policy: &TemporalPolicy, t: i32) -> Result<bool, ModelError> {
132 policy.validate().map_err(|e| ModelError::Unsupported { message: e.to_string() })?;
133 Ok(policy.is_active_at(t))
134}
135
136fn apply_intervention(
137 model: &CompiledCausalModel,
138 overlay: &mut InterventionOverlay,
139 iv: &Intervention,
140 allow_nested_sequence: bool,
141) -> Result<(), ModelError> {
142 match iv {
143 Intervention::Set { variable, value } => {
144 let dense = require_dense(model, *variable)?;
145 let v = value.as_f64().ok_or_else(|| ModelError::Unsupported {
146 message: "hard set requires numeric value".into(),
147 })?;
148 overlay.hard_set[dense.as_usize()] = Some(v);
149 Ok(())
150 }
151 Intervention::Shift { variable, delta } => {
152 let dense = require_dense(model, *variable)?;
153 let d = delta.as_f64().ok_or_else(|| ModelError::Unsupported {
154 message: "shift requires numeric delta".into(),
155 })?;
156 overlay.shifts[dense.as_usize()] += d;
157 Ok(())
158 }
159 Intervention::Stochastic { variable, policy } => {
160 policy.validate().map_err(|e| ModelError::Unsupported { message: e.to_string() })?;
161 let dense = require_dense(model, *variable)?;
162 overlay.stochastic[dense.as_usize()] = Some(policy.clone());
163 Ok(())
164 }
165 Intervention::Soft { variable, mechanism } => {
166 let dense = require_dense(model, *variable)?;
167 if mechanism.family_id.as_ref() == "additive_shift" {
170 let d = mechanism.parameters.first().copied().unwrap_or(0.0);
171 overlay.shifts[dense.as_usize()] += d;
172 return Ok(());
173 }
174 overlay.soft[dense.as_usize()] = Some(mechanism.clone());
175 Ok(())
176 }
177 Intervention::Sequence(seq) => {
178 if !allow_nested_sequence {
179 return Err(ModelError::Unsupported {
180 message: "nested intervention sequences are not supported here".into(),
181 });
182 }
183 for step in seq.steps.iter() {
185 if temporal_active(&step.temporal, 0)? {
186 apply_intervention(model, overlay, &step.intervention, false)?;
187 }
188 }
189 Ok(())
190 }
191 _ => Err(ModelError::Unsupported { message: "unknown intervention variant".into() }),
192 }
193}
194
195fn require_dense(model: &CompiledCausalModel, var: VariableId) -> Result<DenseNodeId, ModelError> {
196 model.dense_of(var).ok_or_else(|| ModelError::Shape {
197 message: format!("variable {var} not in compiled model"),
198 })
199}
200
201#[derive(Clone, Debug)]
203pub struct ModelView<'a> {
204 pub model: &'a CompiledCausalModel,
206 pub overlay: Arc<InterventionOverlay>,
208}
209
210impl<'a> ModelView<'a> {
211 #[must_use]
213 pub fn observational(model: &'a CompiledCausalModel) -> Self {
214 Self { model, overlay: Arc::new(InterventionOverlay::observational(model.n_nodes())) }
215 }
216
217 #[must_use]
219 pub fn with_overlay(model: &'a CompiledCausalModel, overlay: InterventionOverlay) -> Self {
220 Self { model, overlay: Arc::new(overlay) }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use antecedent_core::{
228 DynamicRuleId, Intervention, InterventionSequence, SequencedIntervention, TemporalPolicy,
229 Value, VariableId,
230 };
231 use antecedent_graph::Dag;
232
233 #[test]
234 fn hard_set_overlay() {
235 let g = Dag::with_variables(2);
236 let model = CompiledCausalModel::compile(g).unwrap();
237 let t = VariableId::from_raw(0);
238 let overlay = InterventionOverlay::from_interventions(
239 &model,
240 &[Intervention::set(t, Value::f64(1.0))],
241 )
242 .unwrap();
243 assert_eq!(overlay.hard_set[0], Some(1.0));
244 assert!(overlay.hard_set[1].is_none());
245 }
246
247 #[test]
248 fn shift_overlay_accumulates_and_is_independent_of_hard_set() {
249 let g = Dag::with_variables(2);
250 let model = CompiledCausalModel::compile(g).unwrap();
251 let x = VariableId::from_raw(0);
252 let y = VariableId::from_raw(1);
253 let overlay = InterventionOverlay::from_interventions(
254 &model,
255 &[Intervention::shift(x, Value::f64(1.5)), Intervention::shift(x, Value::f64(0.5))],
256 )
257 .unwrap();
258 assert!((overlay.shifts[0] - 2.0).abs() < 1e-12);
260 assert!(overlay.hard_set[0].is_none());
261 assert!(overlay.shifts[1].abs() < 1e-12);
263 assert!(overlay.hard_set[y.as_usize()].is_none());
264 }
265
266 #[test]
269 fn set_and_shift_on_same_variable_rejected_in_either_order() {
270 let g = Dag::with_variables(2);
271 let model = CompiledCausalModel::compile(g).unwrap();
272 let x = VariableId::from_raw(0);
273
274 for ivs in [
275 vec![Intervention::set(x, Value::f64(1.0)), Intervention::shift(x, Value::f64(0.5))],
276 vec![Intervention::shift(x, Value::f64(0.5)), Intervention::set(x, Value::f64(1.0))],
277 ] {
278 let err = InterventionOverlay::from_interventions(&model, &ivs).unwrap_err();
279 assert!(
280 matches!(&err, ModelError::Unsupported { message }
281 if message.contains("hard set") && message.contains("additive shift")),
282 "unexpected error: {err}"
283 );
284 }
285 }
286
287 #[test]
290 fn additive_shift_soft_override_collides_with_hard_set() {
291 let g = Dag::with_variables(1);
292 let model = CompiledCausalModel::compile(g).unwrap();
293 let x = VariableId::from_raw(0);
294 let err = InterventionOverlay::from_interventions(
295 &model,
296 &[
297 Intervention::set(x, Value::f64(2.0)),
298 Intervention::soft(x, MechanismOverride::additive_shift(0.25)),
299 ],
300 )
301 .unwrap_err();
302 assert!(matches!(err, ModelError::Unsupported { .. }), "unexpected error: {err}");
303 }
304
305 #[test]
308 fn zero_shift_alongside_hard_set_is_allowed() {
309 let g = Dag::with_variables(1);
310 let model = CompiledCausalModel::compile(g).unwrap();
311 let x = VariableId::from_raw(0);
312 let overlay = InterventionOverlay::from_interventions(
313 &model,
314 &[Intervention::shift(x, Value::f64(0.0)), Intervention::set(x, Value::f64(3.0))],
315 )
316 .unwrap();
317 assert_eq!(overlay.hard_set[0], Some(3.0));
318 }
319
320 #[test]
322 fn set_and_shift_on_different_variables_compose() {
323 let g = Dag::with_variables(2);
324 let model = CompiledCausalModel::compile(g).unwrap();
325 let x = VariableId::from_raw(0);
326 let y = VariableId::from_raw(1);
327 let overlay = InterventionOverlay::from_interventions(
328 &model,
329 &[Intervention::set(x, Value::f64(1.0)), Intervention::shift(y, Value::f64(0.5))],
330 )
331 .unwrap();
332 assert_eq!(overlay.hard_set[0], Some(1.0));
333 assert!((overlay.shifts[1] - 0.5).abs() < 1e-12);
334 }
335
336 #[test]
339 fn sequence_set_and_shift_conflict_only_when_steps_overlap() {
340 let g = Dag::with_variables(1);
341 let model = CompiledCausalModel::compile(g).unwrap();
342 let x = VariableId::from_raw(0);
343
344 let disjoint = InterventionSequence::new(vec![
345 SequencedIntervention::new(
346 Intervention::set(x, Value::f64(1.0)),
347 TemporalPolicy::dynamic(DynamicRuleId::from_raw(0), [0]),
348 ),
349 SequencedIntervention::new(
350 Intervention::shift(x, Value::f64(0.5)),
351 TemporalPolicy::dynamic(DynamicRuleId::from_raw(1), [1]),
352 ),
353 ]);
354 assert_eq!(
355 InterventionOverlay::from_sequence_at(&model, &disjoint, 0).unwrap().hard_set[0],
356 Some(1.0)
357 );
358 let at_one = InterventionOverlay::from_sequence_at(&model, &disjoint, 1).unwrap();
359 assert!(at_one.hard_set[0].is_none());
360 assert!((at_one.shifts[0] - 0.5).abs() < 1e-12);
361
362 let overlapping = InterventionSequence::new(vec![
363 SequencedIntervention::new(
364 Intervention::set(x, Value::f64(1.0)),
365 TemporalPolicy::dynamic(DynamicRuleId::from_raw(0), [0]),
366 ),
367 SequencedIntervention::new(
368 Intervention::shift(x, Value::f64(0.5)),
369 TemporalPolicy::dynamic(DynamicRuleId::from_raw(1), [0]),
370 ),
371 ]);
372 assert!(InterventionOverlay::from_sequence_at(&model, &overlapping, 0).is_err());
373 }
374
375 #[test]
378 fn hand_built_overlay_validates() {
379 let mut overlay = InterventionOverlay::observational(2);
380 assert!(overlay.validate().is_ok());
381 overlay.hard_set[0] = Some(1.0);
382 assert!(overlay.validate().is_ok());
383 overlay.shifts[0] = 0.5;
384 let err = overlay.validate().unwrap_err();
385 assert!(
386 matches!(&err, ModelError::Unsupported { message } if message.contains("dense node 0")),
387 "error should name the offending node: {err}"
388 );
389 }
390
391 #[test]
392 fn dynamic_policy_sequence_activates_on_schedule() {
393 let g = Dag::with_variables(1);
394 let model = CompiledCausalModel::compile(g).unwrap();
395 let t = VariableId::from_raw(0);
396 let seq = InterventionSequence::new(vec![SequencedIntervention::new(
397 Intervention::set(t, Value::f64(1.0)),
398 TemporalPolicy::dynamic(DynamicRuleId::from_raw(0), [0, 2]),
399 )]);
400 let overlay = InterventionOverlay::from_sequence_at(&model, &seq, 0).unwrap();
401 assert_eq!(overlay.hard_set[0], Some(1.0));
402 let idle = InterventionOverlay::from_sequence_at(&model, &seq, 1).unwrap();
403 assert!(idle.hard_set[0].is_none());
404 let again = InterventionOverlay::from_sequence_at(&model, &seq, 2).unwrap();
405 assert_eq!(again.hard_set[0], Some(1.0));
406 }
407}