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    /// Whether any node is intervened.
48    #[must_use]
49    pub fn is_empty(&self) -> bool {
50        self.hard_set.iter().all(Option::is_none)
51            && self.shifts.iter().all(|s| *s == 0.0)
52            && self.stochastic.iter().all(Option::is_none)
53            && self.soft.iter().all(Option::is_none)
54            && self.active.iter().all(|a| *a)
55    }
56
57    /// Compile interventions against a model (simultaneous / single-step).
58    ///
59    /// # Errors
60    ///
61    /// Unknown variables or invalid interventions.
62    pub fn from_interventions(
63        model: &CompiledCausalModel,
64        interventions: &[Intervention],
65    ) -> Result<Self, ModelError> {
66        let mut overlay = Self::observational(model.n_nodes());
67        for iv in interventions {
68            apply_intervention(model, &mut overlay, iv, true)?;
69        }
70        Ok(overlay)
71    }
72
73    /// Overlay for a temporal sequence at discrete step `t`.
74    ///
75    /// # Errors
76    ///
77    /// Invalid sequence or unknown variables.
78    pub fn from_sequence_at(
79        model: &CompiledCausalModel,
80        seq: &InterventionSequence,
81        t: i32,
82    ) -> Result<Self, ModelError> {
83        let mut overlay = Self::observational(model.n_nodes());
84        for step in seq.steps.iter() {
85            if temporal_active(&step.temporal, t)? {
86                apply_intervention(model, &mut overlay, &step.intervention, true)?;
87            }
88        }
89        Ok(overlay)
90    }
91}
92
93fn temporal_active(policy: &TemporalPolicy, t: i32) -> Result<bool, ModelError> {
94    policy.validate().map_err(|e| ModelError::Unsupported { message: e.to_string() })?;
95    Ok(policy.is_active_at(t))
96}
97
98fn apply_intervention(
99    model: &CompiledCausalModel,
100    overlay: &mut InterventionOverlay,
101    iv: &Intervention,
102    allow_nested_sequence: bool,
103) -> Result<(), ModelError> {
104    match iv {
105        Intervention::Set { variable, value } => {
106            let dense = require_dense(model, *variable)?;
107            let v = value.as_f64().ok_or_else(|| ModelError::Unsupported {
108                message: "hard set requires numeric value".into(),
109            })?;
110            overlay.hard_set[dense.as_usize()] = Some(v);
111            Ok(())
112        }
113        Intervention::Shift { variable, delta } => {
114            let dense = require_dense(model, *variable)?;
115            let d = delta.as_f64().ok_or_else(|| ModelError::Unsupported {
116                message: "shift requires numeric delta".into(),
117            })?;
118            overlay.shifts[dense.as_usize()] += d;
119            Ok(())
120        }
121        Intervention::Stochastic { variable, policy } => {
122            policy.validate().map_err(|e| ModelError::Unsupported { message: e.to_string() })?;
123            let dense = require_dense(model, *variable)?;
124            overlay.stochastic[dense.as_usize()] = Some(policy.clone());
125            Ok(())
126        }
127        Intervention::Soft { variable, mechanism } => {
128            let dense = require_dense(model, *variable)?;
129            // Unify with `Intervention::Shift`: additive soft overrides are shifts, so
130            // ancestral and structural sampling share the same noise semantics.
131            if mechanism.family_id.as_ref() == "additive_shift" {
132                let d = mechanism.parameters.first().copied().unwrap_or(0.0);
133                overlay.shifts[dense.as_usize()] += d;
134                return Ok(());
135            }
136            overlay.soft[dense.as_usize()] = Some(mechanism.clone());
137            Ok(())
138        }
139        Intervention::Sequence(seq) => {
140            if !allow_nested_sequence {
141                return Err(ModelError::Unsupported {
142                    message: "nested intervention sequences are not supported here".into(),
143                });
144            }
145            // Simultaneous interpretation at t=0 for static models.
146            for step in seq.steps.iter() {
147                if temporal_active(&step.temporal, 0)? {
148                    apply_intervention(model, overlay, &step.intervention, false)?;
149                }
150            }
151            Ok(())
152        }
153        _ => Err(ModelError::Unsupported { message: "unknown intervention variant".into() }),
154    }
155}
156
157fn require_dense(model: &CompiledCausalModel, var: VariableId) -> Result<DenseNodeId, ModelError> {
158    model.dense_of(var).ok_or_else(|| ModelError::Shape {
159        message: format!("variable {var} not in compiled model"),
160    })
161}
162
163/// Shared immutable model plus overlay (no model clone).
164#[derive(Clone, Debug)]
165pub struct ModelView<'a> {
166    /// Borrowed compiled plan.
167    pub model: &'a CompiledCausalModel,
168    /// Intervention overlay.
169    pub overlay: Arc<InterventionOverlay>,
170}
171
172impl<'a> ModelView<'a> {
173    /// Observational view.
174    #[must_use]
175    pub fn observational(model: &'a CompiledCausalModel) -> Self {
176        Self { model, overlay: Arc::new(InterventionOverlay::observational(model.n_nodes())) }
177    }
178
179    /// Interventional view.
180    #[must_use]
181    pub fn with_overlay(model: &'a CompiledCausalModel, overlay: InterventionOverlay) -> Self {
182        Self { model, overlay: Arc::new(overlay) }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use antecedent_core::{
190        DynamicRuleId, Intervention, InterventionSequence, SequencedIntervention, TemporalPolicy,
191        Value, VariableId,
192    };
193    use antecedent_graph::Dag;
194
195    #[test]
196    fn hard_set_overlay() {
197        let g = Dag::with_variables(2);
198        let model = CompiledCausalModel::compile(g).unwrap();
199        let t = VariableId::from_raw(0);
200        let overlay = InterventionOverlay::from_interventions(
201            &model,
202            &[Intervention::set(t, Value::f64(1.0))],
203        )
204        .unwrap();
205        assert_eq!(overlay.hard_set[0], Some(1.0));
206        assert!(overlay.hard_set[1].is_none());
207    }
208
209    #[test]
210    fn dynamic_policy_sequence_activates_on_schedule() {
211        let g = Dag::with_variables(1);
212        let model = CompiledCausalModel::compile(g).unwrap();
213        let t = VariableId::from_raw(0);
214        let seq = InterventionSequence::new(vec![SequencedIntervention::new(
215            Intervention::set(t, Value::f64(1.0)),
216            TemporalPolicy::dynamic(DynamicRuleId::from_raw(0), [0, 2]),
217        )]);
218        let overlay = InterventionOverlay::from_sequence_at(&model, &seq, 0).unwrap();
219        assert_eq!(overlay.hard_set[0], Some(1.0));
220        let idle = InterventionOverlay::from_sequence_at(&model, &seq, 1).unwrap();
221        assert!(idle.hard_set[0].is_none());
222        let again = InterventionOverlay::from_sequence_at(&model, &seq, 2).unwrap();
223        assert_eq!(again.hard_set[0], Some(1.0));
224    }
225}