Skip to main content

antecedent_validate/stability/
null_calibration.rs

1//! Synthetic-null discovery calibration.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6
7use std::sync::Arc;
8
9use antecedent_core::{
10    CausalSchemaBuilder, ExecutionContext, MeasurementSpec, RoleHint, SmallRoleSet, ValueType,
11    VariableId,
12};
13use antecedent_data::{
14    Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
15    TimeSeriesData, ValidityBitmap,
16};
17use antecedent_discovery::{DiscoveryWorkspace, Pcmci};
18use antecedent_kernels::standard_normal;
19
20use crate::error::ValidationError;
21
22/// Empirical false-positive calibration under independent noise.
23#[derive(Clone, Debug)]
24pub struct NullCalibrationReport {
25    /// Nominal α used for discovery.
26    pub alpha: f64,
27    /// Simulations run.
28    pub n_sim: u32,
29    /// Empirical edge rate (edges per possible directed lag-1 pair per sim, averaged).
30    pub empirical_fpr: f64,
31    /// Binomial SE under H0 rate = α (rough guide).
32    pub se: f64,
33    /// Whether `|empirical_fpr − α| ≤ band_tol * se` (or absolute floor).
34    pub within_band: bool,
35    /// Tolerance multiplier used for `within_band`.
36    pub band_tol: f64,
37}
38
39/// Monte Carlo FPR calibration for PCMCI under independent Gaussian noise.
40#[derive(Clone, Debug)]
41pub struct SyntheticNullCalibration {
42    /// PCMCI configuration (α should match the intended type-I level).
43    pub pcmci: Pcmci,
44    /// Significance level expected under the null.
45    pub alpha: f64,
46    /// Number of independent simulations.
47    pub n_sim: u32,
48    /// Observations per simulation.
49    pub n_obs: usize,
50    /// Variables per simulation (≥2).
51    pub n_vars: usize,
52    /// Band width in SE units for `within_band` (default 3).
53    pub band_tol: f64,
54}
55
56impl SyntheticNullCalibration {
57    /// Build a null calibrator.
58    #[must_use]
59    pub fn new(pcmci: Pcmci, alpha: f64, n_sim: u32, n_obs: usize, n_vars: usize) -> Self {
60        Self { pcmci, alpha, n_sim, n_obs, n_vars, band_tol: 3.0 }
61    }
62
63    /// Run synthetic-null calibration.
64    ///
65    /// # Errors
66    ///
67    /// Invalid config or discovery failures.
68    pub fn run(
69        &self,
70        workspace: &mut DiscoveryWorkspace,
71        ctx: &ExecutionContext,
72    ) -> Result<NullCalibrationReport, ValidationError> {
73        if self.n_sim == 0 || self.n_obs < 8 || self.n_vars < 2 {
74            return Err(ValidationError::NotApplicable {
75                message: "synthetic-null needs n_sim>0, n_obs≥8, n_vars≥2",
76            });
77        }
78        if !(self.alpha > 0.0 && self.alpha <= 1.0) {
79            return Err(ValidationError::NotApplicable {
80                message: "synthetic-null alpha must be in (0, 1]",
81            });
82        }
83        let variables: Vec<VariableId> =
84            (0..self.n_vars as u32).map(VariableId::from_raw).collect();
85        // Possible directed lag edges under max_lag (approx family size for FPR).
86        let max_lag = self.pcmci.engine().constraints.temporal.max_lag.raw().max(1);
87        let family = (self.n_vars * self.n_vars.saturating_sub(0) * max_lag as usize).max(1);
88        let mut rng = ctx.rng.stream(0x5011_u64);
89        let mut edge_hits = 0u64;
90        let mut trials = 0u64;
91        for _ in 0..self.n_sim {
92            let data = independent_noise_series(self.n_obs, self.n_vars, &mut rng)?;
93            let result =
94                self.pcmci.run(&data, &variables, workspace, ctx).map_err(ValidationError::from)?;
95            edge_hits += result.evidence.links.len() as u64;
96            trials += family as u64;
97        }
98        let empirical_fpr = if trials == 0 { 0.0 } else { edge_hits as f64 / trials as f64 };
99        let se = (self.alpha * (1.0 - self.alpha) / f64::from(self.n_sim)).sqrt();
100        let abs_floor = 0.05;
101        let within_band = (empirical_fpr - self.alpha).abs() <= (self.band_tol * se).max(abs_floor);
102        Ok(NullCalibrationReport {
103            alpha: self.alpha,
104            n_sim: self.n_sim,
105            empirical_fpr,
106            se,
107            within_band,
108            band_tol: self.band_tol,
109        })
110    }
111}
112
113fn independent_noise_series(
114    n_obs: usize,
115    n_vars: usize,
116    rng: &mut antecedent_core::CausalRng,
117) -> Result<TimeSeriesData, ValidationError> {
118    let mut b = CausalSchemaBuilder::new();
119    for i in 0..n_vars {
120        b.add_variable(
121            format!("v{i}"),
122            ValueType::Continuous,
123            SmallRoleSet::from_hint(RoleHint::Context),
124            None,
125            None,
126            MeasurementSpec::default(),
127        )
128        .map_err(|_| ValidationError::NotApplicable {
129            message: "synthetic-null schema variable rejected",
130        })?;
131    }
132    let schema = b.build().map_err(|_| ValidationError::NotApplicable {
133        message: "synthetic-null schema build failed",
134    })?;
135    let mut cols = Vec::with_capacity(n_vars);
136    for v in 0..n_vars {
137        let values: Vec<f64> = (0..n_obs).map(|_| standard_normal(rng)).collect();
138        cols.push(OwnedColumn::Float64(
139            Float64Column::new(
140                VariableId::from_raw(v as u32),
141                Arc::from(values),
142                ValidityBitmap::all_valid(n_obs),
143            )
144            .map_err(ValidationError::from)?,
145        ));
146    }
147    let storage =
148        OwnedColumnarStorage::try_new(schema, cols, None, None).map_err(ValidationError::from)?;
149    TimeSeriesData::try_new(
150        storage,
151        TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n_obs },
152    )
153    .map_err(ValidationError::from)
154}
155
156#[cfg(test)]
157mod tests {
158    use antecedent_core::{ExecutionContext, Lag};
159    use antecedent_discovery::{DiscoveryConstraints, DiscoveryWorkspace, TemporalConstraints};
160
161    use super::*;
162
163    #[test]
164    fn synthetic_null_smoke() {
165        let constraints = DiscoveryConstraints {
166            temporal: TemporalConstraints { max_lag: Lag::from_raw(1), min_lag: Lag::from_raw(1) },
167            max_cond_size: 1,
168            alpha: 0.05,
169            ..Default::default()
170        };
171        let cal = SyntheticNullCalibration::new(
172            Pcmci::new().with_fdr(false).with_constraints(constraints),
173            0.05,
174            2,
175            80,
176            2,
177        );
178        let mut ws = DiscoveryWorkspace::default();
179        let ctx = ExecutionContext::for_tests(2);
180        let report = cal.run(&mut ws, &ctx).unwrap();
181        assert_eq!(report.n_sim, 2);
182        assert!(report.empirical_fpr >= 0.0);
183    }
184
185    /// Empirical FPR near α under independent noise. Runs in well under a second
186    /// (`n_sim=40`, `n_obs=200`, `n_vars=3`), so it stays in the default `cargo
187    /// test` suite rather than the weekly `scripts/gate_calibration.sh` gate — a
188    /// regression in the PCMCI significance calibration should fail CI, not wait
189    /// a week to be caught.
190    #[test]
191    fn synthetic_null_fpr_near_alpha_gate() {
192        let constraints = DiscoveryConstraints {
193            temporal: TemporalConstraints { max_lag: Lag::from_raw(1), min_lag: Lag::from_raw(1) },
194            max_cond_size: 1,
195            alpha: 0.05,
196            ..Default::default()
197        };
198        let mut cal = SyntheticNullCalibration::new(
199            Pcmci::new().with_fdr(false).with_constraints(constraints),
200            0.05,
201            40,
202            200,
203            3,
204        );
205        cal.band_tol = 4.0;
206        let mut ws = DiscoveryWorkspace::default();
207        let ctx = ExecutionContext::for_tests(42);
208        let report = cal.run(&mut ws, &ctx).unwrap();
209        assert!(
210            report.within_band,
211            "FPR={} α={} se={} band_tol={}",
212            report.empirical_fpr, report.alpha, report.se, report.band_tol
213        );
214    }
215}