Skip to main content

tsorder_core/
hankel.rs

1//! McMillan degree by singular-value thresholding of the Hankel matrix of
2//! sample autocovariances (M3 route 2).
3//!
4//! The rank of the autocovariance Hankel `H[i][j] = γ(i+j+1)` is the state
5//! dimension. A raw SVD cut fails because the threshold must carry the scale
6//! of the estimation error (the day-1 finding); following the calibration
7//! lesson of Zheng, Jedra & Proutière (2025, arXiv:2506.08720), the cut is
8//!
9//! `ξ_n = c · ŝ · sqrt(K · log n / n)`,
10//!
11//! where `ŝ² = 2 Σ_{|h| ≤ 2K} γ̂(h)²` is a Bartlett-style plug-in bound on the
12//! per-entry error variance `n·Var(γ̂(h)) ≈ Σ_k γ(k)² + γ(k+h)γ(k−h)`, and
13//! `sqrt(K)` is the random-matrix scaling of the operator norm of an
14//! entrywise-noise perturbation (the worst-case factor `K` proved hopelessly
15//! conservative in calibration: it underestimated everywhere). Recovery
16//! additionally needs *separation*: the smallest non-zero singular value of
17//! the true Hankel must exceed `ξ_n` — e.g. MA(2) with θ = (0.6, 0.3) has
18//! true Hankel singular values ≈ (0.88, 0.10), and 0.10 sits below any
19//! honest threshold at practical `n`; that is the problem being hard, not
20//! the threshold failing.
21
22use crate::acov::autocovariance;
23use crate::dist::{imhof_sf, satterthwaite_sf};
24use crate::linalg::{singular_values, SquareMatrix};
25use crate::threshold::polylog_memory;
26use crate::Result;
27
28/// Configuration of [`hankel_svt_order`].
29#[derive(Debug, Clone, Copy)]
30pub struct HankelSvtConfig {
31    /// Hankel dimension `K`; `None` → `⌈2 log n⌉` ([`polylog_memory`]).
32    pub k: Option<usize>,
33    /// Threshold constant `c`. Empirical: at the default 1.0 the calibration
34    /// runs gave 100 % correct rank 0 on white noise, ~100 % on Bauer's
35    /// Example I, 96–98 % on AR(1) φ=0.9, ~90 % on ARMA(1,1) — and MA(2)
36    /// fails on separation, as expected.
37    pub c: f64,
38}
39
40impl Default for HankelSvtConfig {
41    fn default() -> Self {
42        HankelSvtConfig { k: None, c: 1.0 }
43    }
44}
45
46/// Output of [`hankel_svt_order`].
47#[derive(Debug, Clone)]
48pub struct HankelSvtResult {
49    /// Estimated McMillan degree: number of singular values above `xi`.
50    pub order: usize,
51    /// Singular values of the autocovariance Hankel, descending.
52    pub singular_values: Vec<f64>,
53    /// The threshold `ξ_n` actually used.
54    pub xi: f64,
55    pub k: usize,
56}
57
58/// Singular-value-thresholding estimator of the McMillan degree.
59pub fn hankel_svt_order(x: &[f64], config: HankelSvtConfig) -> Result<HankelSvtResult> {
60    let n = x.len();
61    let k = config.k.unwrap_or_else(|| polylog_memory(n, 2.0)).max(2);
62    let r = autocovariance(x, 2 * k)?;
63    let mut h = vec![0.0; k * k];
64    for i in 0..k {
65        for j in 0..k {
66            h[i * k + j] = r[i + j + 1];
67        }
68    }
69    let sv = singular_values(&h, k, k);
70    let s2 = 2.0 * r.iter().map(|g| g * g).sum::<f64>();
71    let xi = config.c * s2.sqrt() * ((k as f64) * (n as f64).ln() / n as f64).sqrt();
72    let order = sv.iter().take_while(|s| **s > xi).count();
73    Ok(HankelSvtResult {
74        order,
75        singular_values: sv,
76        xi,
77        k,
78    })
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn exact_ar1_hankel_has_rank_one_structure() {
87        // Exact AR(1) autocovariances: Hankel is rank 1, so σ_2 = 0 exactly.
88        let phi: f64 = 0.8;
89        let n = 10;
90        let r: Vec<f64> = (0..=2 * n)
91            .map(|h| phi.powi(h as i32) / (1.0 - phi * phi))
92            .collect();
93        let mut h = vec![0.0; n * n];
94        for i in 0..n {
95            for j in 0..n {
96                h[i * n + j] = r[i + j + 1];
97            }
98        }
99        let sv = singular_values(&h, n, n);
100        assert!(sv[0] > 1.0);
101        assert!(sv[1].abs() < 1e-10, "{sv:?}");
102    }
103}
104
105/// How the sequential rank test sets its level at sample size `n`.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub enum RankAlpha {
108    /// Fixed level (the rank estimate then over-rejects with probability α forever).
109    Fixed(f64),
110    /// `α_n = κ / log n` with `κ = 0.05·log 50` (so `α_50 = 0.05`) — the
111    /// Cragg–Donald rule used by Camba-Méndez & Kapetanios; satisfies the
112    /// Hosoya consistency condition `α_n → 0`, `log(α_n)/n → 0`.
113    LogDecay,
114}
115
116/// How the weighted-χ²₁ null p-value is computed.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RankPvalue {
119    /// Imhof's exact inversion (default).
120    Imhof,
121    /// Satterthwaite's two-moment approximation (faster, slightly rougher).
122    Satterthwaite,
123}
124
125/// Configuration of [`hankel_rank_order`].
126#[derive(Debug, Clone, Copy)]
127pub struct HankelRankConfig {
128    /// Hankel dimensions `k = p`; `None` → `max(3, ⌈log n⌉)`. Keep it small:
129    /// the null-weight covariance grows with `k + p` and is poorly estimated
130    /// when large (the lesson of Camba-Méndez & Kapetanios's `k = 4` runs).
131    pub k: Option<usize>,
132    pub alpha: RankAlpha,
133    pub pvalue: RankPvalue,
134}
135
136impl Default for HankelRankConfig {
137    fn default() -> Self {
138        HankelRankConfig {
139            k: None,
140            alpha: RankAlpha::LogDecay,
141            pvalue: RankPvalue::Imhof,
142        }
143    }
144}
145
146/// Output of [`hankel_rank_order`].
147#[derive(Debug, Clone)]
148pub struct HankelRankResult {
149    /// Estimated McMillan degree: first `r` whose test does not reject.
150    pub order: usize,
151    /// P-value of H₀: rank ≤ r, for r = 0..=order (the sequence actually run).
152    pub pvalues: Vec<f64>,
153    /// The level `α_n` used.
154    pub alpha: f64,
155    pub k: usize,
156}
157
158/// Sequential rank test on the autocovariance Hankel: Robin & Smith's (2000)
159/// characteristic-root statistic `CRT_r = n Σ_{i>r} σ̂_i²` (their Wald form,
160/// with identity weighting, so the roots are the squared singular values of
161/// the Hankel), with the null law `Σ λ_i χ²₁` of their Theorem 3.2. The
162/// weights are the eigenvalues of `(D ⊗ C)' Ω (D ⊗ C)` with `C, D` the
163/// null-space singular vectors; the Hankel's duplication structure reduces
164/// this to the small matrix `(L' W)' (L' W)` where `Ξ = L L'` is the
165/// Bartlett (Gaussian) covariance of the distinct sample autocovariances and
166/// `W[ℓ, (a,b)] = Σ_{i+j+1=ℓ} C[i,a] D[j,b]`. Stops at the first
167/// non-rejection (Camba-Méndez & Kapetanios's sequence, started at r = 0);
168/// with `RankAlpha::LogDecay` the estimate is consistent (Hosoya 1989).
169///
170/// Combining Robin–Smith's statistic with the autocovariance Hankel is this
171/// crate's assembly of published pieces (CK use Cragg–Donald's test instead);
172/// the lineage is Baram & Porat (1988) → Robin & Smith (2000).
173pub fn hankel_rank_order(x: &[f64], config: HankelRankConfig) -> Result<HankelRankResult> {
174    let n = x.len();
175    let k = config
176        .k
177        .unwrap_or_else(|| polylog_memory(n, 1.0).max(3))
178        .max(2);
179    let lags = 2 * k - 1; // distinct autocovariance lags 1..=2k-1
180    let big = 2 * lags; // window for the Bartlett formula plug-in
181    let r = autocovariance(x, big.min(n / 3))?;
182    let gamma = |h: i64| -> f64 {
183        let a = h.unsigned_abs() as usize;
184        if a < r.len() {
185            r[a]
186        } else {
187            0.0
188        }
189    };
190    // Hankel H[i][j] = γ̂(i+j+1), k × k.
191    let mut h = vec![0.0; k * k];
192    for i in 0..k {
193        for j in 0..k {
194            h[i * k + j] = r[i + j + 1];
195        }
196    }
197    // Bartlett covariance of the distinct lags: Ξ(g,h) =
198    // Σ_u γ(u)γ(u+g−h) + γ(u+g)γ(u−h), truncated at the available window.
199    let u_max = (r.len() - 1) as i64;
200    let mut xi = SquareMatrix::zeros(lags);
201    for a in 1..=lags {
202        for b in a..=lags {
203            let (ga, gb) = (a as i64, b as i64);
204            let mut s = 0.0;
205            for u in -u_max..=u_max {
206                s += gamma(u) * gamma(u + gb - ga) + gamma(u + gb) * gamma(u - ga);
207            }
208            xi.set(a - 1, b - 1, s);
209            xi.set(b - 1, a - 1, s);
210        }
211    }
212    // Cholesky of Ξ, with a small ridge if the truncated plug-in is not PD.
213    let lxi = match xi.cholesky() {
214        Ok(l) => l,
215        Err(_) => {
216            let ridge = 1e-8 * (1..=lags).map(|i| xi.get(i - 1, i - 1)).sum::<f64>() / lags as f64;
217            for i in 0..lags {
218                let v = xi.get(i, i) + ridge;
219                xi.set(i, i, v);
220            }
221            xi.cholesky()?
222        }
223    };
224    // Full SVD data: we need singular values and both singular-vector sets.
225    // One-sided Jacobi on H gives V (the rotations) and U = HV/σ; for these
226    // small matrices recompute vectors by orthogonalising columns of H'H and
227    // HH' via the same Jacobi pass is overkill — use the simple route:
228    // eigenvectors of H'H (right) and HH' (left) via Jacobi eigen below.
229    let (sv, u_vecs, v_vecs) = svd_small(&h, k);
230    let alpha = match config.alpha {
231        RankAlpha::Fixed(a) => a,
232        RankAlpha::LogDecay => 0.05 * 50f64.ln() / (n as f64).ln().max(1.0),
233    };
234    let mut pvalues = Vec::new();
235    let mut order = k; // if every stage rejects
236    for rr in 0..k {
237        let crt: f64 = (rr..k).map(|i| sv[i] * sv[i]).sum::<f64>() * n as f64;
238        // W[ℓ, (a,b)] over null vectors a, b = rr..k-1.
239        let m = k - rr;
240        let mut w = vec![0.0; lags * m * m];
241        for (ai, a) in (rr..k).enumerate() {
242            for (bi, b) in (rr..k).enumerate() {
243                let cidx = ai * m + bi;
244                for i in 0..k {
245                    for j in 0..k {
246                        let l = i + j + 1;
247                        w[(l - 1) * m * m + cidx] += u_vecs[i * k + a] * v_vecs[j * k + b];
248                    }
249                }
250            }
251        }
252        // Weights = squared singular values of L' W (lags × m²).
253        let mut lw = vec![0.0; lags * m * m];
254        for row in 0..lags {
255            for c in 0..m * m {
256                let mut s = 0.0;
257                for t in row..lags {
258                    s += lxi.get(t, row) * w[t * m * m + c];
259                }
260                lw[row * m * m + c] = s;
261            }
262        }
263        let wsv = singular_values(&lw, lags, m * m);
264        let weights: Vec<f64> = wsv.iter().map(|s| s * s).filter(|v| *v > 1e-12).collect();
265        let p = match config.pvalue {
266            RankPvalue::Imhof => imhof_sf(crt, &weights),
267            RankPvalue::Satterthwaite => satterthwaite_sf(crt, &weights),
268        };
269        pvalues.push(p);
270        if p >= alpha {
271            order = rr;
272            break;
273        }
274    }
275    Ok(HankelRankResult {
276        order,
277        pvalues,
278        alpha,
279        k,
280    })
281}
282
283/// SVD of a small dense `k × k` matrix: singular values (descending) plus
284/// left and right singular vectors (column-major: `u[i*k + a]` is component
285/// `i` of the `a`-th left vector). Jacobi eigendecomposition of `HᵀH` gives
286/// `V` and `σ²`; `U = H V / σ`, completed by Gram–Schmidt for null columns.
287fn svd_small(h: &[f64], k: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
288    // G = HᵀH.
289    let mut g = SquareMatrix::zeros(k);
290    for i in 0..k {
291        for j in 0..k {
292            let mut s = 0.0;
293            for t in 0..k {
294                s += h[t * k + i] * h[t * k + j];
295            }
296            g.set(i, j, s);
297        }
298    }
299    // Cyclic Jacobi eigendecomposition of the symmetric G.
300    let mut a = g;
301    let mut v = SquareMatrix::zeros(k);
302    for i in 0..k {
303        v.set(i, i, 1.0);
304    }
305    for _ in 0..100 {
306        let mut off = 0.0;
307        for i in 0..k {
308            for j in i + 1..k {
309                off += a.get(i, j).abs();
310            }
311        }
312        if off < 1e-13 {
313            break;
314        }
315        for i in 0..k {
316            for j in i + 1..k {
317                let apq = a.get(i, j);
318                if apq.abs() < 1e-300 {
319                    continue;
320                }
321                let (app, aqq) = (a.get(i, i), a.get(j, j));
322                let zeta = (aqq - app) / (2.0 * apq);
323                let t = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
324                let c = 1.0 / (1.0 + t * t).sqrt();
325                let s = c * t;
326                for l in 0..k {
327                    let (ail, ajl) = (a.get(i, l), a.get(j, l));
328                    a.set(i, l, c * ail - s * ajl);
329                    a.set(j, l, s * ail + c * ajl);
330                }
331                for l in 0..k {
332                    let (ali, alj) = (a.get(l, i), a.get(l, j));
333                    a.set(l, i, c * ali - s * alj);
334                    a.set(l, j, s * ali + c * alj);
335                }
336                for l in 0..k {
337                    let (vli, vlj) = (v.get(l, i), v.get(l, j));
338                    v.set(l, i, c * vli - s * vlj);
339                    v.set(l, j, s * vli + c * vlj);
340                }
341            }
342        }
343    }
344    // Sort by eigenvalue descending.
345    let mut idx: Vec<usize> = (0..k).collect();
346    idx.sort_by(|&p, &q| a.get(q, q).total_cmp(&a.get(p, p)));
347    let mut sv = Vec::with_capacity(k);
348    let mut vv = vec![0.0; k * k];
349    for (col, &p) in idx.iter().enumerate() {
350        sv.push(a.get(p, p).max(0.0).sqrt());
351        for i in 0..k {
352            vv[i * k + col] = v.get(i, p);
353        }
354    }
355    // U = H V / σ, Gram–Schmidt completion where σ ≈ 0.
356    let mut uu = vec![0.0; k * k];
357    for col in 0..k {
358        if sv[col] > 1e-12 * sv[0].max(1e-300) {
359            for i in 0..k {
360                let mut s = 0.0;
361                for j in 0..k {
362                    s += h[i * k + j] * vv[j * k + col];
363                }
364                uu[i * k + col] = s / sv[col];
365            }
366        } else {
367            // Any unit vector orthogonal to the previous columns.
368            for seed in 0..k {
369                for i in 0..k {
370                    uu[i * k + col] = if i == seed { 1.0 } else { 0.0 };
371                }
372                for prev in 0..col {
373                    let dot: f64 = (0..k).map(|i| uu[i * k + col] * uu[i * k + prev]).sum();
374                    for i in 0..k {
375                        uu[i * k + col] -= dot * uu[i * k + prev];
376                    }
377                }
378                let norm: f64 = (0..k).map(|i| uu[i * k + col].powi(2)).sum::<f64>().sqrt();
379                if norm > 1e-8 {
380                    for i in 0..k {
381                        uu[i * k + col] /= norm;
382                    }
383                    break;
384                }
385            }
386        }
387    }
388    (sv, uu, vv)
389}