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    /// Variable → dense-id map backing [`CompiledCausalModel::dense_of`]
348    /// (first occurrence wins, matching the historical linear scan).
349    var_to_dense: Arc<std::collections::HashMap<VariableId, DenseNodeId>>,
350    /// Dense id → index into `parent_gathers`, backing
351    /// [`CompiledCausalModel::gather_for`] without a per-call linear scan.
352    gather_slot: Arc<[u32]>,
353}
354
355impl CompiledCausalModel {
356    /// Compile a static DAG into a topological execution plan.
357    ///
358    /// Mechanisms start vacant; assignment/fit fills them .
359    ///
360    /// # Errors
361    ///
362    /// Cyclic graph or non-static nodes.
363    pub fn compile(graph: Dag) -> Result<Self, ModelError> {
364        let order = graph.topological_order().ok_or_else(|| ModelError::NotDag {
365            message: "graph has no topological order".into(),
366        })?;
367        let n = graph.node_count();
368        let mut variables = Vec::with_capacity(n);
369        for i in 0..n {
370            let id = DenseNodeId::from_raw(i as u32);
371            match graph.nodes().get(i) {
372                Some(NodeRef::Static(v)) => variables.push(*v),
373                Some(other) => {
374                    return Err(ModelError::Unsupported {
375                        message: format!(
376                            "CompiledCausalModel requires Static nodes, got {other:?}"
377                        ),
378                    });
379                }
380                None => {
381                    return Err(ModelError::Shape { message: "node missing".into() });
382                }
383            }
384            let _ = id;
385        }
386        let mut gathers = Vec::with_capacity(order.len());
387        let mut gather_slot = vec![0u32; n];
388        for (gi, &child) in order.iter().enumerate() {
389            let parents = graph.parents(child).to_vec();
390            gather_slot[child.as_usize()] = gi as u32;
391            gathers.push(ParentGatherPlan { child, parents: Arc::from(parents) });
392        }
393        let mut var_to_dense = std::collections::HashMap::with_capacity(n);
394        for (i, &v) in variables.iter().enumerate() {
395            // First occurrence wins, like the linear `position` scan it replaces.
396            var_to_dense.entry(v).or_insert_with(|| DenseNodeId::from_raw(i as u32));
397        }
398        let node_order = Arc::from(order);
399        Ok(Self {
400            output_layout: ModelOutputLayout {
401                node_order: Arc::clone(&node_order),
402                variables: Arc::from(variables),
403            },
404            node_order,
405            parent_gathers: Arc::from(gathers),
406            mechanisms: CompiledMechanismStore::vacant(n),
407            graph: Arc::new(graph),
408            var_to_dense: Arc::new(var_to_dense),
409            gather_slot: Arc::from(gather_slot),
410        })
411    }
412
413    /// Number of nodes.
414    #[must_use]
415    pub fn n_nodes(&self) -> usize {
416        self.graph.node_count()
417    }
418
419    /// Dense id for a variable, if present (O(1) via the compile-time map).
420    #[must_use]
421    pub fn dense_of(&self, var: VariableId) -> Option<DenseNodeId> {
422        self.var_to_dense.get(&var).copied()
423    }
424
425    /// Replace mechanism store (fit / assignment).
426    #[must_use]
427    pub fn with_mechanisms(mut self, mechanisms: CompiledMechanismStore) -> Self {
428        self.mechanisms = mechanisms;
429        self
430    }
431
432    /// Gather plan for a child dense id (O(1) via the compile-time index).
433    #[must_use]
434    pub fn gather_for(&self, child: DenseNodeId) -> Option<&ParentGatherPlan> {
435        self.gather_slot.get(child.as_usize()).map(|&gi| &self.parent_gathers[gi as usize])
436    }
437}
438
439/// Probabilistic causal model (PCM): observational mechanisms without required invertibility.
440#[derive(Clone, Debug)]
441pub struct ProbabilisticCausalModel {
442    /// Compiled plan.
443    pub compiled: CompiledCausalModel,
444}
445
446impl ProbabilisticCausalModel {
447    /// Wrap a compiled plan.
448    #[must_use]
449    pub fn new(compiled: CompiledCausalModel) -> Self {
450        Self { compiled }
451    }
452}
453
454/// Structural causal model (SCM): additive / structural assignments with noise.
455#[derive(Clone, Debug)]
456pub struct StructuralCausalModel {
457    /// Compiled plan.
458    pub compiled: CompiledCausalModel,
459}
460
461impl StructuralCausalModel {
462    /// Wrap.
463    #[must_use]
464    pub fn new(compiled: CompiledCausalModel) -> Self {
465        Self { compiled }
466    }
467}
468
469/// Invertible SCM supporting abduction (noise inference).
470#[derive(Clone, Debug)]
471pub struct InvertibleStructuralCausalModel {
472    /// Compiled plan.
473    pub compiled: CompiledCausalModel,
474}
475
476impl InvertibleStructuralCausalModel {
477    /// Wrap; caller ensures mechanisms are invertible families.
478    #[must_use]
479    pub fn new(compiled: CompiledCausalModel) -> Self {
480        Self { compiled }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use antecedent_core::VariableId;
488    use antecedent_graph::Dag;
489
490    #[test]
491    fn compile_chain_topo_order() {
492        let mut g = Dag::with_variables(3);
493        let a = DenseNodeId::from_raw(0);
494        let b = DenseNodeId::from_raw(1);
495        let c = DenseNodeId::from_raw(2);
496        g.insert_directed(a, b).unwrap();
497        g.insert_directed(b, c).unwrap();
498        let plan = CompiledCausalModel::compile(g).unwrap();
499        assert_eq!(plan.n_nodes(), 3);
500        assert_eq!(plan.node_order.as_ref(), &[a, b, c]);
501        assert_eq!(plan.gather_for(c).unwrap().n_parents(), 1);
502        assert_eq!(plan.dense_of(VariableId::from_raw(1)), Some(b));
503    }
504}