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}