tacet_core/statistics/block_length.rs
1//! Optimal block length estimation using Politis-White (2004) with
2//! Patton-Politis-White (2009) correction.
3//!
4//! This module implements data-adaptive block length selection for time-series
5//! bootstraps. The algorithm analyzes the autocorrelation structure of the data
6//! to determine the optimal block size, rather than using a fixed rule like n^(1/3).
7//!
8//! # Algorithm Overview
9//!
10//! 1. Find the lag `m` where autocorrelations become insignificant
11//! 2. Use a flat-top kernel to estimate the long-run variance and its derivative
12//! 3. Compute optimal block lengths that minimize MSE of the bootstrap variance estimator
13//!
14//! # Class-Conditional ACF (spec §3.3.2)
15//!
16//! For interleaved timing streams, computing ACF on the pooled stream directly is
17//! anti-conservative: class alternation masks within-class autocorrelation. Instead,
18//! we compute class-conditional ACF at acquisition-stream lags:
19//!
20//! - ρ^(F)_k = corr(y_t, y_{t+k} | c_t = c_{t+k} = F)
21//! - ρ^(R)_k = corr(y_t, y_{t+k} | c_t = c_{t+k} = R)
22//! - |ρ^(max)_k| = max(|ρ^(F)_k|, |ρ^(R)_k|)
23//!
24//! # References
25//!
26//! - Politis, D. N., & White, H. (2004). Automatic Block-Length Selection for
27//! the Dependent Bootstrap. Econometric Reviews, 23(1), 53-70.
28//! - Patton, A., Politis, D. N., & White, H. (2009). Correction to "Automatic
29//! Block-Length Selection for the Dependent Bootstrap". Econometric Reviews, 28(4), 372-375.
30
31extern crate alloc;
32
33use alloc::vec;
34use alloc::vec::Vec;
35
36use crate::math;
37use crate::types::{Class, TimingSample};
38
39/// Result of optimal block length estimation.
40#[derive(Debug, Clone, Copy)]
41pub struct OptimalBlockLength {
42 /// Optimal block length for stationary bootstrap (exponentially distributed blocks).
43 pub stationary: f64,
44 /// Optimal block length for circular block bootstrap (fixed-size blocks with wrap-around).
45 pub circular: f64,
46}
47
48/// Estimate optimal block lengths for a single time series.
49///
50/// Implements the Politis-White (2004) algorithm with Patton-Politis-White (2009)
51/// corrections for automatic block length selection.
52///
53/// # Arguments
54///
55/// * `x` - A slice of time series observations (minimum 10 required)
56///
57/// # Returns
58///
59/// `OptimalBlockLength` containing estimates for both stationary and circular bootstraps.
60///
61/// # Panics
62///
63/// Panics if `x.len() < 10` (need sufficient data for autocorrelation estimation).
64///
65/// # Algorithm
66///
67/// The algorithm proceeds in three phases:
68///
69/// 1. **Find truncation lag `m`**: Scan autocorrelations to find the first lag where
70/// `k_n` consecutive autocorrelations are all insignificant (within ±2√(log₁₀(n)/n)).
71/// This determines the "memory" of the process.
72///
73/// 2. **Estimate spectral quantities**: Using a flat-top (trapezoidal) kernel,
74/// estimate the long-run variance σ² and its derivative g.
75///
76/// 3. **Compute optimal block length**: The MSE-optimal block length is:
77/// ```text
78/// b_opt = ((2 * g²) / d)^(1/3) * n^(1/3)
79/// ```
80/// where d = 2σ⁴ for stationary bootstrap and d = (4/3)σ⁴ for circular.
81pub fn optimal_block_length(x: &[f64]) -> OptimalBlockLength {
82 let n = x.len();
83 assert!(
84 n >= 10,
85 "Need at least 10 observations for block length estimation"
86 );
87
88 // =========================================================================
89 // Step 1: Center the data
90 // =========================================================================
91 let mean = x.iter().sum::<f64>() / n as f64;
92 let centered: Vec<f64> = x.iter().map(|&xi| xi - mean).collect();
93
94 // =========================================================================
95 // Step 2: Compute tuning parameters
96 // =========================================================================
97
98 // Maximum allowed block length: min(3√n, n/3)
99 // Prevents blocks from being too large relative to sample size
100 let max_block_length = math::ceil((3.0 * math::sqrt(n as f64)).min(n as f64 / 3.0));
101
102 // k_n: number of consecutive insignificant autocorrelations needed
103 // Scales slowly with n: max(5, log₁₀(n))
104 let consecutive_insignificant_needed = 5.max(math::log10(n as f64) as usize);
105
106 // m_max: maximum lag to consider for autocorrelation truncation
107 // Roughly √n + k_n to ensure we explore enough lags
108 let max_lag = math::ceil(math::sqrt(n as f64)) as usize + consecutive_insignificant_needed;
109
110 // Critical value for insignificance test: ±2√(log₁₀(n)/n)
111 // Conservative bound that scales appropriately with sample size
112 let insignificance_threshold = 2.0 * math::sqrt(math::log10(n as f64) / n as f64);
113
114 // =========================================================================
115 // Step 3: Compute autocovariances and find truncation lag
116 // =========================================================================
117
118 // Storage for autocovariances γ(k) and |ρ(k)| (absolute autocorrelations)
119 let mut autocovariances = vec![0.0; max_lag + 1];
120 let mut abs_autocorrelations = vec![0.0; max_lag + 1];
121
122 // Track when we first find k_n consecutive insignificant autocorrelations
123 let mut first_insignificant_run_start: Option<usize> = None;
124
125 for lag in 0..=max_lag {
126 // Need at least lag+1 observations for the cross-product
127 if lag + 1 >= n {
128 break;
129 }
130
131 // Compute variance of the leading and trailing segments
132 // These are used to normalize the cross-product into a correlation
133 let leading_segment = ¢ered[lag + 1..]; // x_{lag+1}, ..., x_n
134 let trailing_segment = ¢ered[..n - lag - 1]; // x_1, ..., x_{n-lag-1}
135
136 let variance_leading: f64 = leading_segment.iter().map(|e| e * e).sum();
137 let variance_trailing: f64 = trailing_segment.iter().map(|e| e * e).sum();
138
139 // Cross-product: Σ_{k=lag}^{n-1} x_k * x_{k-lag}
140 let cross_product: f64 = centered[lag..]
141 .iter()
142 .zip(centered[..n - lag].iter())
143 .map(|(&a, &b)| a * b)
144 .sum();
145
146 // Store autocovariance: γ(lag) = (1/n) * Σ (x_t - μ)(x_{t-lag} - μ)
147 autocovariances[lag] = cross_product / n as f64;
148
149 // Store absolute autocorrelation: |ρ(lag)| = |cross_product| / √(var_lead * var_trail)
150 let denominator = math::sqrt(variance_leading * variance_trailing);
151 abs_autocorrelations[lag] = if denominator > 0.0 {
152 cross_product.abs() / denominator
153 } else {
154 0.0
155 };
156
157 // Check if we've found k_n consecutive insignificant autocorrelations
158 if lag >= consecutive_insignificant_needed && first_insignificant_run_start.is_none() {
159 let recent_autocorrelations =
160 &abs_autocorrelations[lag - consecutive_insignificant_needed..lag];
161 let all_insignificant = recent_autocorrelations
162 .iter()
163 .all(|&r| r < insignificance_threshold);
164
165 if all_insignificant {
166 // The run of insignificant autocorrelations starts k_n lags ago
167 first_insignificant_run_start = Some(lag - consecutive_insignificant_needed);
168 }
169 }
170 }
171
172 // =========================================================================
173 // Step 4: Determine truncation lag m
174 // =========================================================================
175
176 // If we found a run of insignificant autocorrelations, use 2 * (start of run)
177 // Otherwise, fall back to max_lag
178 let truncation_lag = match first_insignificant_run_start {
179 Some(start) => (2 * start.max(1)).min(max_lag),
180 None => max_lag,
181 };
182
183 // =========================================================================
184 // Step 5: Compute spectral quantities using flat-top kernel
185 // =========================================================================
186
187 // g: weighted sum of lag * autocovariance (related to derivative of spectrum)
188 // long_run_variance: weighted sum of autocovariances (spectrum at frequency 0)
189
190 let mut g = 0.0; // Σ λ(k/m) * k * γ(k) for k ≠ 0
191 let mut long_run_variance = autocovariances[0]; // Start with γ(0)
192
193 for (lag, &acv) in autocovariances[1..=truncation_lag].iter().enumerate() {
194 let lag = lag + 1; // Adjust since we started from index 1
195
196 // Flat-top (trapezoidal) kernel:
197 // λ(x) = 1 if |x| ≤ 0.5
198 // λ(x) = 2(1 - |x|) if 0.5 < |x| ≤ 1
199 // λ(x) = 0 otherwise
200 let kernel_arg = lag as f64 / truncation_lag as f64;
201 let kernel_weight = if kernel_arg <= 0.5 {
202 1.0
203 } else {
204 2.0 * (1.0 - kernel_arg)
205 };
206
207 // g accumulates kernel-weighted lag * autocovariance
208 // Factor of 2 accounts for both positive and negative lags (symmetry)
209 g += 2.0 * kernel_weight * lag as f64 * acv;
210
211 // Long-run variance accumulates kernel-weighted autocovariances
212 long_run_variance += 2.0 * kernel_weight * acv;
213 }
214
215 // =========================================================================
216 // Step 6: Compute optimal block lengths
217 // =========================================================================
218
219 // The MSE-optimal block length formula:
220 // b_opt = ((2 * g²) / d)^(1/3) * n^(1/3)
221 //
222 // where d depends on the bootstrap type:
223 // - Stationary bootstrap: d = 2 * σ⁴
224 // - Circular block bootstrap: d = (4/3) * σ⁴
225
226 let variance_squared = math::sq(long_run_variance);
227
228 // Constants for each bootstrap type
229 let d_stationary = 2.0 * variance_squared;
230 let d_circular = (4.0 / 3.0) * variance_squared;
231
232 // Compute block lengths, handling degenerate cases
233 let n_cuberoot = math::cbrt(n as f64);
234
235 let block_stationary = if d_stationary > 0.0 {
236 let ratio = (2.0 * math::sq(g)) / d_stationary;
237 math::cbrt(ratio) * n_cuberoot
238 } else {
239 // Degenerate case: no dependence or zero variance
240 1.0
241 };
242
243 let block_circular = if d_circular > 0.0 {
244 let ratio = (2.0 * math::sq(g)) / d_circular;
245 math::cbrt(ratio) * n_cuberoot
246 } else {
247 1.0
248 };
249
250 // Apply upper bound to prevent unreasonably large blocks
251 OptimalBlockLength {
252 stationary: block_stationary.min(max_block_length),
253 circular: block_circular.min(max_block_length),
254 }
255}
256
257/// Compute optimal block length for paired time series (for timing oracle).
258///
259/// When analyzing timing differences between two classes, we need a block length
260/// that accounts for the dependence structure in both series. This function
261/// computes optimal block lengths for each series and returns the maximum,
262/// ensuring we adequately capture the temporal dependence in both.
263///
264/// # Arguments
265///
266/// * `baseline` - Timing measurements for baseline class
267/// * `sample` - Timing measurements for sample class
268///
269/// # Returns
270///
271/// The ceiling of the maximum circular bootstrap block length from both series.
272/// Uses the circular bootstrap estimate as it's more appropriate for the
273/// fixed-block resampling used in timing oracle.
274pub fn paired_optimal_block_length(baseline: &[f64], sample: &[f64]) -> usize {
275 let opt_baseline = optimal_block_length(baseline);
276 let opt_sample = optimal_block_length(sample);
277
278 // Take the maximum to ensure we capture dependence in both series
279 // Use circular estimate since our bootstrap uses fixed-size blocks
280 let max_circular = opt_baseline.circular.max(opt_sample.circular);
281
282 // Return ceiling, with minimum of 1
283 math::ceil(max_circular).max(1.0) as usize
284}
285
286/// Minimum block length floor (spec §3.3.2 Step 4).
287const BLOCK_LENGTH_FLOOR: usize = 10;
288
289/// Compute optimal block length using class-conditional acquisition-lag ACF (spec §3.3.2).
290///
291/// This is the recommended approach for interleaved timing streams. Computing ACF
292/// on the pooled stream directly is anti-conservative because class alternation
293/// masks within-class autocorrelation, leading to underestimated block lengths
294/// and inflated false positive rates.
295///
296/// # Algorithm
297///
298/// 1. For each lag k, compute autocorrelation using only same-class pairs:
299/// - ρ^(F)_k = corr(y_t, y_{t+k} | c_t = c_{t+k} = Baseline)
300/// - ρ^(R)_k = corr(y_t, y_{t+k} | c_t = c_{t+k} = Sample)
301/// 2. Combine conservatively: |ρ^(max)_k| = max(|ρ^(F)_k|, |ρ^(R)_k|)
302/// 3. Run Politis-White on the combined ACF
303/// 4. Apply safety floor: b ← max(b, 10)
304///
305/// # Arguments
306///
307/// * `stream` - Interleaved acquisition stream with class labels
308/// * `is_fragile` - If true, apply inflation factor for fragile regimes
309///
310/// # Returns
311///
312/// Optimal block length as usize, with safety floor applied.
313pub fn class_conditional_optimal_block_length(stream: &[TimingSample], is_fragile: bool) -> usize {
314 let n = stream.len();
315 if n < 20 {
316 // Not enough data for meaningful ACF estimation
317 return BLOCK_LENGTH_FLOOR;
318 }
319
320 // Compute class-conditional ACF and autocovariances
321 let (max_abs_acf, max_acv, truncation_lag) = compute_class_conditional_acf(stream);
322
323 if truncation_lag == 0 || max_acv.is_empty() {
324 return BLOCK_LENGTH_FLOOR;
325 }
326
327 // Run Politis-White on the combined ACF/autocovariances
328 let block_length = politis_white_from_acf(&max_abs_acf, &max_acv, truncation_lag, n);
329
330 // Apply safety floor (spec §3.3.2 Step 4)
331 let mut result = (math::ceil(block_length) as usize).max(BLOCK_LENGTH_FLOOR);
332
333 // Apply inflation factor for fragile regimes (spec §3.3.2 Step 4)
334 if is_fragile {
335 result = math::ceil((result as f64) * 1.5) as usize;
336 }
337
338 // Cap at reasonable maximum
339 let max_block = ((3.0 * math::sqrt(n as f64)).min(n as f64 / 3.0)) as usize;
340 result.min(max_block).max(BLOCK_LENGTH_FLOOR)
341}
342
343/// Compute class-conditional ACF at acquisition-stream lags.
344///
345/// Returns (max_abs_acf, max_autocovariances, truncation_lag).
346fn compute_class_conditional_acf(stream: &[TimingSample]) -> (Vec<f64>, Vec<f64>, usize) {
347 let n = stream.len();
348
349 // Compute per-class means for centering
350 let (sum_f, count_f, sum_r, count_r) =
351 stream
352 .iter()
353 .fold((0.0, 0usize, 0.0, 0usize), |(sf, cf, sr, cr), s| {
354 match s.class {
355 Class::Baseline => (sf + s.time_ns, cf + 1, sr, cr),
356 Class::Sample => (sf, cf, sr + s.time_ns, cr + 1),
357 }
358 });
359
360 if count_f < 5 || count_r < 5 {
361 return (vec![], vec![], 0);
362 }
363
364 let mean_f = sum_f / count_f as f64;
365 let mean_r = sum_r / count_r as f64;
366
367 // Compute per-class variances
368 let var_f: f64 = stream
369 .iter()
370 .filter(|s| s.class == Class::Baseline)
371 .map(|s| math::sq(s.time_ns - mean_f))
372 .sum::<f64>()
373 / count_f as f64;
374
375 let var_r: f64 = stream
376 .iter()
377 .filter(|s| s.class == Class::Sample)
378 .map(|s| math::sq(s.time_ns - mean_r))
379 .sum::<f64>()
380 / count_r as f64;
381
382 if var_f < 1e-12 || var_r < 1e-12 {
383 return (vec![], vec![], 0);
384 }
385
386 // Tuning parameters (same as standard Politis-White)
387 let consecutive_insignificant_needed = 5.max(math::log10(n as f64) as usize);
388 let max_lag =
389 (math::ceil(math::sqrt(n as f64)) as usize + consecutive_insignificant_needed).min(n / 2);
390 let insignificance_threshold = 2.0 * math::sqrt(math::log10(n as f64) / n as f64);
391
392 let mut max_abs_acf = vec![0.0; max_lag + 1];
393 let mut max_acv = vec![0.0; max_lag + 1];
394 let mut first_insignificant_run_start: Option<usize> = None;
395
396 // Lag 0: variance (autocorrelation = 1)
397 max_abs_acf[0] = 1.0;
398 max_acv[0] = var_f.max(var_r);
399
400 for lag in 1..=max_lag {
401 // Compute class-conditional autocorrelation at this lag
402 let (acf_f, acv_f) = compute_single_lag_acf(stream, lag, Class::Baseline, mean_f, var_f);
403 let (acf_r, acv_r) = compute_single_lag_acf(stream, lag, Class::Sample, mean_r, var_r);
404
405 // Take conservative max of absolute values
406 let abs_acf_f = acf_f.abs();
407 let abs_acf_r = acf_r.abs();
408
409 if abs_acf_f >= abs_acf_r {
410 max_abs_acf[lag] = abs_acf_f;
411 max_acv[lag] = acv_f;
412 } else {
413 max_abs_acf[lag] = abs_acf_r;
414 max_acv[lag] = acv_r;
415 }
416
417 // Check for run of insignificant autocorrelations
418 if lag >= consecutive_insignificant_needed && first_insignificant_run_start.is_none() {
419 let recent = &max_abs_acf[lag - consecutive_insignificant_needed..lag];
420 if recent.iter().all(|&r| r < insignificance_threshold) {
421 first_insignificant_run_start = Some(lag - consecutive_insignificant_needed);
422 }
423 }
424 }
425
426 let truncation_lag = match first_insignificant_run_start {
427 Some(start) => (2 * start.max(1)).min(max_lag),
428 None => max_lag,
429 };
430
431 (max_abs_acf, max_acv, truncation_lag)
432}
433
434/// Compute autocorrelation and autocovariance at a single lag for one class.
435fn compute_single_lag_acf(
436 stream: &[TimingSample],
437 lag: usize,
438 class: Class,
439 mean: f64,
440 var: f64,
441) -> (f64, f64) {
442 let n = stream.len();
443 if lag >= n {
444 return (0.0, 0.0);
445 }
446
447 // Find all pairs (t, t+lag) where both belong to the target class
448 let mut cross_sum = 0.0;
449 let mut pair_count = 0usize;
450
451 for t in 0..(n - lag) {
452 if stream[t].class == class && stream[t + lag].class == class {
453 let x_t = stream[t].time_ns - mean;
454 let x_t_lag = stream[t + lag].time_ns - mean;
455 cross_sum += x_t * x_t_lag;
456 pair_count += 1;
457 }
458 }
459
460 if pair_count < 3 {
461 // Not enough pairs for reliable estimate
462 return (0.0, 0.0);
463 }
464
465 let autocovariance = cross_sum / pair_count as f64;
466 let autocorrelation = if var > 1e-12 {
467 autocovariance / var
468 } else {
469 0.0
470 };
471
472 (autocorrelation, autocovariance)
473}
474
475/// Run Politis-White algorithm given pre-computed ACF and autocovariances.
476fn politis_white_from_acf(
477 _abs_acf: &[f64],
478 autocovariances: &[f64],
479 truncation_lag: usize,
480 n: usize,
481) -> f64 {
482 if truncation_lag == 0 || autocovariances.is_empty() {
483 return BLOCK_LENGTH_FLOOR as f64;
484 }
485
486 // Compute spectral quantities using flat-top kernel
487 let mut g = 0.0;
488 let mut long_run_variance = autocovariances[0];
489
490 for (lag, &acv) in autocovariances
491 .iter()
492 .enumerate()
493 .skip(1)
494 .take(truncation_lag.min(autocovariances.len() - 1))
495 {
496 let kernel_arg = lag as f64 / truncation_lag as f64;
497 let kernel_weight = if kernel_arg <= 0.5 {
498 1.0
499 } else {
500 2.0 * (1.0 - kernel_arg)
501 };
502
503 g += 2.0 * kernel_weight * lag as f64 * acv;
504 long_run_variance += 2.0 * kernel_weight * acv;
505 }
506
507 // Compute optimal block length (circular bootstrap formula)
508 let variance_squared = math::sq(long_run_variance);
509 let d_circular = (4.0 / 3.0) * variance_squared;
510
511 if d_circular > 1e-12 {
512 let ratio = (2.0 * math::sq(g)) / d_circular;
513 let n_cuberoot = math::cbrt(n as f64);
514 math::cbrt(ratio) * n_cuberoot
515 } else {
516 BLOCK_LENGTH_FLOOR as f64
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use rand::Rng;
524 use rand::SeedableRng;
525 use rand_xoshiro::Xoshiro256PlusPlus;
526
527 /// Generate an AR(1) process: x_t = φ * x_{t-1} + ε_t
528 fn generate_ar1(n: usize, phi: f64, seed: u64) -> Vec<f64> {
529 let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
530
531 let mut x = vec![0.0; n];
532 x[0] = rng.random::<f64>() - 0.5;
533
534 for i in 1..n {
535 let innovation = rng.random::<f64>() - 0.5;
536 x[i] = phi * x[i - 1] + innovation;
537 }
538
539 x
540 }
541
542 #[test]
543 fn test_iid_data_small_block() {
544 // IID data should have small optimal block length (close to 1)
545 let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
546 let x: Vec<f64> = (0..500).map(|_| rng.random::<f64>()).collect();
547
548 let opt = optimal_block_length(&x);
549
550 // IID data should give small block lengths
551 assert!(
552 opt.stationary < 10.0,
553 "IID stationary block {} should be small",
554 opt.stationary
555 );
556 assert!(
557 opt.circular < 10.0,
558 "IID circular block {} should be small",
559 opt.circular
560 );
561 }
562
563 #[test]
564 fn test_ar1_moderate_dependence() {
565 // AR(1) with φ=0.5 should have moderate block length
566 let x = generate_ar1(500, 0.5, 123);
567
568 let opt = optimal_block_length(&x);
569
570 // Moderate dependence: expect blocks in range [3, 30]
571 assert!(
572 opt.stationary > 2.0 && opt.stationary < 40.0,
573 "AR(1) φ=0.5 stationary block {} outside expected range",
574 opt.stationary
575 );
576 }
577
578 #[test]
579 fn test_ar1_strong_dependence() {
580 // AR(1) with φ=0.9 should have larger block length
581 let x = generate_ar1(500, 0.9, 456);
582
583 let opt = optimal_block_length(&x);
584
585 // Strong dependence: expect larger blocks than moderate case
586 assert!(
587 opt.stationary > 5.0,
588 "AR(1) φ=0.9 stationary block {} should be substantial",
589 opt.stationary
590 );
591 }
592
593 #[test]
594 fn test_stationary_vs_circular() {
595 // Circular block length should be larger than stationary
596 // (stationary uses d=2σ⁴, circular uses d=(4/3)σ⁴)
597 // Same numerator, smaller denominator for circular → larger block
598 let x = generate_ar1(500, 0.6, 789);
599
600 let opt = optimal_block_length(&x);
601
602 // Due to the formula, circular should be (2 / (4/3))^(1/3) ≈ 1.14× larger
603 let expected_ratio = (2.0_f64 / (4.0 / 3.0)).powf(1.0 / 3.0);
604
605 let actual_ratio = opt.circular / opt.stationary;
606
607 assert!(
608 (actual_ratio - expected_ratio).abs() < 0.01,
609 "Circular/stationary ratio {} should be ~{}",
610 actual_ratio,
611 expected_ratio
612 );
613 }
614
615 #[test]
616 fn test_paired_optimal_takes_max() {
617 // Paired function should return max of both series
618 let x = generate_ar1(500, 0.9, 111); // High dependence
619 let y = generate_ar1(500, 0.3, 222); // Low dependence
620
621 let paired = paired_optimal_block_length(&x, &y);
622
623 let opt_x = optimal_block_length(&x);
624 let opt_y = optimal_block_length(&y);
625
626 let expected = math::ceil(opt_x.circular.max(opt_y.circular)) as usize;
627
628 assert_eq!(
629 paired, expected,
630 "Paired block length {} should equal max of individual circular estimates {}",
631 paired, expected
632 );
633 }
634
635 #[test]
636 fn test_minimum_sample_size() {
637 // Should work with minimum sample size
638 let mut rng = Xoshiro256PlusPlus::seed_from_u64(999);
639 let x: Vec<f64> = (0..10).map(|_| rng.random::<f64>()).collect();
640
641 let opt = optimal_block_length(&x);
642
643 // Just verify it returns something reasonable
644 assert!(opt.stationary >= 1.0, "Block length should be at least 1");
645 assert!(
646 opt.circular <= 10.0,
647 "Block length should not exceed sample size"
648 );
649 }
650
651 #[test]
652 #[should_panic(expected = "Need at least 10 observations")]
653 fn test_insufficient_samples_panics() {
654 let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
655 let _ = optimal_block_length(&x);
656 }
657
658 #[test]
659 fn test_constant_series() {
660 // Constant series has zero autocovariance for lag > 0
661 let x = vec![42.0; 100];
662
663 let opt = optimal_block_length(&x);
664
665 // Should fall back to 1 (degenerate case)
666 assert_eq!(opt.stationary, 1.0, "Constant series should give block = 1");
667 assert_eq!(opt.circular, 1.0, "Constant series should give block = 1");
668 }
669
670 #[test]
671 fn test_deterministic_results() {
672 // Same input should give same output
673 let x = generate_ar1(500, 0.5, 42);
674
675 let opt1 = optimal_block_length(&x);
676 let opt2 = optimal_block_length(&x);
677
678 assert_eq!(opt1.stationary, opt2.stationary, "Should be deterministic");
679 assert_eq!(opt1.circular, opt2.circular, "Should be deterministic");
680 }
681}