dsfb_rf/stationarity.rs
1//! Wide-Sense Stationarity (WSS) verification for the calibration window.
2//!
3//! ## Theoretical Basis (Wiener-Khinchin Theorem)
4//!
5//! The Wiener-Khinchin theorem establishes that a wide-sense stationary (WSS)
6//! process has a power spectral density equal to the Fourier transform of its
7//! autocorrelation function. DSFB's calibration step assumes the healthy window
8//! is WSS — that the mean and autocovariance are time-invariant over the
9//! calibration period. If this assumption is violated, the envelope radius ρ
10//! is unreliable.
11//!
12//! This module provides a lightweight WSS check that verifies:
13//! 1. **Mean stationarity**: the mean of the first half ≈ the mean of the second half
14//! 2. **Variance stationarity**: the variance of the first half ≈ the variance of the second half
15//! 3. **Autocorrelation decay**: the lag-1 autocorrelation is bounded (no persistent trend)
16//!
17//! ## Design
18//!
19//! - `no_std`, `no_alloc`, zero `unsafe`
20//! - O(n) single-pass computation
21//! - Returns a typed `StationarityVerdict` with quantified deviation metrics
22//!
23//! ## GUM / IEEE 1764 Relevance
24//!
25//! The Guide to the Expression of Uncertainty in Measurement (GUM) requires
26//! that measurement uncertainty budgets be derived from stationary processes.
27//! This check provides the pre-condition for the GUM uncertainty budget
28//! in the admissibility envelope (see `envelope.rs`).
29
30use crate::math::{mean_f32, std_dev_f32};
31
32/// Result of WSS verification on a calibration window.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct StationarityVerdict {
35 /// Mean of the first half of the window.
36 pub mean_first_half: f32,
37 /// Mean of the second half of the window.
38 pub mean_second_half: f32,
39 /// Relative mean deviation: |μ₁ − μ₂| / max(|μ₁|, |μ₂|, ε).
40 pub mean_deviation: f32,
41 /// Variance of the first half.
42 pub var_first_half: f32,
43 /// Variance of the second half.
44 pub var_second_half: f32,
45 /// Relative variance deviation: |σ₁² − σ₂²| / max(σ₁², σ₂², ε).
46 pub variance_deviation: f32,
47 /// Lag-1 normalized autocorrelation r(1) / r(0).
48 pub lag1_autocorrelation: f32,
49 /// Whether the window passes all WSS checks.
50 pub is_wss: bool,
51}
52
53/// Configuration thresholds for WSS verification.
54#[derive(Debug, Clone, Copy)]
55pub struct StationarityConfig {
56 /// Maximum acceptable relative mean deviation between halves.
57 /// Default: 0.20 (20%).
58 pub max_mean_deviation: f32,
59 /// Maximum acceptable relative variance deviation between halves.
60 /// Default: 0.50 (50%).
61 pub max_variance_deviation: f32,
62 /// Maximum acceptable |lag-1 autocorrelation| (above which: persistent trend).
63 /// Default: 0.70.
64 pub max_lag1_autocorrelation: f32,
65}
66
67impl Default for StationarityConfig {
68 fn default() -> Self {
69 Self {
70 max_mean_deviation: 0.20,
71 max_variance_deviation: 0.50,
72 max_lag1_autocorrelation: 0.70,
73 }
74 }
75}
76
77/// Outcome of a pre-calibration bootstrap integrity check.
78///
79/// Returned by [`check_bootstrap_integrity`] before an admissibility envelope
80/// is locked. A non-[`BootstrapIntegrityAlert::Clean`] result means the
81/// calibration window contains organised structure and should be rejected;
82/// the engine must not lock the envelope until a clean window is found.
83///
84/// This directly addresses the "Bootstrap Paradox" panel criticism
85/// (paper §L \textit{Pre-emptive Technical Defence}, item 6): if the South
86/// China Sea calibration window is already jammed, spectral non-flatness
87/// will manifest as a non-zero lag-1 autocorrelation (Wiener-Khinchin) and
88/// this alert fires.
89///
90/// # Examples
91///
92/// ```
93/// use dsfb_rf::stationarity::{check_bootstrap_integrity, StationarityConfig, BootstrapIntegrityAlert};
94/// let clean: Vec<f32> = (0..64).map(|i| 0.5 + 0.01 * (i as f32 % 7.0 - 3.0)).collect();
95/// assert_eq!(check_bootstrap_integrity(&clean, &StationarityConfig::default()),
96/// BootstrapIntegrityAlert::Clean);
97/// ```
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub enum BootstrapIntegrityAlert {
100 /// Calibration window passed all WSS and PSD flatness tests. Safe to lock.
101 Clean,
102 /// PSD flatness test failed: lag-1 autocorrelation exceeded the configured
103 /// threshold. The window likely contains periodic interference.
104 PsdNonFlat {
105 /// Measured |lag-1 autocorrelation|.
106 flatness_score: f32,
107 /// Configured maximum before alert fires.
108 threshold: f32,
109 },
110 /// Mean stationarity test failed: the half-window means diverge.
111 MeanShift {
112 /// Normalised |mean first_half − mean second_half| / max(|mean|, ε).
113 deviation: f32,
114 },
115 /// Variance stationarity test failed: the half-window variances diverge.
116 VarianceShift {
117 /// Normalised |var first_half − var second_half| / max(var, ε).
118 deviation: f32,
119 },
120}
121
122impl BootstrapIntegrityAlert {
123 /// Returns `true` if this alert blocks calibration lock.
124 #[inline]
125 pub fn blocks_calibration(self) -> bool {
126 !matches!(self, BootstrapIntegrityAlert::Clean)
127 }
128}
129
130/// Check whether a candidate calibration window is safe to use as a baseline.
131///
132/// Runs [`verify_wss`] and maps the result onto a typed
133/// [`BootstrapIntegrityAlert`] that the engine layer can surface to the
134/// operator before locking any admissibility envelope.
135///
136/// Returns [`BootstrapIntegrityAlert::Clean`] when the window has fewer than
137/// 4 observations (not enough data to detect contamination; the caller should
138/// collect more samples before relying on the baseline).
139///
140/// # Examples
141///
142/// ```
143/// use dsfb_rf::stationarity::{check_bootstrap_integrity, StationarityConfig, BootstrapIntegrityAlert};
144/// // Inject a sinusoidal jammer into the calibration window
145/// let jammed: Vec<f32> = (0..64).map(|i| 0.5 + 0.3 * (i as f32 * 0.4).sin()).collect();
146/// let alert = check_bootstrap_integrity(&jammed, &StationarityConfig::default());
147/// assert!(alert.blocks_calibration());
148/// ```
149pub fn check_bootstrap_integrity(
150 norms: &[f32],
151 config: &StationarityConfig,
152) -> BootstrapIntegrityAlert {
153 match verify_wss(norms, config) {
154 None => BootstrapIntegrityAlert::Clean, // insufficient data — caller must extend window
155 Some(v) if v.is_wss => BootstrapIntegrityAlert::Clean,
156 Some(v) => {
157 if v.mean_deviation > config.max_mean_deviation {
158 BootstrapIntegrityAlert::MeanShift { deviation: v.mean_deviation }
159 } else if v.variance_deviation > config.max_variance_deviation {
160 BootstrapIntegrityAlert::VarianceShift { deviation: v.variance_deviation }
161 } else {
162 // Autocorrelation/PSD flatness failure
163 BootstrapIntegrityAlert::PsdNonFlat {
164 flatness_score: v.lag1_autocorrelation.abs(),
165 threshold: config.max_lag1_autocorrelation,
166 }
167 }
168 }
169 }
170}
171
172/// Verify wide-sense stationarity of a calibration window.
173///
174/// Splits the window in half and compares mean, variance, and lag-1
175/// autocorrelation against configured thresholds.
176///
177/// Returns `None` if the window has fewer than 4 observations.
178pub fn verify_wss(norms: &[f32], config: &StationarityConfig) -> Option<StationarityVerdict> {
179 if norms.len() < 4 {
180 return None;
181 }
182
183 let mid = norms.len() / 2;
184 let first = &norms[..mid];
185 let second = &norms[mid..];
186
187 let m1 = mean_f32(first);
188 let m2 = mean_f32(second);
189 let s1 = std_dev_f32(first);
190 let s2 = std_dev_f32(second);
191 let v1 = s1 * s1;
192 let v2 = s2 * s2;
193
194 let eps = 1e-10_f32;
195 let mean_dev = (m1 - m2).abs() / (m1.abs().max(m2.abs()).max(eps));
196 let var_dev = (v1 - v2).abs() / (v1.max(v2).max(eps));
197
198 // Lag-1 autocorrelation: r(1)/r(0)
199 let m_all = mean_f32(norms);
200 let mut r0 = 0.0_f32;
201 let mut r1 = 0.0_f32;
202 for i in 0..norms.len() {
203 let d = norms[i] - m_all;
204 r0 += d * d;
205 if i + 1 < norms.len() {
206 let d_next = norms[i + 1] - m_all;
207 r1 += d * d_next;
208 }
209 }
210 let lag1 = if r0 > eps { r1 / r0 } else { 0.0 };
211
212 let is_wss = mean_dev <= config.max_mean_deviation
213 && var_dev <= config.max_variance_deviation
214 && lag1.abs() <= config.max_lag1_autocorrelation;
215
216 Some(StationarityVerdict {
217 mean_first_half: m1,
218 mean_second_half: m2,
219 mean_deviation: mean_dev,
220 var_first_half: v1,
221 var_second_half: v2,
222 variance_deviation: var_dev,
223 lag1_autocorrelation: lag1,
224 is_wss,
225 })
226}
227
228// ── Reverse Arrangements Test (Olmstead-Tukey 1947) ──────────────────────
229
230/// Result of the Reverse Arrangements Test for trend detection.
231///
232/// ## Theoretical Basis: Olmstead & Tukey (1947)
233///
234/// The Reverse Arrangements Test (RAT) is the canonical non-parametric test
235/// for detecting monotone trends in a data window (Olmstead & Tukey 1947;
236/// WMO-No. 100 §3.3.3). Unlike the lag-1 autocorrelation test, RAT makes
237/// **no distributional assumption** about the observations and is specifically
238/// sensitive to subtle drifts that are invisible to the split-mean variance check.
239///
240/// **Test statistic:** Count A = #{(i,j) : i < j, x_i > x_j}
241///
242/// Under H₀ (no trend, i.i.d. observations):
243/// - E[A] = N(N−1)/4
244/// - Var[A] = N(2N+5)(N−1)/72
245///
246/// **Z-score** (normal approximation, reliable for N ≥ 10):
247/// Z = (A − E[A]) / √Var[A] ~ N(0, 1) under H₀
248///
249/// Critical values (two-sided):
250/// - |Z| > 1.645 → 10% significance
251/// - |Z| > 1.960 → 5% significance ← default `has_trend`
252/// - |Z| > 2.576 → 1% significance ← `has_trend_strict`
253///
254/// A positive Z (A > E[A]) indicates a **downtrend** (many later values are
255/// smaller than earlier values). A negative Z means an **uptrend**.
256///
257/// ## GUM Integration
258///
259/// A calibration window that fails the RAT has a *systematic monotone trend*
260/// that **invalidates** the GUM Type A uncertainty estimate. This function
261/// is called by the DSFB calibration path as a mandatory pre-condition before
262/// the admissibility radius ρ is locked.
263///
264/// ## Reference
265///
266/// Olmstead, P.S. & Tukey, J.W. (1947). "A corner test for association."
267/// *Ann. Math. Statist.* 18(4):495–513.
268///
269/// WMO-No. 100. (2018). *Guide to Climatological Practices.* §3.3.3.
270#[derive(Debug, Clone, Copy)]
271pub struct ReverseArrangementsResult {
272 /// Number of reverse arrangements: A = #{(i,j): i < j, x_i > x_j}.
273 pub n_arrangements: u32,
274 /// Expected value under H₀: E[A] = N(N−1)/4.
275 pub expected: f32,
276 /// Variance under H₀: Var[A] = N(2N+5)(N−1)/72.
277 pub variance: f32,
278 /// Standardized Z-score: (A − E[A]) / √Var[A].
279 pub z_score: f32,
280 /// True if |Z| > 1.96 (trend present at 5% significance, p < 0.05).
281 pub has_trend: bool,
282 /// True if |Z| > 2.576 (strict: trend at 1% significance, p < 0.01).
283 pub has_trend_strict: bool,
284 /// Trend direction: +1 = uptrend, −1 = downtrend, 0 = no significant trend.
285 pub trend_direction: i8,
286}
287
288/// Run the Reverse Arrangements Test on a calibration window.
289///
290/// Requires N ≥ 10 observations for a reliable normal approximation.
291/// Returns `None` if the window has fewer than 10 observations.
292///
293/// **Time complexity:** O(N²). Acceptable for calibration windows (N ≤ 500).
294/// For N = 200 this is 20,000 comparisons — ~100 µs on a Cortex-M4F.
295pub fn reverse_arrangements_test(norms: &[f32]) -> Option<ReverseArrangementsResult> {
296 let n = norms.len();
297 if n < 10 { return None; }
298
299 // Count A = #{(i,j): i < j, x_i > x_j}
300 let mut a = 0u32;
301 for i in 0..n {
302 for j in (i + 1)..n {
303 if norms[i] > norms[j] { a += 1; }
304 }
305 }
306
307 let n_f = n as f32;
308 let expected = n_f * (n_f - 1.0) / 4.0;
309 let variance = n_f * (2.0 * n_f + 5.0) * (n_f - 1.0) / 72.0;
310 let z = if variance > 1e-30 {
311 (a as f32 - expected) / crate::math::sqrt_f32(variance)
312 } else { 0.0 };
313
314 // Z < 0: fewer inversions than expected → uptrend; Z > 0: more → downtrend
315 let trend_direction: i8 = if z.abs() > 1.96 {
316 if z < 0.0 { 1 } else { -1 }
317 } else { 0 };
318
319 Some(ReverseArrangementsResult {
320 n_arrangements: a,
321 expected,
322 variance,
323 z_score: z,
324 has_trend: z.abs() > 1.96,
325 has_trend_strict: z.abs() > 2.576,
326 trend_direction,
327 })
328}
329
330// ── Tests ──────────────────────────────────────────────────────────────────
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn constant_signal_is_wss() {
338 let norms = [0.05_f32; 100];
339 let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
340 assert!(v.is_wss, "constant signal must be WSS: {:?}", v);
341 assert!(v.mean_deviation < 0.01);
342 assert!(v.variance_deviation < 0.01);
343 }
344
345 #[test]
346 fn stationary_noise_is_wss() {
347 // Simulated white noise with constant statistics
348 let norms: [f32; 100] = core::array::from_fn(|i| {
349 0.05 + 0.01 * ((i as f32 * 7.3).sin())
350 });
351 let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
352 assert!(v.is_wss, "stationary noise must be WSS: {:?}", v);
353 }
354
355 #[test]
356 fn trending_signal_fails_wss() {
357 // Linear trend: mean shifts between halves
358 let norms: [f32; 100] = core::array::from_fn(|i| 0.01 + i as f32 * 0.01);
359 let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
360 assert!(!v.is_wss, "trending signal must fail WSS: {:?}", v);
361 }
362
363 #[test]
364 fn step_change_fails_wss() {
365 // Step change at midpoint: first half ≈ 0.05, second half ≈ 0.5
366 let mut norms = [0.05_f32; 100];
367 for i in 50..100 { norms[i] = 0.50; }
368 let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
369 assert!(!v.is_wss, "step change must fail WSS: mean_dev={}", v.mean_deviation);
370 }
371
372 #[test]
373 fn returns_none_for_short_window() {
374 assert!(verify_wss(&[0.1, 0.2], &StationarityConfig::default()).is_none());
375 assert!(verify_wss(&[], &StationarityConfig::default()).is_none());
376 }
377
378 #[test]
379 fn high_autocorrelation_fails() {
380 // Highly correlated sequence (random walk)
381 let mut norms = [0.0_f32; 100];
382 norms[0] = 0.5;
383 for i in 1..100 {
384 norms[i] = norms[i - 1] + 0.001;
385 }
386 let v = verify_wss(&norms, &StationarityConfig::default()).unwrap();
387 assert!(v.lag1_autocorrelation.abs() > 0.5,
388 "random walk must have high lag-1: {}", v.lag1_autocorrelation);
389 }
390
391 // ── Reverse Arrangements Test ──────────────────────────────────────────
392
393 #[test]
394 fn rat_returns_none_for_short_window() {
395 assert!(reverse_arrangements_test(&[0.1, 0.2, 0.3]).is_none());
396 }
397
398 #[test]
399 fn rat_flat_window_no_trend() {
400 // A symmetric sinusoidal oscillation around a fixed mean has A ≈ E[A] → |Z| ≈ 0.
401 // (Note: a perfectly constant sequence has A = 0 << E[A] and tests AS an uptrend.)
402 let norms: [f32; 50] = core::array::from_fn(|i| {
403 0.05_f32 + 0.01 * ((i as f32 * 3.141_592_6 * 2.0 / 7.0).sin())
404 });
405 let r = reverse_arrangements_test(&norms).unwrap();
406 assert!(!r.has_trend,
407 "stationary sinusoid must have no trend: Z={}", r.z_score);
408 }
409
410 #[test]
411 fn rat_uptrend_detected() {
412 // Strictly increasing: x_i < x_j for all i<j → A = 0 << E[A] → Z << 0
413 let norms: [f32; 40] = core::array::from_fn(|i| i as f32 * 0.01);
414 let r = reverse_arrangements_test(&norms).unwrap();
415 assert!(r.has_trend, "strictly increasing must be detected: Z={}", r.z_score);
416 assert_eq!(r.trend_direction, 1, "should be uptrend");
417 }
418
419 #[test]
420 fn rat_downtrend_detected() {
421 // Strictly decreasing: x_i > x_j for all i<j → A = max → Z >> 0
422 let norms: [f32; 40] = core::array::from_fn(|i| 1.0 - i as f32 * 0.01);
423 let r = reverse_arrangements_test(&norms).unwrap();
424 assert!(r.has_trend, "strictly decreasing must be detected: Z={}", r.z_score);
425 assert_eq!(r.trend_direction, -1, "should be downtrend");
426 }
427
428 #[test]
429 fn rat_stationary_noise_no_trend() {
430 // Bounded oscillation: no systematic trend
431 let norms: [f32; 50] = core::array::from_fn(|i| {
432 0.05 + 0.01 * ((i as f32 * 7.3).sin())
433 });
434 let r = reverse_arrangements_test(&norms).unwrap();
435 // Oscillating noise may or may not trigger at 5%; just check |Z| is small
436 assert!(r.z_score.abs() < 4.0,
437 "stationary oscillation must have small Z: {}", r.z_score);
438 }
439
440 #[test]
441 fn rat_expected_value_formula() {
442 // For N samples, E[A] = N(N-1)/4
443 let norms = [0.0_f32; 20];
444 let r = reverse_arrangements_test(&norms).unwrap();
445 let expected = 20.0_f32 * 19.0 / 4.0;
446 assert!((r.expected - expected).abs() < 0.1,
447 "E[A] formula: expected={} got={}", expected, r.expected);
448 }
449}