antecedent_validate/stability/
null_calibration.rs1#![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#[derive(Clone, Debug)]
24pub struct NullCalibrationReport {
25 pub alpha: f64,
27 pub n_sim: u32,
29 pub empirical_fpr: f64,
31 pub se: f64,
33 pub within_band: bool,
35 pub band_tol: f64,
37}
38
39#[derive(Clone, Debug)]
41pub struct SyntheticNullCalibration {
42 pub pcmci: Pcmci,
44 pub alpha: f64,
46 pub n_sim: u32,
48 pub n_obs: usize,
50 pub n_vars: usize,
52 pub band_tol: f64,
54}
55
56impl SyntheticNullCalibration {
57 #[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 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 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 #[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}