antecedent_validate/stability/
regime.rs1#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6
7use std::collections::BTreeMap;
8
9use antecedent_core::{ExecutionContext, RegimeId, VariableId};
10use antecedent_data::{ResamplingPlan, TableView, TimeSeriesData, resample_timeseries};
11use antecedent_discovery::{DiscoveryWorkspace, LaggedLink, RegimeAssignment, Rpcmci};
12
13use crate::error::ValidationError;
14
15use super::pcmci_grid::{DiscoveryStabilityReport, report_from_counts};
16
17#[derive(Clone, Debug)]
19pub struct RegimeStabilityReport {
20 pub per_regime: BTreeMap<RegimeId, DiscoveryStabilityReport>,
22 pub replicates: u32,
24 pub block_size: usize,
26}
27
28#[derive(Clone, Debug)]
30pub struct RegimeStability {
31 pub rpcmci: Rpcmci,
33 pub assignment: RegimeAssignment,
35 pub replicates: u32,
37 pub block_size: usize,
39}
40
41impl RegimeStability {
42 #[must_use]
44 pub fn new(rpcmci: Rpcmci, assignment: RegimeAssignment) -> Self {
45 Self {
46 rpcmci: rpcmci.with_alternating_iters(0),
47 assignment,
48 replicates: 10,
49 block_size: 20,
50 }
51 }
52
53 pub fn run(
61 &self,
62 data: &TimeSeriesData,
63 variables: &[VariableId],
64 workspace: &mut DiscoveryWorkspace,
65 ctx: &ExecutionContext,
66 ) -> Result<RegimeStabilityReport, ValidationError> {
67 if self.replicates == 0 || self.block_size == 0 {
68 return Err(ValidationError::NotApplicable {
69 message: "regime stability requires positive replicates and block_size",
70 });
71 }
72 if self.assignment.len() != data.row_count() {
73 return Err(ValidationError::NotApplicable {
74 message: "regime assignment length must match series length",
75 });
76 }
77 if self.block_size > data.row_count() {
78 return Err(ValidationError::NotApplicable {
79 message: "block_size exceeds series length",
80 });
81 }
82 let regimes = self.assignment.unique_regimes();
83 let mut counts: BTreeMap<RegimeId, BTreeMap<LaggedLink, u32>> = BTreeMap::new();
84 for &r in ®imes {
85 counts.insert(r, BTreeMap::new());
86 }
87 let mut rng = ctx.rng.stream(0x5E61_u64);
88 let mut index_scratch = Vec::new();
89 for _ in 0..self.replicates {
90 let boot = resample_timeseries(
91 data,
92 ResamplingPlan::MovingBlock { length: self.block_size },
93 &mut rng,
94 &mut index_scratch,
95 )
96 .map_err(ValidationError::from)?;
97 let boot_labels: Vec<_> = index_scratch
99 .iter()
100 .map(|&i| self.assignment.at(i as usize).expect("bootstrap index in range"))
101 .collect();
102 let boot_assign =
103 RegimeAssignment::try_new(boot_labels).map_err(ValidationError::from)?;
104 let result = self
105 .rpcmci
106 .run(&boot, variables, &boot_assign, workspace, ctx)
107 .map_err(ValidationError::from)?;
108 for (idx, &(regime, _)) in result.graphs.graphs.iter().enumerate() {
109 let Some(per) = result.per_regime.get(idx) else {
110 continue;
111 };
112 let entry = counts.entry(regime).or_default();
113 for s in per.evidence.links.iter() {
114 *entry.entry(s.link).or_insert(0) += 1;
115 }
116 }
117 }
118 let mut per_regime = BTreeMap::new();
119 for (regime, c) in counts {
120 per_regime.insert(regime, report_from_counts(c, self.replicates, self.block_size));
121 }
122 Ok(RegimeStabilityReport {
123 per_regime,
124 replicates: self.replicates,
125 block_size: self.block_size,
126 })
127 }
128}
129
130#[cfg(test)]
131#[allow(clippy::cast_precision_loss)]
132mod tests {
133 use antecedent_core::{
134 CausalSchemaBuilder, ExecutionContext, Lag, MeasurementSpec, RoleHint, SmallRoleSet,
135 ValueType, VariableId,
136 };
137 use antecedent_data::{
138 Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
139 TimeSeriesData, ValidityBitmap,
140 };
141 use antecedent_discovery::{
142 DiscoveryConstraints, DiscoveryWorkspace, PcmciPlus, TemporalConstraints,
143 two_regime_half_split,
144 };
145 use std::sync::Arc;
146
147 use super::*;
148
149 fn two_regime_series(n: usize) -> (TimeSeriesData, Vec<VariableId>) {
150 let mut b = CausalSchemaBuilder::new();
151 for name in ["x", "y"] {
152 b.add_variable(
153 name,
154 ValueType::Continuous,
155 SmallRoleSet::from_hint(RoleHint::Context),
156 None,
157 None,
158 MeasurementSpec::default(),
159 )
160 .unwrap();
161 }
162 let schema = b.build().unwrap();
163 let mid = n / 2;
164 let mut x = vec![0.0; n];
165 let mut y = vec![0.0; n];
166 for t in 1..n {
167 x[t] = 0.5 * x[t - 1] + (t as f64 * 0.01).sin() * 0.1;
168 if t < mid {
169 y[t] = 0.8 * x[t - 1] + 0.2 * y[t - 1];
170 } else {
171 y[t] = -0.7 * x[t - 1] + 0.2 * y[t - 1];
172 }
173 }
174 let cols = vec![
175 OwnedColumn::Float64(
176 Float64Column::new(
177 VariableId::from_raw(0),
178 Arc::from(x),
179 ValidityBitmap::all_valid(n),
180 )
181 .unwrap(),
182 ),
183 OwnedColumn::Float64(
184 Float64Column::new(
185 VariableId::from_raw(1),
186 Arc::from(y),
187 ValidityBitmap::all_valid(n),
188 )
189 .unwrap(),
190 ),
191 ];
192 let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
193 let data = TimeSeriesData::try_new(
194 storage,
195 TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
196 )
197 .unwrap();
198 (data, vec![VariableId::from_raw(0), VariableId::from_raw(1)])
199 }
200
201 #[test]
202 fn regime_stability_runs_two_regimes() {
203 let n = 200usize;
204 let (data, vars) = two_regime_series(n);
205 let assign = two_regime_half_split(n);
206 let constraints = DiscoveryConstraints {
207 temporal: TemporalConstraints { max_lag: Lag::from_raw(1), min_lag: Lag::from_raw(1) },
208 max_cond_size: 1,
209 alpha: 0.15,
210 ..Default::default()
211 };
212 let rpcmci = Rpcmci::new()
213 .with_pcmci_plus(PcmciPlus::new().with_fdr(false).with_constraints(constraints))
214 .with_min_regime_len(40)
215 .with_alternating_iters(0);
216 let stab = RegimeStability { rpcmci, assignment: assign, replicates: 3, block_size: 25 };
217 let mut ws = DiscoveryWorkspace::default();
218 let ctx = ExecutionContext::for_tests(3);
219 let report = stab.run(&data, &vars, &mut ws, &ctx).unwrap();
220 assert_eq!(report.replicates, 3);
221 assert!(!report.per_regime.is_empty());
222 }
223}