tacet_core/preflight/
autocorr.rs1extern crate alloc;
14
15use alloc::string::String;
16use alloc::vec::Vec;
17
18use crate::result::{PreflightCategory, PreflightSeverity, PreflightWarningInfo};
19
20#[derive(Debug, Clone)]
22#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
23pub enum AutocorrWarning {
24 PeriodicInterference {
31 lag: usize,
33 acf_value: f64,
35 threshold: f64,
37 },
38
39 InsufficientSamples {
43 available: usize,
45 required: usize,
47 },
48}
49
50impl AutocorrWarning {
51 pub fn is_result_undermining(&self) -> bool {
56 false
57 }
58
59 pub fn severity(&self) -> PreflightSeverity {
61 PreflightSeverity::Informational
63 }
64
65 pub fn description(&self) -> String {
67 match self {
68 AutocorrWarning::PeriodicInterference {
69 lag,
70 acf_value,
71 threshold,
72 } => {
73 alloc::format!(
74 "High autocorrelation at lag {}: ACF={:.2} (threshold: {:.2}). \
75 This reduces effective sample size but the block bootstrap \
76 accounts for this.",
77 lag,
78 acf_value,
79 threshold
80 )
81 }
82 AutocorrWarning::InsufficientSamples {
83 available,
84 required,
85 } => {
86 alloc::format!(
87 "Insufficient samples for autocorrelation check: {} available, {} required.",
88 available,
89 required
90 )
91 }
92 }
93 }
94
95 pub fn guidance(&self) -> Option<String> {
97 match self {
98 AutocorrWarning::PeriodicInterference { .. } => Some(
99 "Consider checking for background processes or periodic system tasks \
100 to improve sample efficiency."
101 .into(),
102 ),
103 AutocorrWarning::InsufficientSamples { .. } => None,
104 }
105 }
106
107 pub fn to_warning_info(&self) -> PreflightWarningInfo {
109 match self.guidance() {
110 Some(guidance) => PreflightWarningInfo::with_guidance(
111 PreflightCategory::Autocorrelation,
112 self.severity(),
113 self.description(),
114 guidance,
115 ),
116 None => PreflightWarningInfo::new(
117 PreflightCategory::Autocorrelation,
118 self.severity(),
119 self.description(),
120 ),
121 }
122 }
123}
124
125const ACF_THRESHOLD: f64 = 0.3;
127
128const MAX_LAG: usize = 2;
130
131const MIN_SAMPLES_FOR_ACF: usize = 100;
133
134pub fn autocorrelation_check(fixed: &[f64], random: &[f64]) -> Option<AutocorrWarning> {
148 if fixed.len() < MIN_SAMPLES_FOR_ACF || random.len() < MIN_SAMPLES_FOR_ACF {
149 let n = fixed.len().min(random.len());
150 return Some(AutocorrWarning::InsufficientSamples {
151 available: n,
152 required: MIN_SAMPLES_FOR_ACF,
153 });
154 }
155
156 for lag in 1..=MAX_LAG {
158 let acf_f = compute_acf(fixed, lag);
159 let acf_r = compute_acf(random, lag);
160 let max_acf = if acf_f.abs() > acf_r.abs() {
161 acf_f
162 } else {
163 acf_r
164 };
165
166 if max_acf.abs() > ACF_THRESHOLD {
167 return Some(AutocorrWarning::PeriodicInterference {
168 lag,
169 acf_value: max_acf,
170 threshold: ACF_THRESHOLD,
171 });
172 }
173 }
174
175 None
176}
177
178pub fn compute_acf(data: &[f64], lag: usize) -> f64 {
192 if data.len() <= lag {
193 return 0.0;
194 }
195
196 let n = data.len();
197
198 let mean: f64 = data.iter().sum::<f64>() / n as f64;
200
201 let variance: f64 = data.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n as f64;
203
204 if variance < 1e-10 {
205 return 0.0;
206 }
207
208 let autocovariance: f64 = data
210 .iter()
211 .take(n - lag)
212 .zip(data.iter().skip(lag))
213 .map(|(x_t, x_t_k)| (x_t - mean) * (x_t_k - mean))
214 .sum::<f64>()
215 / (n - lag) as f64;
216
217 autocovariance / variance
219}
220
221#[allow(dead_code)]
234pub fn compute_full_acf(data: &[f64], max_lag: usize) -> Vec<f64> {
235 (0..=max_lag).map(|lag| compute_acf(data, lag)).collect()
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn test_insufficient_samples() {
244 let data = alloc::vec![1.0; 50];
245 let result = autocorrelation_check(&data, &data);
246 assert!(matches!(
247 result,
248 Some(AutocorrWarning::InsufficientSamples { .. })
249 ));
250 }
251
252 #[test]
253 fn test_periodic_signal_detected() {
254 let data: Vec<f64> = (0..1000)
256 .map(|i| if i % 2 == 0 { 100.0 } else { 200.0 })
257 .collect();
258
259 let result = autocorrelation_check(&data, &data);
260 assert!(
261 matches!(result, Some(AutocorrWarning::PeriodicInterference { .. })),
262 "Periodic signal should trigger warning"
263 );
264 }
265
266 #[test]
267 fn test_acf_at_lag_0() {
268 let data = alloc::vec![1.0, 2.0, 3.0, 4.0, 5.0];
269 let acf0 = compute_acf(&data, 0);
270 assert!(
271 (acf0 - 1.0).abs() < 1e-10,
272 "ACF at lag 0 should be 1.0, got {}",
273 acf0
274 );
275 }
276
277 #[test]
278 fn test_severity() {
279 let periodic = AutocorrWarning::PeriodicInterference {
280 lag: 1,
281 acf_value: 0.45,
282 threshold: 0.3,
283 };
284 assert_eq!(periodic.severity(), PreflightSeverity::Informational);
285 assert!(!periodic.is_result_undermining());
286 }
287}