Skip to main content

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 = &centered[lag + 1..]; // x_{lag+1}, ..., x_n
134        let trailing_segment = &centered[..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}