Skip to main content

antecedent_model/
compile.rs

1//! Compiled topological execution plans.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_possible_truncation)]
6
7use std::sync::Arc;
8
9use antecedent_core::VariableId;
10use antecedent_graph::{Dag, DenseNodeId, NodeRef};
11
12use crate::error::ModelError;
13
14/// Plan for gathering parent values into an aligned buffer for one child node.
15#[derive(Clone, Debug)]
16pub struct ParentGatherPlan {
17    /// Child dense id.
18    pub child: DenseNodeId,
19    /// Parent dense ids in gather order.
20    pub parents: Arc<[DenseNodeId]>,
21}
22
23impl ParentGatherPlan {
24    /// Number of parents.
25    #[must_use]
26    pub fn n_parents(&self) -> usize {
27        self.parents.len()
28    }
29
30    /// Gather parent columns from a column-major value buffer into `out`
31    /// (`parent * n_rows + row`).
32    pub fn gather(&self, values: &[f64], n_rows: usize, out: &mut [f64]) {
33        debug_assert!(out.len() >= self.parents.len().saturating_mul(n_rows));
34        for (pi, &p) in self.parents.iter().enumerate() {
35            let src = p.as_usize() * n_rows;
36            let dst = pi * n_rows;
37            out[dst..dst + n_rows].copy_from_slice(&values[src..src + n_rows]);
38        }
39    }
40}
41
42/// Layout of sampled outputs.
43#[derive(Clone, Debug)]
44pub struct ModelOutputLayout {
45    /// Dense node order (same as compile topo order).
46    pub node_order: Arc<[DenseNodeId]>,
47    /// Variable id per dense node (static graphs).
48    pub variables: Arc<[VariableId]>,
49}
50
51/// Slow-path dynamic mechanism.
52///
53/// Built-ins stay on concrete [`MechanismSlot`] variants; user/Python wrappers
54/// implement this trait and live in [`MechanismSlot::Dynamic`].
55pub trait DynamicMechanism: Send + Sync {
56    /// Sample structural noise into `output` (length ≥ `n_rows`).
57    ///
58    /// # Errors
59    ///
60    /// Shape / unsupported.
61    fn sample_noise_column(
62        &self,
63        n_rows: usize,
64        rng: &mut antecedent_core::CausalRng,
65        output: &mut [f64],
66    ) -> Result<(), ModelError>;
67
68    /// Evaluate `x = f(parents, noise)` into `output`.
69    ///
70    /// # Errors
71    ///
72    /// Shape / unsupported.
73    fn evaluate_column(
74        &self,
75        parents: crate::batch::ParentBatch<'_>,
76        noise: &[f64],
77        output: &mut [f64],
78        workspace: &mut crate::batch::MechanismWorkspace,
79    ) -> Result<(), ModelError>;
80
81    /// Infer exogenous noise under an additive-noise assumption:
82    /// `noise = y − f(parents, 0)`.
83    ///
84    /// Override for non-additive mechanisms.
85    ///
86    /// # Errors
87    ///
88    /// Shape / evaluation failures.
89    fn infer_noise_column(
90        &self,
91        value: &[f64],
92        parents: crate::batch::ParentBatch<'_>,
93        output: &mut [f64],
94    ) -> Result<(), ModelError> {
95        let n = parents.n_rows;
96        if value.len() < n || output.len() < n {
97            return Err(ModelError::Shape {
98                message: "dynamic infer_noise buffers too short".into(),
99            });
100        }
101        let zeros = vec![0.0; n];
102        let mut mean = vec![0.0; n];
103        let mut ws = crate::batch::MechanismWorkspace::default();
104        self.evaluate_column(parents, &zeros, &mut mean, &mut ws)?;
105        for i in 0..n {
106            output[i] = value[i] - mean[i];
107        }
108        Ok(())
109    }
110
111    /// Log-density of observed values under additive `N(0,1)` residual noise.
112    ///
113    /// Override for non-Gaussian / non-additive mechanisms.
114    ///
115    /// # Errors
116    ///
117    /// Shape / evaluation failures.
118    fn log_prob_column(
119        &self,
120        values: &[f64],
121        parents: crate::batch::ParentBatch<'_>,
122        output: &mut [f64],
123    ) -> Result<(), ModelError> {
124        let n = parents.n_rows;
125        if values.len() < n || output.len() < n {
126            return Err(ModelError::Shape { message: "dynamic log_prob buffers too short".into() });
127        }
128        let mut resid = vec![0.0; n];
129        self.infer_noise_column(values, parents, &mut resid)?;
130        let log_norm = -0.5 * (2.0 * std::f64::consts::PI).ln();
131        for i in 0..n {
132            output[i] = log_norm - 0.5 * resid[i] * resid[i];
133        }
134        Ok(())
135    }
136}
137
138/// Mechanism slot filled by fitting / registry.
139#[derive(Clone, Default)]
140pub enum MechanismSlot {
141    /// Unassigned.
142    #[default]
143    Vacant,
144    /// Assigned family id pending fit.
145    Pending {
146        /// Family registry id.
147        family_id: Arc<str>,
148    },
149    /// Fitted linear Gaussian: intercept + parent coeffs + residual σ.
150    LinearGaussian {
151        /// Intercept.
152        intercept: f64,
153        /// Coefficients aligned with [`ParentGatherPlan::parents`].
154        coeffs: Arc<[f64]>,
155        /// Residual standard deviation.
156        sigma: f64,
157    },
158    /// Discrete categorical over finite support.
159    ///
160    /// Unconditional when `logit_coeffs` is `None` (use `probs`).
161    /// Parent-conditional when `logit_coeffs` is `Some`: softmax over
162    /// `support.len()` rows of length `1 + n_parents` (intercept + parent coeffs).
163    /// Coefficients are baseline-category multinomial-logit MLEs (reference category
164    /// index 0 is pinned to zero).
165    Discrete {
166        /// Support values.
167        support: Arc<[f64]>,
168        /// Unconditional probabilities (same length as support); ignored when
169        /// `logit_coeffs` is set.
170        probs: Arc<[f64]>,
171        /// Optional softmax logit coefficients, row-major `[k * (1 + p)]`.
172        logit_coeffs: Option<Arc<[f64]>>,
173    },
174    /// Constant mechanism.
175    Constant {
176        /// Fixed value.
177        value: f64,
178    },
179    /// Hierarchical linear Gaussian (partial-pooling / ridge toward prior mean 0).
180    HierarchicalLinear {
181        /// Intercept.
182        intercept: f64,
183        /// Parent coefficients (shrunk).
184        coeffs: Arc<[f64]>,
185        /// Residual standard deviation.
186        sigma: f64,
187        /// Shrinkage strength used at fit (`λ` on diagonal of `XtX`).
188        shrinkage: f64,
189    },
190    /// Bayesian VAR-style linear Gaussian on parent lags (single-equation).
191    Bvar {
192        /// Intercept.
193        intercept: f64,
194        /// Parent / lag coefficients.
195        coeffs: Arc<[f64]>,
196        /// Residual standard deviation.
197        sigma: f64,
198    },
199    /// Linear Gaussian state-space observation mechanism (1-D LGSSM).
200    ///
201    /// Latent: `x_t = a x_{t-1} + σ_proc ε`; observation: `y_t = x_t + σ_obs η`.
202    /// Parents unused at evaluate time (state evolves from shared noise).
203    LinearGaussianStateSpace {
204        /// AR coefficient.
205        a: f64,
206        /// Process noise std.
207        process_std: f64,
208        /// Observation noise std.
209        obs_std: f64,
210        /// Initial latent mean.
211        initial_mean: f64,
212    },
213    /// Gaussian-process mechanism (RBF dual form); requires `gaussian-process` feature to fit.
214    GaussianProcess {
215        /// Length scale.
216        length_scale: f64,
217        /// Signal variance.
218        variance: f64,
219        /// Observation noise std.
220        noise_std: f64,
221        /// Training parent rows, row-major `[n_train * n_parents]`.
222        x_train: Arc<[f64]>,
223        /// Training rows.
224        n_train: usize,
225        /// Parent arity.
226        n_parents: usize,
227        /// Dual coefficients `α = (K + σ²I)^{-1} y`.
228        alpha: Arc<[f64]>,
229    },
230    /// Explicit slow-path dynamic / user mechanism (not serializable).
231    Dynamic {
232        /// Stable label for diagnostics (e.g. variable name).
233        id: Arc<str>,
234        /// Object-safe mechanism implementation.
235        mechanism: Arc<dyn DynamicMechanism>,
236    },
237}
238
239impl std::fmt::Debug for MechanismSlot {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::Vacant => write!(f, "Vacant"),
243            Self::Pending { family_id } => {
244                f.debug_struct("Pending").field("family_id", family_id).finish()
245            }
246            Self::LinearGaussian { intercept, coeffs, sigma } => f
247                .debug_struct("LinearGaussian")
248                .field("intercept", intercept)
249                .field("coeffs", coeffs)
250                .field("sigma", sigma)
251                .finish(),
252            Self::Discrete { support, probs, logit_coeffs } => f
253                .debug_struct("Discrete")
254                .field("support", support)
255                .field("probs", probs)
256                .field("logit_coeffs", logit_coeffs)
257                .finish(),
258            Self::Constant { value } => f.debug_struct("Constant").field("value", value).finish(),
259            Self::HierarchicalLinear { intercept, coeffs, sigma, shrinkage } => f
260                .debug_struct("HierarchicalLinear")
261                .field("intercept", intercept)
262                .field("coeffs", coeffs)
263                .field("sigma", sigma)
264                .field("shrinkage", shrinkage)
265                .finish(),
266            Self::Bvar { intercept, coeffs, sigma } => f
267                .debug_struct("Bvar")
268                .field("intercept", intercept)
269                .field("coeffs", coeffs)
270                .field("sigma", sigma)
271                .finish(),
272            Self::LinearGaussianStateSpace { a, process_std, obs_std, initial_mean } => f
273                .debug_struct("LinearGaussianStateSpace")
274                .field("a", a)
275                .field("process_std", process_std)
276                .field("obs_std", obs_std)
277                .field("initial_mean", initial_mean)
278                .finish(),
279            Self::GaussianProcess {
280                length_scale, variance, noise_std, n_train, n_parents, ..
281            } => f
282                .debug_struct("GaussianProcess")
283                .field("length_scale", length_scale)
284                .field("variance", variance)
285                .field("noise_std", noise_std)
286                .field("n_train", n_train)
287                .field("n_parents", n_parents)
288                .finish(),
289            Self::Dynamic { id, .. } => f
290                .debug_struct("Dynamic")
291                .field("id", id)
292                .field("mechanism", &"<dyn DynamicMechanism>")
293                .finish(),
294        }
295    }
296}
297
298/// Per-node mechanism storage for a compiled model.
299#[derive(Clone, Debug)]
300pub struct CompiledMechanismStore {
301    /// Slot per dense node id (index = dense raw).
302    pub slots: Arc<[MechanismSlot]>,
303}
304
305impl CompiledMechanismStore {
306    /// Vacant slots for `n` nodes.
307    #[must_use]
308    pub fn vacant(n: usize) -> Self {
309        Self { slots: Arc::from(vec![MechanismSlot::Vacant; n]) }
310    }
311
312    /// Slot for dense id.
313    #[must_use]
314    pub fn get(&self, id: DenseNodeId) -> &MechanismSlot {
315        &self.slots[id.as_usize()]
316    }
317
318    /// Replace the slot at `id`, returning a new store (copy-on-write).
319    ///
320    /// # Errors
321    ///
322    /// Out-of-range dense id.
323    pub fn with_replaced(&self, id: DenseNodeId, slot: MechanismSlot) -> Result<Self, ModelError> {
324        let idx = id.as_usize();
325        if idx >= self.slots.len() {
326            return Err(ModelError::Shape { message: "mechanism slot index out of range".into() });
327        }
328        let mut slots = self.slots.as_ref().to_vec();
329        slots[idx] = slot;
330        Ok(Self { slots: Arc::from(slots) })
331    }
332}
333
334/// Immutable compiled causal model plan.
335#[derive(Clone, Debug)]
336pub struct CompiledCausalModel {
337    /// Topological dense node order.
338    pub node_order: Arc<[DenseNodeId]>,
339    /// Parent gather plans aligned with `node_order`.
340    pub parent_gathers: Arc<[ParentGatherPlan]>,
341    /// Mechanisms per dense node.
342    pub mechanisms: CompiledMechanismStore,
343    /// Output layout.
344    pub output_layout: ModelOutputLayout,
345    /// Source DAG (shared; never cloned per intervention).
346    pub graph: Arc<Dag>,
347}
348
349impl CompiledCausalModel {
350    /// Compile a static DAG into a topological execution plan.
351    ///
352    /// Mechanisms start vacant; assignment/fit fills them .
353    ///
354    /// # Errors
355    ///
356    /// Cyclic graph or non-static nodes.
357    pub fn compile(graph: Dag) -> Result<Self, ModelError> {
358        let order = graph.topological_order().ok_or_else(|| ModelError::NotDag {
359            message: "graph has no topological order".into(),
360        })?;
361        let n = graph.node_count();
362        let mut variables = Vec::with_capacity(n);
363        for i in 0..n {
364            let id = DenseNodeId::from_raw(i as u32);
365            match graph.nodes().get(i) {
366                Some(NodeRef::Static(v)) => variables.push(*v),
367                Some(other) => {
368                    return Err(ModelError::Unsupported {
369                        message: format!(
370                            "CompiledCausalModel requires Static nodes, got {other:?}"
371                        ),
372                    });
373                }
374                None => {
375                    return Err(ModelError::Shape { message: "node missing".into() });
376                }
377            }
378            let _ = id;
379        }
380        let mut gathers = Vec::with_capacity(order.len());
381        for &child in &order {
382            let parents = graph.parents(child).to_vec();
383            gathers.push(ParentGatherPlan { child, parents: Arc::from(parents) });
384        }
385        let node_order = Arc::from(order);
386        Ok(Self {
387            output_layout: ModelOutputLayout {
388                node_order: Arc::clone(&node_order),
389                variables: Arc::from(variables),
390            },
391            node_order,
392            parent_gathers: Arc::from(gathers),
393            mechanisms: CompiledMechanismStore::vacant(n),
394            graph: Arc::new(graph),
395        })
396    }
397
398    /// Number of nodes.
399    #[must_use]
400    pub fn n_nodes(&self) -> usize {
401        self.graph.node_count()
402    }
403
404    /// Dense id for a variable, if present.
405    #[must_use]
406    pub fn dense_of(&self, var: VariableId) -> Option<DenseNodeId> {
407        self.output_layout
408            .variables
409            .iter()
410            .position(|v| *v == var)
411            .map(|i| DenseNodeId::from_raw(i as u32))
412    }
413
414    /// Replace mechanism store (fit / assignment).
415    #[must_use]
416    pub fn with_mechanisms(mut self, mechanisms: CompiledMechanismStore) -> Self {
417        self.mechanisms = mechanisms;
418        self
419    }
420
421    /// Gather plan for a child dense id.
422    #[must_use]
423    pub fn gather_for(&self, child: DenseNodeId) -> Option<&ParentGatherPlan> {
424        self.parent_gathers.iter().find(|g| g.child == child)
425    }
426}
427
428/// Probabilistic causal model (PCM): observational mechanisms without required invertibility.
429#[derive(Clone, Debug)]
430pub struct ProbabilisticCausalModel {
431    /// Compiled plan.
432    pub compiled: CompiledCausalModel,
433}
434
435impl ProbabilisticCausalModel {
436    /// Wrap a compiled plan.
437    #[must_use]
438    pub fn new(compiled: CompiledCausalModel) -> Self {
439        Self { compiled }
440    }
441}
442
443/// Structural causal model (SCM): additive / structural assignments with noise.
444#[derive(Clone, Debug)]
445pub struct StructuralCausalModel {
446    /// Compiled plan.
447    pub compiled: CompiledCausalModel,
448}
449
450impl StructuralCausalModel {
451    /// Wrap.
452    #[must_use]
453    pub fn new(compiled: CompiledCausalModel) -> Self {
454        Self { compiled }
455    }
456}
457
458/// Invertible SCM supporting abduction (noise inference).
459#[derive(Clone, Debug)]
460pub struct InvertibleStructuralCausalModel {
461    /// Compiled plan.
462    pub compiled: CompiledCausalModel,
463}
464
465impl InvertibleStructuralCausalModel {
466    /// Wrap; caller ensures mechanisms are invertible families.
467    #[must_use]
468    pub fn new(compiled: CompiledCausalModel) -> Self {
469        Self { compiled }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use antecedent_core::VariableId;
477    use antecedent_graph::Dag;
478
479    #[test]
480    fn compile_chain_topo_order() {
481        let mut g = Dag::with_variables(3);
482        let a = DenseNodeId::from_raw(0);
483        let b = DenseNodeId::from_raw(1);
484        let c = DenseNodeId::from_raw(2);
485        g.insert_directed(a, b).unwrap();
486        g.insert_directed(b, c).unwrap();
487        let plan = CompiledCausalModel::compile(g).unwrap();
488        assert_eq!(plan.n_nodes(), 3);
489        assert_eq!(plan.node_order.as_ref(), &[a, b, c]);
490        assert_eq!(plan.gather_for(c).unwrap().n_parents(), 1);
491        assert_eq!(plan.dense_of(VariableId::from_raw(1)), Some(b));
492    }
493}