Skip to main content

antecedent_model/
overlay.rs

1//! Intervention overlays on an immutable compiled plan.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use 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/// Compact overlay describing how sampling differs from the observational plan.
17///
18/// The underlying [`CompiledCausalModel`] is never cloned; overlays are applied
19/// during ancestral sampling.
20#[derive(Clone, Debug, Default)]
21pub struct InterventionOverlay {
22    /// Per-node hard sets (dense index → value), `None` = not hard-set.
23    pub hard_set: Vec<Option<f64>>,
24    /// Per-node additive shifts.
25    pub shifts: Vec<f64>,
26    /// Per-node stochastic policies.
27    pub stochastic: Vec<Option<StochasticPolicy>>,
28    /// Per-node soft mechanism overrides.
29    pub soft: Vec<Option<MechanismOverride>>,
30    /// Optional temporal activation mask per node (`true` = active at current step).
31    pub active: Vec<bool>,
32}
33
34impl InterventionOverlay {
35    /// Empty overlay (observational) for `n_nodes`.
36    #[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    /// Reject a node carrying both a hard set and an additive shift.
48    ///
49    /// A hard set replaces a node's assignment outright, so an additive shift on the
50    /// same node has nothing well-defined to add to: the mechanism it would shift is
51    /// exactly the one the set discarded. Rather than pick a winner silently, both
52    /// overlay construction and the overlay-accepting samplers refuse the pair and
53    /// make the caller say which they meant.
54    ///
55    /// Called for you by [`Self::from_interventions`] and [`Self::from_sequence_at`];
56    /// call it yourself if you populate the public fields directly.
57    ///
58    /// # Errors
59    ///
60    /// [`ModelError::Unsupported`] naming the first offending dense node index.
61    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    /// Whether any node is intervened.
77    #[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    /// Compile interventions against a model (simultaneous / single-step).
87    ///
88    /// # Errors
89    ///
90    /// Unknown variables, invalid interventions, or a node both set and shifted
91    /// (see [`Self::validate`]).
92    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        // Checked once here rather than per-variant inside `apply_intervention`: the
101        // conflict is order-independent, and any future shift-producing variant is
102        // covered without having to remember to re-add the check.
103        overlay.validate()?;
104        Ok(overlay)
105    }
106
107    /// Overlay for a temporal sequence at discrete step `t`.
108    ///
109    /// # Errors
110    ///
111    /// Invalid sequence, unknown variables, or a node both set and shifted at `t`
112    /// (see [`Self::validate`]).
113    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        // Only steps active at `t` compose, so a set and a shift that never overlap in
125        // time remain legal — the conflict is evaluated per step, not across the sequence.
126        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            // Unify with `Intervention::Shift`: additive soft overrides are shifts, so
168            // ancestral and structural sampling share the same noise semantics.
169            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            // Simultaneous interpretation at t=0 for static models.
184            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/// Shared immutable model plus overlay (no model clone).
202#[derive(Clone, Debug)]
203pub struct ModelView<'a> {
204    /// Borrowed compiled plan.
205    pub model: &'a CompiledCausalModel,
206    /// Intervention overlay.
207    pub overlay: Arc<InterventionOverlay>,
208}
209
210impl<'a> ModelView<'a> {
211    /// Observational view.
212    #[must_use]
213    pub fn observational(model: &'a CompiledCausalModel) -> Self {
214        Self { model, overlay: Arc::new(InterventionOverlay::observational(model.n_nodes())) }
215    }
216
217    /// Interventional view.
218    #[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        // Multiple shifts on the same variable accumulate additively.
259        assert!((overlay.shifts[0] - 2.0).abs() < 1e-12);
260        assert!(overlay.hard_set[0].is_none());
261        // An unrelated variable is untouched.
262        assert!(overlay.shifts[1].abs() < 1e-12);
263        assert!(overlay.hard_set[y.as_usize()].is_none());
264    }
265
266    /// Order-independence matters: `apply_intervention` writes `hard_set` and `shifts`
267    /// into separate arrays, so neither one "sees" the other as it lands.
268    #[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    /// `Intervention::Soft` with the `additive_shift` family folds into `shifts`, so it
288    /// collides with a hard set exactly like `Intervention::Shift` does.
289    #[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    /// A zero shift is not a shift: it leaves the assignment untouched, so pairing it
306    /// with a set discards nothing and stays legal.
307    #[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    /// Shifts on a *different* variable never conflict — the check is per node, not global.
321    #[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    /// Sequences compose only the steps active at `t`, so a set and a shift scheduled on
337    /// disjoint steps are legal at every `t` even though they name the same variable.
338    #[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    /// The fields are public, so an overlay can be built without going through
376    /// `from_interventions`. `validate` is what such a caller has to reach for.
377    #[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}