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}