Skip to main content

antecedent_validate/stability/
regime.rs

1//! Regime stability via RPCMCI block bootstrap.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![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/// Per-regime link-frequency stability under fixed caller labels.
18#[derive(Clone, Debug)]
19pub struct RegimeStabilityReport {
20    /// Per-regime discovery stability reports.
21    pub per_regime: BTreeMap<RegimeId, DiscoveryStabilityReport>,
22    /// Bootstrap replicates.
23    pub replicates: u32,
24    /// Moving-block length.
25    pub block_size: usize,
26}
27
28/// Block-bootstrap regime stability around [`Rpcmci`] with fixed assignments.
29#[derive(Clone, Debug)]
30pub struct RegimeStability {
31    /// RPCMCI configuration (`alternating_iters` should be 0 for fixed labels).
32    pub rpcmci: Rpcmci,
33    /// Caller-supplied regime labels (no unsupervised search).
34    pub assignment: RegimeAssignment,
35    /// Bootstrap replicates.
36    pub replicates: u32,
37    /// Block length.
38    pub block_size: usize,
39}
40
41impl RegimeStability {
42    /// Build with fixed assignment; forces `alternating_iters = 0`.
43    #[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    /// Run regime stability assessment.
54    ///
55    /// Bootstraps the series, re-applies the same regime labels, and re-runs RPCMCI.
56    ///
57    /// # Errors
58    ///
59    /// Length mismatch, empty configs, or discovery failures.
60    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 &regimes {
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            // Re-apply fixed labels by mapping bootstrap row indexes back to original regimes.
98            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}