Skip to main content

tsorder_core/
threshold.rs

1//! Vanishing thresholds `τ_n` and growing memory `p_n` for Fine–Hwang estimators.
2//!
3//! Thresholds are expressed on the *studentized* scale: lag `k` survives iff
4//! `|stat_k / se_k| > τ_n`. On the raw scale of Fine & Hwang this is
5//! `η_n = τ_n · se_k ≈ τ_n / sqrt(n)`.
6//!
7//! **Which theory justifies which threshold.** Hwang's (1978, Thm 5.3) sufficient
8//! condition `p_n / (n η_n²) → 0` translates to `τ_n² / p_n → ∞`, i.e. the threshold
9//! must grow *faster than `sqrt(p_n)`*. None of the `log n`-type thresholds below
10//! satisfies it with `p_n = n^0.4` (the ratio `p_n/τ_n²` grows from 0.9 at n=100 to
11//! 5.8 at n=10⁵). Hwang's condition comes from a Chebyshev bound and is far from
12//! necessary; the `sqrt(c log n)` and Gumbel-type thresholds are instead justified by
13//! maximal inequalities / extreme-value limits for the vector of long-AR estimates —
14//! see Jirak (2012, *Ann. Statist.*), who proves a Gumbel limit for
15//! `max_{i ≤ d_n} |θ̂_i − θ_i|` with `d_n = O(n^δ)` and derives consistent band-based
16//! order estimators from it.
17
18use crate::{Error, Result};
19
20/// Threshold rule on the studentized scale.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum Threshold {
23    /// `sqrt(c · log n)`. With `c = 1` this equals the per-increment threshold implicit in BIC
24    /// (adjacent-order BIC difference is negative iff `t_k² > log n` to first order) — this is
25    /// *not* the same as BIC's global argmin. Requires `c > 0`.
26    BicLike { c: f64 },
27    /// `sqrt(2 c · log log n)`: Hannan–Quinn / law-of-the-iterated-logarithm rate. Requires `c > 0`;
28    /// consistency arguments need `c > 1`.
29    Lil { c: f64 },
30    /// `sqrt(2 · log p_n) + c`: Bonferroni/extreme-value correction for the number of lags
31    /// inspected — the natural companion of Jirak's (2012) Gumbel limit. Requires `c ≥ 0`.
32    MaxOfK { c: f64 },
33    /// Fixed value on the studentized scale, e.g. `1.96` reproduces the Box–Jenkins band
34    /// (not consistent; for benchmarking). Requires a finite value `≥ 0`.
35    Fixed(f64),
36    /// Level-calibrated threshold: the `1−alpha` quantile of the Gumbel limit of
37    /// `max_{k ≤ p_n} |Z_k|`, `Z_k` approximately standard normal, i.e.
38    /// `b_p + a_p · (−log(−log(1−alpha)))` with `a_p = 1/sqrt(2 log p)` and
39    /// `b_p = sqrt(2 log p) − (log log p + log π) / (2 sqrt(2 log p))` (absolute values ⇒ `2p` tails).
40    /// With a fixed `alpha` this controls the family-wise overestimation probability at ≈ `alpha`
41    /// (not consistent); with `alpha = alpha_n → 0` slowly it is the Jirak-type consistent rule.
42    /// Jirak (2012) gives the exact normalising constants for Yule–Walker estimates
43    /// (see `Statistic::JirakBand`); this generic form uses the universal Gumbel constants.
44    /// Requires `0 < alpha < 1`.
45    Gumbel { alpha: f64 },
46}
47
48impl Default for Threshold {
49    /// `BicLike { c: 1.5 }` — matched BIC in the 2026-10-23 simulations. Chosen empirically;
50    /// not derived from a theorem.
51    fn default() -> Self {
52        Threshold::BicLike { c: 1.5 }
53    }
54}
55
56impl Threshold {
57    /// Reject parameters that would make the rule meaningless (negative or NaN constants).
58    pub fn validate(self) -> Result<()> {
59        let ok = match self {
60            Threshold::BicLike { c } | Threshold::Lil { c } => c.is_finite() && c > 0.0,
61            Threshold::MaxOfK { c } => c.is_finite() && c >= 0.0,
62            Threshold::Fixed(v) => v.is_finite() && v >= 0.0,
63            Threshold::Gumbel { alpha } => alpha.is_finite() && alpha > 0.0 && alpha < 1.0,
64        };
65        if ok {
66            Ok(())
67        } else {
68            Err(Error::Invalid(
69                "threshold constant must be finite and positive (BicLike/Lil) or non-negative (MaxOfK/Fixed)",
70            ))
71        }
72    }
73
74    /// Numerical threshold for sample size `n` and memory `p_n`.
75    pub fn value(self, n: usize, p_n: usize) -> f64 {
76        let nf = (n.max(3)) as f64;
77        match self {
78            Threshold::BicLike { c } => (c * nf.ln()).sqrt(),
79            Threshold::Lil { c } => (2.0 * c * nf.ln().ln().max(0.0)).sqrt(),
80            Threshold::MaxOfK { c } => (2.0 * (p_n.max(2) as f64).ln()).sqrt() + c,
81            Threshold::Fixed(v) => v,
82            Threshold::Gumbel { alpha } => {
83                // max of p |N(0,1)| ≈ max of 2p one-sided tails
84                let m = (2 * p_n.max(2)) as f64;
85                let l = (2.0 * m.ln()).sqrt();
86                let a = 1.0 / l;
87                let b = l - (m.ln().ln() + (4.0 * core::f64::consts::PI).ln()) / (2.0 * l);
88                b + a * (-(-(1.0 - alpha).ln()).ln())
89            }
90        }
91    }
92
93    pub fn name(self) -> String {
94        match self {
95            Threshold::BicLike { c } => format!("bic(c={c})"),
96            Threshold::Lil { c } => format!("lil(c={c})"),
97            Threshold::MaxOfK { c } => format!("maxk(c={c})"),
98            Threshold::Fixed(v) => format!("fixed({v})"),
99            Threshold::Gumbel { alpha } => format!("gumbel(alpha={alpha})"),
100        }
101    }
102}
103
104/// Poly-logarithmic memory `p_n = ceil(c · log n)`, clamped to `[2, n/4]`. This is the window
105/// size covered by the uniform law-of-the-iterated-logarithm bounds for sample autocovariances
106/// (Hannan & Deistler 1988, Thm 5.3.2; Bauer 2001, Lemma 1), hence a candidate default
107/// pending theory work. `c = 2` gives 9 at n=100, 14 at n=1000, 19 at n=10⁴.
108pub fn polylog_memory(n: usize, c: f64) -> usize {
109    let p = (c * (n.max(3) as f64).ln()).ceil() as usize;
110    p.clamp(2, (n / 4).max(2))
111}
112
113/// How the growing memory `p_n` is chosen; the default was fixed by
114/// benchmark. [`Memory::resolve`] turns it into a concrete window for a
115/// sample size.
116#[derive(Debug, Clone, Copy, PartialEq, Default)]
117pub enum Memory {
118    /// The statistic's own default: `⌈2 log n⌉` for most statistics, the
119    /// shorter `⌈log n⌉` for the robust PACF (see
120    /// `Statistic::default_memory`).
121    #[default]
122    Default,
123    /// `⌊n^alpha⌋` — the Fine–Hwang-style power rule ([`power_memory`] is
124    /// the `alpha = 0.4` special case).
125    PowerLaw { alpha: f64 },
126    /// `⌈c log n⌉` ([`polylog_memory`]), the window covered by the uniform
127    /// LIL bounds.
128    PolyLog { c: f64 },
129    /// A fixed window.
130    Fixed(usize),
131}
132
133impl Memory {
134    /// The concrete window, clamped to `[2, n/4]` except for [`Memory::Fixed`].
135    pub fn resolve(&self, n: usize, statistic_default: usize) -> usize {
136        match self {
137            Memory::Default => statistic_default,
138            Memory::PowerLaw { alpha } => {
139                let p = (n as f64).powf(*alpha).floor() as usize;
140                p.clamp(2, (n / 4).max(2))
141            }
142            Memory::PolyLog { c } => polylog_memory(n, *c),
143            Memory::Fixed(k) => *k,
144        }
145    }
146}
147
148/// Power-law growing memory `p_n = floor(n^0.4)`, clamped to `[2, n/4]` —
149/// the Fine–Hwang-style window. Satisfies `p_n²/n → 0` (Hwang) but not
150/// `p_n³/n → 0` (Berk 1974 / Lewis–Reinsel 1985 uniform results). It was the
151/// package default until the benchmark showed the poly-log window dominating
152/// it (notably on seasonal AR, where `n^0.4 < 12` until `n ≈ 800`); the
153/// default is now [`polylog_memory`]`(n, 2.0)` via
154/// [`crate::Statistic::default_memory`]. Kept as an option and for the
155/// benchmark.
156pub fn power_memory(n: usize) -> usize {
157    let p = (n as f64).powf(0.4).floor() as usize;
158    p.clamp(2, (n / 4).max(2))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn invalid_constants_rejected() {
167        assert!(Threshold::BicLike { c: -1.0 }.validate().is_err());
168        assert!(Threshold::BicLike { c: 0.0 }.validate().is_err());
169        assert!(Threshold::Lil { c: f64::NAN }.validate().is_err());
170        assert!(Threshold::Fixed(-5.0).validate().is_err());
171        assert!(Threshold::Fixed(1.96).validate().is_ok());
172        assert!(Threshold::MaxOfK { c: 0.0 }.validate().is_ok());
173        assert!(Threshold::Gumbel { alpha: 0.0 }.validate().is_err());
174        assert!(Threshold::Gumbel { alpha: 1.0 }.validate().is_err());
175        assert!(Threshold::Gumbel { alpha: 0.05 }.validate().is_ok());
176    }
177
178    #[test]
179    fn gumbel_threshold_is_sane() {
180        // For p=10 lags at alpha=0.05 the Bonferroni value is Φ^{-1}(1-0.05/20) ≈ 2.81;
181        // the Gumbel approximation should be in the same neighbourhood, and increase with p
182        // and with smaller alpha.
183        let t10 = Threshold::Gumbel { alpha: 0.05 }.value(1000, 10);
184        assert!(t10 > 2.3 && t10 < 3.3, "{t10}");
185        assert!(Threshold::Gumbel { alpha: 0.05 }.value(1000, 100) > t10);
186        assert!(Threshold::Gumbel { alpha: 0.001 }.value(1000, 10) > t10);
187    }
188
189    #[test]
190    fn memory_rules_resolve() {
191        assert_eq!(Memory::Default.resolve(1000, 14), 14);
192        assert_eq!(
193            Memory::PowerLaw { alpha: 0.4 }.resolve(1000, 14),
194            power_memory(1000)
195        );
196        assert_eq!(
197            Memory::PolyLog { c: 2.0 }.resolve(1000, 14),
198            polylog_memory(1000, 2.0)
199        );
200        assert_eq!(Memory::Fixed(7).resolve(1000, 14), 7);
201        assert_eq!(Memory::default(), Memory::Default);
202    }
203
204    #[test]
205    fn polylog_memory_grows_slowly() {
206        assert_eq!(polylog_memory(100, 2.0), 10);
207        assert!(polylog_memory(10_000, 2.0) < power_memory(10_000));
208        assert!(polylog_memory(10, 2.0) >= 2);
209    }
210}