Skip to main content

antecedent_model/
evaluate.rs

1//! Model evaluation and falsification.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(
6    clippy::cast_precision_loss,
7    clippy::cast_possible_truncation,
8    clippy::cast_sign_loss,
9    clippy::needless_range_loop
10)]
11
12use std::borrow::Cow;
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use antecedent_core::{CausalRng, ExecutionContext, VariableId};
17use antecedent_data::{TableView, TabularData};
18use antecedent_graph::DenseNodeId;
19use antecedent_stats::ci::{
20    CiBatchRequest, CiQuery, CiWorkspace, ConditionalIndependenceTest, ConfidenceMethod,
21    PartialCorrelation, SignificanceMethod,
22};
23
24use crate::batch::{MechanismWorkspace, ParentBatch};
25use crate::compile::{CompiledCausalModel, MechanismSlot};
26use crate::error::ModelError;
27use crate::mechanism::{infer_noise_column, log_prob_column};
28
29/// Model falsification / evaluation report.
30#[derive(Clone, Debug)]
31pub struct ModelEvaluationReport {
32    /// In-sample mean log-likelihood (higher better). No holdout split is performed.
33    pub in_sample_loglik: f64,
34    /// Mean absolute residual for invertible nodes.
35    pub mean_abs_residual: f64,
36    /// Residual independence p-values vs non-parent covariates (empty if none).
37    pub residual_independence_p: Arc<[f64]>,
38    /// Local Markov check p-values (node ⊥ non-descendants | parents).
39    pub local_markov_p: Arc<[f64]>,
40    /// Permutation baseline mean log-lik under shuffled outcomes.
41    pub permutation_loglik: f64,
42    /// Whether the model is considered falsified under alpha.
43    pub falsified: bool,
44    /// Alpha used for independence tests.
45    pub alpha: f64,
46    /// Notes.
47    pub notes: Vec<Arc<str>>,
48}
49
50/// Evaluate a fitted model on data.
51#[derive(Clone, Debug)]
52pub struct ModelEvaluator {
53    /// Significance level for CI tests.
54    pub alpha: f64,
55    /// Permutation replicates for baseline.
56    pub n_permutations: usize,
57    /// RNG seed for permutations.
58    pub seed: u64,
59}
60
61impl Default for ModelEvaluator {
62    fn default() -> Self {
63        Self { alpha: 0.05, n_permutations: 20, seed: 0 }
64    }
65}
66
67impl ModelEvaluator {
68    /// Run evaluation / falsification suite.
69    ///
70    /// # Errors
71    ///
72    /// Data / mechanism failures.
73    pub fn evaluate(
74        &self,
75        model: &CompiledCausalModel,
76        data: &TabularData,
77        ctx: &ExecutionContext,
78    ) -> Result<ModelEvaluationReport, ModelError> {
79        let n = data.row_count();
80        if n == 0 {
81            return Err(ModelError::Shape { message: "empty data for evaluation".into() });
82        }
83        let mut notes = Vec::new();
84        let in_sample_loglik = mean_loglik(model, data)?;
85        let (mean_abs_residual, residuals_by_node) = residual_summary(model, data)?;
86        let residual_independence_p =
87            residual_independence_tests(model, data, &residuals_by_node, self.alpha, ctx)?;
88        let local_markov_p = local_markov_tests(model, data, self.alpha, ctx)?;
89        // Prefer the caller's execution seed when the evaluator still has the default seed.
90        let perm_seed = if self.seed == 0 { ctx.rng.master_seed() } else { self.seed };
91        let permutation_loglik = permutation_baseline(model, data, self.n_permutations, perm_seed)?;
92
93        let mut falsified = false;
94        for &p in &residual_independence_p {
95            if p < self.alpha {
96                falsified = true;
97                notes.push(Arc::from("residual independence rejected at alpha"));
98                break;
99            }
100        }
101        for &p in &local_markov_p {
102            if p < self.alpha {
103                falsified = true;
104                notes.push(Arc::from("local Markov condition rejected at alpha"));
105                break;
106            }
107        }
108        if in_sample_loglik + 1.0 < permutation_loglik {
109            // Model worse than noise baseline by a wide margin.
110            notes.push(Arc::from("in-sample loglik near or below permutation baseline"));
111        }
112
113        Ok(ModelEvaluationReport {
114            in_sample_loglik,
115            mean_abs_residual,
116            residual_independence_p: Arc::from(residual_independence_p),
117            local_markov_p: Arc::from(local_markov_p),
118            permutation_loglik,
119            falsified,
120            alpha: self.alpha,
121            notes,
122        })
123    }
124}
125
126fn mean_loglik(model: &CompiledCausalModel, data: &TabularData) -> Result<f64, ModelError> {
127    let n = data.row_count();
128    let mut total = 0.0;
129    let mut count = 0usize;
130    for gather in model.parent_gathers.iter() {
131        let node = gather.child;
132        let var = model.output_layout.variables[node.as_usize()];
133        let y = data.float64_cow(var).map_err(ModelError::from)?;
134        let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
135        for (pi, &p) in gather.parents.iter().enumerate() {
136            let pv = model.output_layout.variables[p.as_usize()];
137            let col = data.float64_cow(pv).map_err(ModelError::from)?;
138            parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
139        }
140        let parents = ParentBatch {
141            n_rows: n,
142            n_parents: gather.n_parents(),
143            values: &parent_mat[..gather.n_parents().saturating_mul(n)],
144        };
145        let mut lp = vec![0.0; n];
146        log_prob_column(model.mechanisms.get(node), &y, parents, &mut lp)?;
147        for v in lp {
148            if v.is_finite() {
149                total += v;
150                count += 1;
151            }
152        }
153    }
154    Ok(total / count.max(1) as f64)
155}
156
157type ResidualByNode = Vec<Option<Vec<f64>>>;
158
159fn residual_summary(
160    model: &CompiledCausalModel,
161    data: &TabularData,
162) -> Result<(f64, ResidualByNode), ModelError> {
163    let n = data.row_count();
164    let mut residuals_by_node = vec![None; model.n_nodes()];
165    let mut abs_sum = 0.0;
166    let mut abs_count = 0usize;
167    let mut ws = MechanismWorkspace::default();
168    for gather in model.parent_gathers.iter() {
169        let node = gather.child;
170        let slot = model.mechanisms.get(node);
171        if !matches!(
172            slot,
173            MechanismSlot::LinearGaussian { .. }
174                | MechanismSlot::HierarchicalLinear { .. }
175                | MechanismSlot::Bvar { .. }
176        ) {
177            continue;
178        }
179        let var = model.output_layout.variables[node.as_usize()];
180        let y = data.float64_cow(var).map_err(ModelError::from)?;
181        ws.prepare(n, gather.n_parents().max(1));
182        let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
183        for (pi, &p) in gather.parents.iter().enumerate() {
184            let pv = model.output_layout.variables[p.as_usize()];
185            let col = data.float64_cow(pv).map_err(ModelError::from)?;
186            parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
187        }
188        let parents = ParentBatch {
189            n_rows: n,
190            n_parents: gather.n_parents(),
191            values: &parent_mat[..gather.n_parents().saturating_mul(n)],
192        };
193        let mut noise = vec![0.0; n];
194        infer_noise_column(slot, &y, parents, &mut noise)?;
195        // A parentless node has no prediction to be residual *from*: its recovered noise is
196        // just its own deviation from its marginal mean, which is the variable's inherent
197        // spread rather than any misfit. Averaging that into `mean_abs_residual` would make
198        // the metric report a large "residual" for a perfectly specified model.
199        //
200        // This only became reachable once roots stopped being fit as `Constant` (which
201        // `residual_summary` skips outright). Roots still contribute their noise column to
202        // `residuals_by_node`, because the residual-independence and local-Markov checks
203        // downstream genuinely want a root's exogenous noise.
204        if gather.n_parents() > 0 {
205            for &e in &noise {
206                abs_sum += e.abs();
207                abs_count += 1;
208            }
209        }
210        residuals_by_node[node.as_usize()] = Some(noise);
211    }
212    Ok((abs_sum / abs_count.max(1) as f64, residuals_by_node))
213}
214
215fn residual_independence_tests(
216    model: &CompiledCausalModel,
217    data: &TabularData,
218    residuals: &[Option<Vec<f64>>],
219    _alpha: f64,
220    ctx: &ExecutionContext,
221) -> Result<Vec<f64>, ModelError> {
222    let test = PartialCorrelation::new();
223    let mut ws = CiWorkspace::default();
224    let n_nodes = model.n_nodes();
225    let children = child_adjacency(model);
226
227    let mut obs_store: Vec<Cow<'_, [f64]>> = Vec::with_capacity(n_nodes);
228    for i in 0..n_nodes {
229        let var = model.output_layout.variables[i];
230        obs_store.push(data.float64_cow(var).map_err(ModelError::from)?);
231    }
232    let mut cols: Vec<&[f64]> = obs_store.iter().map(std::convert::AsRef::as_ref).collect();
233    let mut resid_col = vec![None; n_nodes];
234    for (i, r) in residuals.iter().enumerate() {
235        if let Some(v) = r {
236            resid_col[i] = Some(cols.len());
237            cols.push(v.as_slice());
238        }
239    }
240
241    let mut queries = Vec::new();
242    for (node_i, resid_opt) in residuals.iter().enumerate() {
243        let Some(_) = resid_opt else { continue };
244        let Some(rx) = resid_col[node_i] else { continue };
245        let gather = model.gather_for(DenseNodeId::from_raw(node_i as u32)).unwrap();
246        let parent_set: HashSet<usize> = gather.parents.iter().map(|p| p.as_usize()).collect();
247        let descendants = descendants_of(&children, node_i);
248        for other in 0..n_nodes {
249            // ANM residuals are independent of non-descendants (parents already skipped).
250            // Dependence on descendants is expected and must not falsify a correct model.
251            if other == node_i || parent_set.contains(&other) || descendants.contains(&other) {
252                continue;
253            }
254            queries.push(CiQuery { x: rx, y: other, z_start: 0, z_len: 0 });
255        }
256    }
257    ci_pvalues(&test, &cols, &queries, &[], &mut ws, ctx)
258}
259
260fn child_adjacency(model: &CompiledCausalModel) -> Vec<Vec<usize>> {
261    let mut children = vec![Vec::new(); model.n_nodes()];
262    for gather in model.parent_gathers.iter() {
263        let child = gather.child.as_usize();
264        for &p in gather.parents.iter() {
265            children[p.as_usize()].push(child);
266        }
267    }
268    children
269}
270
271fn descendants_of(children: &[Vec<usize>], node: usize) -> HashSet<usize> {
272    let mut out = HashSet::new();
273    let mut stack = children.get(node).cloned().unwrap_or_default();
274    while let Some(v) = stack.pop() {
275        if out.insert(v) {
276            stack.extend(children.get(v).into_iter().flatten().copied());
277        }
278    }
279    out
280}
281
282fn local_markov_tests(
283    model: &CompiledCausalModel,
284    data: &TabularData,
285    _alpha: f64,
286    ctx: &ExecutionContext,
287) -> Result<Vec<f64>, ModelError> {
288    let test = PartialCorrelation::new();
289    let mut ws = CiWorkspace::default();
290    let n_nodes = model.n_nodes();
291    let mut storage: Vec<Cow<'_, [f64]>> = Vec::with_capacity(n_nodes);
292    for i in 0..n_nodes {
293        let var = model.output_layout.variables[i];
294        storage.push(data.float64_cow(var).map_err(ModelError::from)?);
295    }
296    let cols: Vec<&[f64]> = storage.iter().map(std::convert::AsRef::as_ref).collect();
297
298    let mut queries = Vec::new();
299    let mut z_flat = Vec::new();
300    for gather in model.parent_gathers.iter() {
301        let node = gather.child;
302        let parent_ids: Vec<usize> = gather.parents.iter().map(|p| p.as_usize()).collect();
303        let others = local_markov_others(model, node, &parent_ids);
304        if others.is_empty() {
305            continue;
306        }
307        let z_start = z_flat.len();
308        z_flat.extend_from_slice(&parent_ids);
309        let z_len = parent_ids.len();
310        for other in others {
311            queries.push(CiQuery { x: node.as_usize(), y: other, z_start, z_len });
312        }
313    }
314    ci_pvalues(&test, &cols, &queries, &z_flat, &mut ws, ctx)
315}
316
317fn ci_pvalues(
318    test: &PartialCorrelation,
319    columns: &[&[f64]],
320    queries: &[CiQuery],
321    z_flat: &[usize],
322    ws: &mut CiWorkspace,
323    ctx: &ExecutionContext,
324) -> Result<Vec<f64>, ModelError> {
325    if queries.is_empty() {
326        return Ok(Vec::new());
327    }
328    let req = CiBatchRequest {
329        columns,
330        queries,
331        z_flat,
332        significance: SignificanceMethod::Analytic,
333        confidence: ConfidenceMethod::default(),
334    };
335    let out = test.test_batch_adhoc(&req, ws, ctx)?;
336    Ok(out.results.into_iter().map(|r| r.p_value).collect())
337}
338
339/// Dense ids of the local-Markov comparison set for `node`: nodes strictly
340/// earlier in topological order that are not parents of `node`.
341///
342/// The historical loop compared topo-order *positions* against dense-id parent
343/// sets and indexed the variable table by position — correct only when the
344/// topological order happens to be the identity permutation of dense ids; on
345/// any other order it tested the wrong variable pairs.
346fn local_markov_others(
347    model: &CompiledCausalModel,
348    node: antecedent_graph::DenseNodeId,
349    parent_ids: &[usize],
350) -> Vec<usize> {
351    let mut out = Vec::new();
352    for &other in model.node_order.iter() {
353        if other == node {
354            break; // strictly earlier in topo order
355        }
356        let od = other.as_usize();
357        if !parent_ids.contains(&od) {
358            out.push(od);
359        }
360    }
361    out
362}
363
364fn permutation_baseline(
365    model: &CompiledCausalModel,
366    data: &TabularData,
367    n_perm: usize,
368    seed: u64,
369) -> Result<f64, ModelError> {
370    if n_perm == 0 {
371        return Ok(f64::NEG_INFINITY);
372    }
373    let mut rng = CausalRng::from_seed(seed);
374    // Permute a leaf outcome column and recompute mean loglik under original mechanisms
375    // as a crude noise baseline (same X, shuffled Y for last node).
376    let last = *model
377        .node_order
378        .last()
379        .ok_or_else(|| ModelError::Shape { message: "empty model".into() })?;
380    let var = model.output_layout.variables[last.as_usize()];
381    let mut y = data.float64_values(var).map_err(ModelError::from)?;
382    let mut acc = 0.0;
383    // The parent gather is invariant across permutations (only y is shuffled),
384    // so build the parent matrix once instead of re-copying it per replicate.
385    let gather = model.gather_for(last).unwrap();
386    let n = y.len();
387    let mut parent_mat = vec![0.0; n * gather.n_parents().max(1)];
388    for (pi, &p) in gather.parents.iter().enumerate() {
389        let pv = model.output_layout.variables[p.as_usize()];
390        let col = data.float64_cow(pv).map_err(ModelError::from)?;
391        parent_mat[pi * n..(pi + 1) * n].copy_from_slice(&col[..n]);
392    }
393    let mut lp = vec![0.0; n];
394    for _ in 0..n_perm {
395        // Fisher–Yates (Fisher & Yates 1938; Durstenfeld 1964)
396        for i in (1..y.len()).rev() {
397            let j = (rng.next_f64() * (i as f64 + 1.0)) as usize;
398            y.swap(i, j.min(i));
399        }
400        // Score only the last node under shuffled y.
401        let parents = ParentBatch {
402            n_rows: n,
403            n_parents: gather.n_parents(),
404            values: &parent_mat[..gather.n_parents().saturating_mul(n)],
405        };
406        log_prob_column(model.mechanisms.get(last), &y, parents, &mut lp)?;
407        acc += lp.iter().filter(|v| v.is_finite()).sum::<f64>() / n.max(1) as f64;
408    }
409    Ok(acc / n_perm as f64)
410}
411
412/// Mechanism predictive check: compare observed mean to predictive mean under sampling.
413#[derive(Clone, Debug)]
414pub struct MechanismPredictiveCheck {
415    /// Sims.
416    pub n_sims: usize,
417    /// Seed.
418    pub seed: u64,
419}
420
421impl Default for MechanismPredictiveCheck {
422    fn default() -> Self {
423        Self { n_sims: 50, seed: 1 }
424    }
425}
426
427impl MechanismPredictiveCheck {
428    /// Check one variable's mean.
429    ///
430    /// # Errors
431    ///
432    /// Sampling failures.
433    pub fn check_mean(
434        &self,
435        model: &CompiledCausalModel,
436        data: &TabularData,
437        var: VariableId,
438    ) -> Result<(f64, f64, f64), ModelError> {
439        use crate::sample::sample_observational;
440        use antecedent_core::ExecutionContext;
441
442        let observed = data.float64_values(var).map_err(ModelError::from)?;
443        let obs_mean = observed.iter().sum::<f64>() / observed.len().max(1) as f64;
444        let dense = model
445            .dense_of(var)
446            .ok_or_else(|| ModelError::Shape { message: "variable not in model".into() })?;
447        let mut rng = CausalRng::from_seed(self.seed);
448        let mut ws = MechanismWorkspace::default();
449        let ctx = ExecutionContext::for_tests(1);
450        let mut means = Vec::with_capacity(self.n_sims);
451        for _ in 0..self.n_sims {
452            let batch = sample_observational(model, observed.len(), &mut rng, &mut ws, &ctx)?;
453            let col = batch.column(dense.as_usize())?;
454            means.push(col.iter().sum::<f64>() / col.len().max(1) as f64);
455        }
456        let pred_mean = means.iter().sum::<f64>() / means.len().max(1) as f64;
457        // Finite-sample MC p-value: (1 + count) / (1 + n) bounds each tail below by
458        // 1/(n+1), so the two-sided p-value can never collapse to exactly 0 even when the
459        // observation falls entirely outside the simulated range.
460        let n_sims = means.len() as f64;
461        let below = means.iter().filter(|&&m| m <= obs_mean).count() as f64;
462        let above = means.iter().filter(|&&m| m >= obs_mean).count() as f64;
463        let p_lower = (1.0 + below) / (1.0 + n_sims);
464        let p_upper = (1.0 + above) / (1.0 + n_sims);
465        let p = (2.0 * p_lower.min(p_upper)).min(1.0);
466        Ok((obs_mean, pred_mean, p))
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::registry::{MechanismFamily, MechanismRegistry, SelectionPolicy};
474    use antecedent_core::{
475        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType,
476    };
477    use antecedent_data::column::{Float64Column, ValidityBitmap};
478    use antecedent_data::{OwnedColumn, OwnedColumnarStorage};
479    use antecedent_graph::Dag;
480
481    #[test]
482    fn local_markov_pairs_use_dense_ids_not_topo_positions() {
483        // Graph 1→0, 0→2: topological order [1, 0, 2] is not the identity
484        // permutation of dense ids. The historical position-indexed loop
485        // paired node 0 with variables[0] — itself — because position 0 in
486        // topo order held node 1, but the variable table is dense-id-indexed.
487        let mut g = Dag::with_variables(3);
488        g.insert_directed(DenseNodeId::from_raw(1), DenseNodeId::from_raw(0)).unwrap();
489        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(2)).unwrap();
490        let compiled = CompiledCausalModel::compile(g).unwrap();
491        assert_eq!(
492            compiled.node_order.iter().map(|d| d.as_usize()).collect::<Vec<_>>(),
493            vec![1, 0, 2],
494            "fixture requires a non-identity topological order"
495        );
496        // Node 0 (parent {1}): the only earlier topo node is its parent — no
497        // comparison pairs. The buggy loop produced one (a self-pair).
498        assert!(local_markov_others(&compiled, DenseNodeId::from_raw(0), &[1]).is_empty());
499        // Node 2 (parent {0}): earlier topo nodes {1, 0} minus parent → {1}.
500        assert_eq!(local_markov_others(&compiled, DenseNodeId::from_raw(2), &[0]), vec![1]);
501        // Node 1 (root, first in topo order): nothing earlier.
502        assert!(local_markov_others(&compiled, DenseNodeId::from_raw(1), &[]).is_empty());
503    }
504
505    #[test]
506    fn evaluation_runs_on_linear_scm() {
507        let n = 40usize;
508        let mut b = CausalSchemaBuilder::new();
509        b.add_variable(
510            "x",
511            ValueType::Continuous,
512            SmallRoleSet::from_hint(RoleHint::Context),
513            None,
514            None,
515            MeasurementSpec::default(),
516        )
517        .unwrap();
518        b.add_variable(
519            "y",
520            ValueType::Continuous,
521            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
522            None,
523            None,
524            MeasurementSpec::default(),
525        )
526        .unwrap();
527        let schema = b.build().unwrap();
528        let xv: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
529        let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x).collect();
530        let validity = ValidityBitmap::all_valid(n);
531        let cols = vec![
532            OwnedColumn::Float64(
533                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
534                    .unwrap(),
535            ),
536            OwnedColumn::Float64(
537                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
538            ),
539        ];
540        let data =
541            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
542        let mut g = Dag::with_variables(2);
543        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
544        let compiled = CompiledCausalModel::compile(g).unwrap();
545        let (store, _) = MechanismRegistry::standard()
546            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
547            .unwrap();
548        let model = compiled.with_mechanisms(store);
549        let rep = ModelEvaluator::default()
550            .evaluate(&model, &data, &ExecutionContext::for_tests(1))
551            .unwrap();
552        assert!(rep.in_sample_loglik.is_finite());
553        assert!(rep.mean_abs_residual < 1e-6, "resid={}", rep.mean_abs_residual);
554    }
555
556    /// MM-A2: `evaluate`'s in-sample log-likelihood must score an LGSSM node against the
557    /// Kalman one-step predictive mean, not `N(0, obs_std²)` of the raw value. Data is a
558    /// persistent near-random-walk series centered far from zero (mean ≈ 5), so the two
559    /// scorings diverge sharply: the old raw-value scoring is dominated by `z = y/obs_std`
560    /// with `y ≈ 5` and small `obs_std`, giving a hugely negative log-lik regardless of fit
561    /// quality, while the fixed scoring reflects the (small) one-step predictive residual.
562    #[test]
563    #[allow(clippy::many_single_char_names)]
564    fn evaluate_lgssm_model_loglik_uses_predictive_mean_not_raw_value() {
565        use antecedent_core::CausalRng;
566        use antecedent_kernels::standard_normal;
567
568        let n = 60usize;
569        let a = 0.95_f64;
570        let process_std = 0.05_f64;
571        let obs_std = 0.05_f64;
572        let initial_mean = 5.0_f64;
573        let mut rng = CausalRng::from_seed(3);
574        let mut yv = vec![0.0; n];
575        let mut x = initial_mean;
576        for i in 0..n {
577            x = if i == 0 {
578                initial_mean + process_std * standard_normal(&mut rng)
579            } else {
580                a * x + process_std * standard_normal(&mut rng)
581            };
582            yv[i] = x + obs_std * standard_normal(&mut rng);
583        }
584
585        let mut b = CausalSchemaBuilder::new();
586        b.add_variable(
587            "y",
588            ValueType::Continuous,
589            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
590            None,
591            None,
592            MeasurementSpec::default(),
593        )
594        .unwrap();
595        let schema = b.build().unwrap();
596        let validity = ValidityBitmap::all_valid(n);
597        let cols = vec![OwnedColumn::Float64(
598            Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity).unwrap(),
599        )];
600        let data =
601            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
602        let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
603        let (store, _) = MechanismRegistry::with_bayesian_families()
604            .assign_and_fit(
605                &compiled,
606                &data,
607                SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
608            )
609            .unwrap();
610        let model = compiled.with_mechanisms(store);
611        let rep = ModelEvaluator::default()
612            .evaluate(&model, &data, &ExecutionContext::for_tests(1))
613            .unwrap();
614        assert!(rep.in_sample_loglik.is_finite());
615
616        let MechanismSlot::LinearGaussianStateSpace { obs_std: fitted_obs_std, .. } =
617            model.mechanisms.get(DenseNodeId::from_raw(0))
618        else {
619            panic!("expected LGSSM slot");
620        };
621        let y = data.float64_values(VariableId::from_raw(0)).unwrap();
622        let inv_s = 1.0 / fitted_obs_std;
623        let log_norm = -0.5 * (2.0 * std::f64::consts::PI).ln() - fitted_obs_std.ln();
624        let raw_value_loglik: f64 = y
625            .iter()
626            .map(|yi| {
627                let z = yi * inv_s;
628                log_norm - 0.5 * z * z
629            })
630            .sum::<f64>()
631            / y.len() as f64;
632        assert!(
633            rep.in_sample_loglik > raw_value_loglik + 10.0,
634            "fixed loglik={} did not clearly beat raw-value (pre-fix) loglik={}",
635            rep.in_sample_loglik,
636            raw_value_loglik
637        );
638    }
639
640    /// MM-A4: a Monte Carlo predictive p-value must never be exactly 0, even when the
641    /// observation falls entirely outside the simulated range (exactly the case this check
642    /// exists to flag). Fits a tight `LinearGaussian` around 0, then checks an observation set
643    /// pinned at 1000.0 — far outside anything the fitted mechanism could plausibly sample.
644    #[test]
645    fn mechanism_predictive_check_p_value_never_zero_for_extreme_outlier() {
646        let n = 30usize;
647        let mut b = CausalSchemaBuilder::new();
648        b.add_variable(
649            "y",
650            ValueType::Continuous,
651            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
652            None,
653            None,
654            MeasurementSpec::default(),
655        )
656        .unwrap();
657        let schema = b.build().unwrap();
658        let yv: Vec<f64> = (0..n).map(|i| 0.01 * (i as f64 - n as f64 / 2.0)).collect();
659        let validity = ValidityBitmap::all_valid(n);
660        let cols = vec![OwnedColumn::Float64(
661            Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity.clone()).unwrap(),
662        )];
663        let storage = OwnedColumnarStorage::try_new(schema.clone(), cols, None, None).unwrap();
664        let data = TabularData::new(storage);
665        let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
666        let (store, _) = MechanismRegistry::standard()
667            .assign_and_fit(
668                &compiled,
669                &data,
670                SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussian),
671            )
672            .unwrap();
673        let model = compiled.with_mechanisms(store);
674
675        let outlier: Vec<f64> = vec![1000.0; n];
676        let outlier_cols = vec![OwnedColumn::Float64(
677            Float64Column::new(VariableId::from_raw(0), Arc::from(outlier), validity).unwrap(),
678        )];
679        let outlier_storage =
680            OwnedColumnarStorage::try_new(schema, outlier_cols, None, None).unwrap();
681        let outlier_data = TabularData::new(outlier_storage);
682
683        let check = MechanismPredictiveCheck::default();
684        let (obs_mean, _pred_mean, p) =
685            check.check_mean(&model, &outlier_data, VariableId::from_raw(0)).unwrap();
686        assert!((obs_mean - 1000.0).abs() < 1e-9);
687        assert!(p > 0.0, "p must never be exactly 0 (finite-sample bound 2/(n_sims+1)); got {p}");
688    }
689}