use crate::math::{mean_f32, std_dev_f32};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StationarityVerdict {
pub mean_first_half: f32,
pub mean_second_half: f32,
pub mean_deviation: f32,
pub var_first_half: f32,
pub var_second_half: f32,
pub variance_deviation: f32,
pub lag1_autocorrelation: f32,
pub is_wss: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct StationarityConfig {
pub max_mean_deviation: f32,
pub max_variance_deviation: f32,
pub max_lag1_autocorrelation: f32,
}
impl Default for StationarityConfig {
fn default() -> Self {
Self {
max_mean_deviation: 0.20,
max_variance_deviation: 0.50,
max_lag1_autocorrelation: 0.70,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BootstrapIntegrityAlert {
Clean,
PsdNonFlat {
flatness_score: f32,
threshold: f32,
},
MeanShift {
deviation: f32,
},
VarianceShift {
deviation: f32,
},
}
impl BootstrapIntegrityAlert {
#[inline]
pub fn blocks_calibration(self) -> bool {
!matches!(self, BootstrapIntegrityAlert::Clean)
}
}
pub fn check_bootstrap_integrity(
norms: &[f32],
config: &StationarityConfig,
) -> BootstrapIntegrityAlert {
match verify_wss(norms, config) {
None => BootstrapIntegrityAlert::Clean, Some(v) if v.is_wss => BootstrapIntegrityAlert::Clean,
Some(v) => {
if v.mean_deviation > config.max_mean_deviation {
BootstrapIntegrityAlert::MeanShift { deviation: v.mean_deviation }
} else if v.variance_deviation > config.max_variance_deviation {
BootstrapIntegrityAlert::VarianceShift { deviation: v.variance_deviation }
} else {
BootstrapIntegrityAlert::PsdNonFlat {
flatness_score: v.lag1_autocorrelation.abs(),
threshold: config.max_lag1_autocorrelation,
}
}
}
}
}
pub fn verify_wss(norms: &[f32], config: &StationarityConfig) -> Option<StationarityVerdict> {
if norms.len() < 4 {
return None;
}
let mid = norms.len() / 2;
let first = &norms[..mid];
let second = &norms[mid..];
let m1 = mean_f32(first);
let m2 = mean_f32(second);
let s1 = std_dev_f32(first);
let s2 = std_dev_f32(second);
let v1 = s1 * s1;
let v2 = s2 * s2;
let eps = 1e-10_f32;
let mean_dev = (m1 - m2).abs() / (m1.abs().max(m2.abs()).max(eps));
let var_dev = (v1 - v2).abs() / (v1.max(v2).max(eps));
let m_all = mean_f32(norms);
let mut r0 = 0.0_f32;
let mut r1 = 0.0_f32;
for i in 0..norms.len() {
let d = norms[i] - m_all;
r0 += d * d;
if i + 1 < norms.len() {
let d_next = norms[i + 1] - m_all;
r1 += d * d_next;
}
}
let lag1 = if r0 > eps { r1 / r0 } else { 0.0 };
let is_wss = mean_dev <= config.max_mean_deviation
&& var_dev <= config.max_variance_deviation
&& lag1.abs() <= config.max_lag1_autocorrelation;
Some(StationarityVerdict {
mean_first_half: m1,
mean_second_half: m2,
mean_deviation: mean_dev,
var_first_half: v1,
var_second_half: v2,
variance_deviation: var_dev,
lag1_autocorrelation: lag1,
is_wss,
})
}
#[derive(Debug, Clone, Copy)]
pub struct ReverseArrangementsResult {
pub n_arrangements: u32,
pub expected: f32,
pub variance: f32,
pub z_score: f32,
pub has_trend: bool,
pub has_trend_strict: bool,
pub trend_direction: i8,
}
pub fn reverse_arrangements_test(norms: &[f32]) -> Option<ReverseArrangementsResult> {
let n = norms.len();
if n < 10 { return None; }
let mut a = 0u32;
for i in 0..n {
for j in (i + 1)..n {
if norms[i] > norms[j] { a += 1; }
}
}
let n_f = n as f32;
let expected = n_f * (n_f - 1.0) / 4.0;
let variance = n_f * (2.0 * n_f + 5.0) * (n_f - 1.0) / 72.0;
let z = if variance > 1e-30 {
(a as f32 - expected) / crate::math::sqrt_f32(variance)
} else { 0.0 };
let trend_direction: i8 = if z.abs() > 1.96 {
if z < 0.0 { 1 } else { -1 }
} else { 0 };
Some(ReverseArrangementsResult {
n_arrangements: a,
expected,
variance,
z_score: z,
has_trend: z.abs() > 1.96,
has_trend_strict: z.abs() > 2.576,
trend_direction,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_signal_is_wss() {
let norms = [0.05_f32; 100];
let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
assert!(v.is_wss, "constant signal must be WSS: {:?}", v);
assert!(v.mean_deviation < 0.01);
assert!(v.variance_deviation < 0.01);
}
#[test]
fn stationary_noise_is_wss() {
let norms: [f32; 100] = core::array::from_fn(|i| {
0.05 + 0.01 * ((i as f32 * 7.3).sin())
});
let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
assert!(v.is_wss, "stationary noise must be WSS: {:?}", v);
}
#[test]
fn trending_signal_fails_wss() {
let norms: [f32; 100] = core::array::from_fn(|i| 0.01 + i as f32 * 0.01);
let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
assert!(!v.is_wss, "trending signal must fail WSS: {:?}", v);
}
#[test]
fn step_change_fails_wss() {
let mut norms = [0.05_f32; 100];
for i in 50..100 { norms[i] = 0.50; }
let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
assert!(!v.is_wss, "step change must fail WSS: mean_dev={}", v.mean_deviation);
}
#[test]
fn returns_none_for_short_window() {
assert!(verify_wss(&[0.1, 0.2], &StationarityConfig::default()).is_none());
assert!(verify_wss(&[], &StationarityConfig::default()).is_none());
}
#[test]
fn high_autocorrelation_fails() {
let mut norms = [0.0_f32; 100];
norms[0] = 0.5;
for i in 1..100 {
norms[i] = norms[i - 1] + 0.001;
}
let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
assert!(v.lag1_autocorrelation.abs() > 0.5,
"random walk must have high lag-1: {}", v.lag1_autocorrelation);
}
#[test]
fn rat_returns_none_for_short_window() {
assert!(reverse_arrangements_test(&[0.1, 0.2, 0.3]).is_none());
}
#[test]
fn rat_flat_window_no_trend() {
let norms: [f32; 50] = core::array::from_fn(|i| {
0.05_f32 + 0.01 * ((i as f32 * 3.141_592_6 * 2.0 / 7.0).sin())
});
let r = reverse_arrangements_test(&norms).unwrap();
assert!(!r.has_trend,
"stationary sinusoid must have no trend: Z={}", r.z_score);
}
#[test]
fn rat_uptrend_detected() {
let norms: [f32; 40] = core::array::from_fn(|i| i as f32 * 0.01);
let r = reverse_arrangements_test(&norms).unwrap();
assert!(r.has_trend, "strictly increasing must be detected: Z={}", r.z_score);
assert_eq!(r.trend_direction, 1, "should be uptrend");
}
#[test]
fn rat_downtrend_detected() {
let norms: [f32; 40] = core::array::from_fn(|i| 1.0 - i as f32 * 0.01);
let r = reverse_arrangements_test(&norms).unwrap();
assert!(r.has_trend, "strictly decreasing must be detected: Z={}", r.z_score);
assert_eq!(r.trend_direction, -1, "should be downtrend");
}
#[test]
fn rat_stationary_noise_no_trend() {
let norms: [f32; 50] = core::array::from_fn(|i| {
0.05 + 0.01 * ((i as f32 * 7.3).sin())
});
let r = reverse_arrangements_test(&norms).unwrap();
assert!(r.z_score.abs() < 4.0,
"stationary oscillation must have small Z: {}", r.z_score);
}
#[test]
fn rat_expected_value_formula() {
let norms = [0.0_f32; 20];
let r = reverse_arrangements_test(&norms).unwrap();
let expected = 20.0_f32 * 19.0 / 4.0;
assert!((r.expected - expected).abs() < 0.1,
"E[A] formula: expected={} got={}", expected, r.expected);
}
}