Skip to main content

gam_terms/inference/
smooth_test.rs

1//! Wood-style smooth-component Wald tests.
2//!
3//! The test follows the rank-truncated covariance inverse used by Wood (2013):
4//! the term's coefficient block is mapped into fitted-value space by the
5//! design-whitening `R` (`RᵀR = X'WX`) and tested with a rank-`round(edf)`
6//! spectral pseudo-inverse of the whitened covariance `R·V·Rᵀ`. The whitening
7//! is essential — truncating the raw coefficient covariance keeps the
8//! largest-variance (heavily-penalized, signal-free) directions and discards
9//! the fitted function; whitening restores the generalized `(V, X'WX)`
10//! eigenbasis whose leading directions are the least-penalized modes that carry
11//! the fit (issue #2142). The reference degrees of freedom use the
12//! coefficient-space influence block `F_jj = (H⁻¹ X'WX)_jj`.
13//!
14//! Bartlett and Lawley mean corrections are likelihood-ratio corrections, so
15//! they are not applied here. In the ordinary unpenalized Gaussian model the
16//! Wald statistic satisfies `T / q ~ F(q, ν)` exactly, while under a ridge
17//! penalty even the one-parameter statistic becomes `(n / (n + λ))χ²₁` rather
18//! than a central χ²/F reference target.
19
20use gam_linalg::faer_ndarray::FaerEigh;
21use ndarray::{Array1, Array2, ArrayView1, s};
22use statrs::distribution::{ChiSquared, ContinuousCDF, FisherSnedecor};
23use std::ops::Range;
24
25/// Whether the residual dispersion `φ` is known or estimated from the
26/// fit.  Selects the reference distribution for the Wald p-value: `Known`
27/// → `χ²_{ref_df}` (e.g. binomial/Poisson), `Estimated` → `F_{ref_df,
28/// residual_df}` (e.g. Gaussian where `φ̂` carries its own sampling
29/// variability).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SmoothTestScale {
32    Known,
33    Estimated,
34}
35
36/// Inputs to `wood_smooth_test`. `beta` is the full coefficient vector;
37/// the term block being tested is `beta[coeff_range]`. `covariance` is the
38/// matching posterior covariance Σ̂ (full p×p; the diagonal block is sliced
39/// out). **`covariance` must be the scale-included posterior covariance**
40/// (mgcv `Vb`/`Vp`, i.e. `H⁻¹` already multiplied by the dispersion `φ̂`),
41/// so the Wald statistic `T = β̂'·Σ̂⁻·β̂` is dimensionless — the residual
42/// dispersion has already been divided out and the F-statistic is `T/ref_df`
43/// with *no* further `φ̂` factor. `influence_matrix` is the optional
44/// coefficient-space influence `F = H⁻¹ X'WX`; when present
45/// `tr(F_jj)² / tr(F_jj²)` is used as the Wood-corrected reference d.f.
46/// `whitening_gram` is the optional term-block-aligned weighted design Gram
47/// `G = X'WX` (`H − S(λ)`, full `p×p`, same coefficient layout as
48/// `covariance`); when present the covariance is mapped into the Wood (2013)
49/// *fitted-value* space `R·V·Rᵀ` (`RᵀR = G`) before the rank-`r` truncation, so
50/// the pseudo-inverse keeps the directions that carry the estimated function
51/// rather than the raw largest-variance (heavily-penalized) coefficient
52/// directions. When absent the raw coefficient covariance is truncated directly
53/// — a graceful fallback for persisted models whose Gram was not serialized.
54/// `edf` is the smooth's effective d.f. (rank of the truncated pseudo-inverse);
55/// `nullspace_dim` is the fixed-effect (unpenalized) leading dimension within
56/// the block, used as a floor on the truncation rank (those directions are
57/// never shrunk and must always be tested). `residual_df` is the denominator
58/// d.f. for the `Estimated`-scale F branch. It is `None` when that inference
59/// geometry is unavailable; the estimated-scale test then returns `None`
60/// instead of inventing denominator degrees of freedom. The known-scale branch
61/// does not consume it.
62#[derive(Debug, Clone)]
63pub struct SmoothTestInput<'a> {
64    pub beta: ArrayView1<'a, f64>,
65    pub covariance: &'a Array2<f64>,
66    pub influence_matrix: Option<&'a Array2<f64>>,
67    pub whitening_gram: Option<&'a Array2<f64>>,
68    pub coeff_range: Range<usize>,
69    pub edf: f64,
70    pub nullspace_dim: usize,
71    pub residual_df: Option<f64>,
72    pub scale: SmoothTestScale,
73}
74
75/// Output of `wood_smooth_test`: the Wald statistic
76/// `T = f̂ᵀ·Vf⁻ᵣ·f̂` (rank-`r` truncated pseudo-inverse of the design-whitened
77/// covariance `Vf = R·V·Rᵀ`), the reference d.f. used to compute the tail
78/// probability, and the resulting `p_value` (clamped to `[0,1]`).
79#[derive(Debug, Clone)]
80pub struct SmoothTestResult {
81    pub statistic: f64,
82    pub ref_df: f64,
83    pub p_value: f64,
84}
85
86/// Wood (2013) rank-truncated Wald smooth-component test.
87///
88/// Maps the term block `beta[coeff_range]` (and its posterior covariance
89/// subblock) into the fitted-value space `f = R·β` — where `RᵀR = G` is the
90/// term's weighted design Gram `G = X'WX` supplied in `whitening_gram` — and
91/// tests it with the rank-`r` spectral pseudo-inverse of the whitened
92/// covariance `Vf = R·V·Rᵀ`, `r = round(edf)` (floored at `nullspace_dim` and
93/// at 1). The statistic `T = f̂ᵀ·Vf⁻ᵣ·f̂` is compared against `χ²_{ref_df}`
94/// when the scale is `Known`, or `F = T/ref_df` against
95/// `F_{ref_df, residual_df}` when `Estimated`.
96///
97/// The whitening is the crux of Wood (2013): the raw coefficient covariance `V`
98/// orders its eigen-directions by *coefficient* variance, which for a genuinely
99/// wiggly smooth places the estimated signal in the small-variance
100/// best-determined directions — so truncating `V` directly and keeping its
101/// *largest* eigenvalues discards exactly the fitted function and reports a
102/// dominant term as non-significant (issue #2142). Whitening by the design Gram
103/// restores the generalized eigenbasis of `(V, G)`, in which the largest
104/// whitened-variance directions are the least-penalized modes that carry the
105/// fit; the rank-`r` truncation then keeps the signal. The statistic is
106/// invariant to any uniform rescaling of `G`, so whether the Gram carries the
107/// dispersion `φ̂` is irrelevant. When `whitening_gram` is `None` the raw
108/// covariance is truncated unchanged (graceful fallback for persisted models
109/// whose Gram was dropped).
110///
111/// Because `covariance` is the scale-included posterior covariance, `T`
112/// already has the dispersion `φ̂` divided out (it is a proper Wald χ²);
113/// the estimated-scale F-statistic is therefore `T/ref_df` with no extra
114/// `φ̂` factor. Dividing by `φ̂` a second time — the historical defect
115/// fixed in issue #675 — makes the p-value scale as `1/φ̂` and so depend on
116/// the units of the response. Returns `None` on degenerate inputs (empty
117/// block, non-finite EDF, non-finite stat, or non-positive residual d.f.
118/// in the F branch).
119pub fn wood_smooth_test(input: SmoothTestInput<'_>) -> Option<SmoothTestResult> {
120    let start = input.coeff_range.start;
121    let end = input.coeff_range.end;
122    if start >= end
123        || end > input.beta.len()
124        || end > input.covariance.nrows()
125        || end > input.covariance.ncols()
126        || !input.edf.is_finite()
127        || input.edf <= 0.0
128    {
129        return None;
130    }
131    let k = end - start;
132    let beta = input.beta.slice(s![start..end]).to_owned();
133    let cov = block(input.covariance, start, end)?;
134    let null_dim = input.nullspace_dim.min(k);
135
136    // Two regimes, selected by whether the design Gram is supplied:
137    //
138    //   * With `whitening_gram` (the `summary()` paths): the genuine Wood (2013)
139    //     test. Map `(β, V)` into fitted-value space `(R·β, R·V·Rᵀ)`
140    //     (`RᵀR = X'WX`) and take a single rank-`round(edf)` truncated
141    //     pseudo-inverse of the whitened covariance. The unpenalized null-space
142    //     directions carry the *largest* whitened variance, so the top-`round(edf)`
143    //     cut keeps them automatically (edf ≥ null_dim structurally); the floor
144    //     at `null_dim` and at 1 only guards rounding / boundary degeneracy.
145    //   * Without it (persisted models, ANOVA-binding / multinomial callers whose
146    //     covariance is already in a projected frame): the legacy null/penalized
147    //     split on the raw covariance — a full-rank quadratic over the leading
148    //     `null_dim` unpenalized coordinates plus a rank-`round(edf − null_dim)`
149    //     truncation of the trailing penalized block. Preserved byte-for-byte so
150    //     no non-summary caller shifts.
151    //
152    // `rank_used` (returned by both) is the number of covariance directions
153    // actually summed; it can fall below the requested rank on a rank-deficient
154    // block. The χ²/F reference d.f. is floored at it so a boundary-shrunk term
155    // (whose Wood influence-trace d.f. collapses toward 0) is never judged
156    // against a degenerate ~0-d.f. reference — the mechanism that turned a *zero*
157    // Wald statistic into p≈0 for a term the fit removed (#1360).
158    let (statistic, rank_used) = match input
159        .whitening_gram
160        .and_then(|g| block(g, start, end))
161        .and_then(|g| whiten_to_fitted_space(&beta, &cov, &g))
162    {
163        Some((beta_w, cov_w)) => {
164            let rank = (input.edf.round() as usize)
165                .max(null_dim)
166                .clamp(1, cov_w.nrows());
167            truncated_quadratic(&beta_w, &cov_w, rank)?
168        }
169        None => legacy_split_quadratic(&beta, &cov, null_dim, input.edf)?,
170    };
171
172    if rank_used == 0 {
173        // No estimable direction in the block (every covariance eigenmode is
174        // numerically null): the term carries no testable signal.
175        return None;
176    }
177    // Wood (2013) influence-trace participation d.f. when available, but never
178    // below `rank_used`. The historical fallback to `edf` collapsed to ~0 for a
179    // shrunk term, making `χ²_{ref_df→0}` degenerate.
180    let ref_df = match reference_df(input.influence_matrix, start, end) {
181        Some(rd) if rd.is_finite() && rd > 0.0 => rd.max(rank_used as f64),
182        _ => rank_used as f64,
183    };
184    if !statistic.is_finite() || statistic < 0.0 || !ref_df.is_finite() || ref_df <= 0.0 {
185        return None;
186    }
187    let p_value = match input.scale {
188        SmoothTestScale::Known => {
189            let dist = ChiSquared::new(ref_df).ok()?;
190            1.0 - dist.cdf(statistic)
191        }
192        SmoothTestScale::Estimated => {
193            let residual_df = input
194                .residual_df
195                .filter(|value| value.is_finite() && *value > 0.0)?;
196            // `statistic` is already a dispersion-free Wald χ² (the covariance
197            // is scale-included), so the estimated-scale F-statistic is the
198            // χ² divided by its reference d.f. only — mgcv's `Tr/rank`. Dividing
199            // by `φ̂` again would re-introduce a response-unit dependence (#675).
200            let f_stat = statistic / ref_df;
201            let dist = FisherSnedecor::new(ref_df, residual_df).ok()?;
202            1.0 - dist.cdf(f_stat)
203        }
204    };
205    if !p_value.is_finite() {
206        return None;
207    }
208    Some(SmoothTestResult {
209        statistic,
210        ref_df,
211        p_value: p_value.clamp(0.0, 1.0),
212    })
213}
214
215fn block(matrix: &Array2<f64>, start: usize, end: usize) -> Option<Array2<f64>> {
216    if start >= end || end > matrix.nrows() || end > matrix.ncols() {
217        return None;
218    }
219    Some(matrix.slice(s![start..end, start..end]).to_owned())
220}
221
222/// Legacy raw-covariance smooth test used when no design Gram is available:
223/// a full-rank quadratic over the leading `null_dim` unpenalized coordinates
224/// plus a rank-`round(edf − null_dim)` truncation of the trailing penalized
225/// block, both on the raw coefficient covariance. Returns the summed statistic
226/// and the total number of covariance directions actually used. This is a
227/// reparameterization-*dependent* approximation of Wood (2013) — the whitened
228/// path supersedes it — but it is retained bit-for-bit for the ANOVA-binding,
229/// multinomial and persisted-model callers that never carry `X'WX`.
230fn legacy_split_quadratic(
231    beta: &Array1<f64>,
232    cov: &Array2<f64>,
233    null_dim: usize,
234    edf: f64,
235) -> Option<(f64, usize)> {
236    let k = beta.len();
237    let null_dim = null_dim.min(k);
238    let pen_dim = k.saturating_sub(null_dim);
239    let mut statistic = 0.0;
240    let mut rank_used = 0usize;
241    if null_dim > 0 {
242        let beta_null = beta.slice(s![0..null_dim]).to_owned();
243        let cov_null = cov.slice(s![0..null_dim, 0..null_dim]).to_owned();
244        let (q, used) = truncated_quadratic(&beta_null, &cov_null, null_dim)?;
245        statistic += q;
246        rank_used += used;
247    }
248    if pen_dim > 0 {
249        let beta_pen = beta.slice(s![null_dim..k]).to_owned();
250        let cov_pen = cov.slice(s![null_dim..k, null_dim..k]).to_owned();
251        let rank = truncated_rank(edf - null_dim as f64, pen_dim);
252        if rank > 0 {
253            let (q, used) = truncated_quadratic(&beta_pen, &cov_pen, rank)?;
254            statistic += q;
255            rank_used += used;
256        }
257    }
258    Some((statistic, rank_used))
259}
260
261fn truncated_rank(edf_pen: f64, pen_dim: usize) -> usize {
262    if pen_dim == 0 || !edf_pen.is_finite() || edf_pen <= 0.0 {
263        return 0;
264    }
265    (edf_pen.round() as usize).clamp(1, pen_dim)
266}
267
268/// Map a term's coefficient-space `(β, V)` into its Wood (2013) fitted-value
269/// space using the weighted design Gram `G = X'WX` (`G = RᵀR`). Returns
270/// `(R·β, R·V·Rᵀ)`, where `R` has one row `√μ_i · u_iᵀ` per eigenpair
271/// `(μ_i, u_i)` of `G` whose eigenvalue clears a relative tolerance. A
272/// rank-deficient Gram (degenerate design, collinear tensor margins) therefore
273/// yields a lower-dimensional fitted space rather than a failure; `None` only
274/// when `G` has no positive eigenvalue (no estimable fitted direction) or the
275/// shapes disagree. `R·V·Rᵀ` is symmetrized to absorb round-off so the
276/// downstream eigendecomposition sees an exactly symmetric matrix.
277fn whiten_to_fitted_space(
278    beta: &Array1<f64>,
279    cov: &Array2<f64>,
280    gram: &Array2<f64>,
281) -> Option<(Array1<f64>, Array2<f64>)> {
282    let k = beta.len();
283    if gram.nrows() != k || gram.ncols() != k || cov.nrows() != k || cov.ncols() != k {
284        return None;
285    }
286    let (evals, evecs) = gram.to_owned().eigh(faer::Side::Lower).ok()?;
287    let max_ev = evals
288        .iter()
289        .copied()
290        .fold(0.0_f64, |acc, v| acc.max(v.abs()));
291    if max_ev <= 0.0 {
292        return None;
293    }
294    let tol = max_ev * 1e-10;
295    let rows: Vec<usize> = (0..evals.len()).filter(|&i| evals[i] > tol).collect();
296    if rows.is_empty() {
297        return None;
298    }
299    // R (r×k): row i = √μ_i · u_iᵀ, so RᵀR = Σ μ_i u_i u_iᵀ = G (up to the
300    // dropped near-null modes) and R maps coefficients to fitted-value coords.
301    let mut r_mat = Array2::<f64>::zeros((rows.len(), k));
302    for (ri, &i) in rows.iter().enumerate() {
303        let scale = evals[i].sqrt();
304        let u = evecs.column(i);
305        for j in 0..k {
306            r_mat[[ri, j]] = scale * u[j];
307        }
308    }
309    let beta_w = r_mat.dot(beta);
310    let mut cov_w = r_mat.dot(cov).dot(&r_mat.t());
311    gam_linalg::matrix::symmetrize_in_place(&mut cov_w);
312    Some((beta_w, cov_w))
313}
314
315/// Returns the rank-`rank` truncated Wald quadratic together with the number of
316/// covariance directions (eigenmodes above the relative tolerance) that were
317/// actually summed into it. The `used` count is the *effective rank of the
318/// statistic*: it can fall below `rank` when the covariance subblock is itself
319/// rank-deficient. Callers fold it into the χ² reference degrees of freedom so
320/// the tail probability is never evaluated against a degenerate ~0 d.f.
321fn truncated_quadratic(beta: &Array1<f64>, cov: &Array2<f64>, rank: usize) -> Option<(f64, usize)> {
322    if beta.is_empty() || cov.nrows() != beta.len() || cov.ncols() != beta.len() || rank == 0 {
323        return None;
324    }
325    let (evals, evecs) = cov.to_owned().eigh(faer::Side::Lower).ok()?;
326    let mut order: Vec<usize> = (0..evals.len()).collect();
327    order.sort_by(|&a, &b| evals[b].total_cmp(&evals[a]));
328    let tol = evals
329        .iter()
330        .copied()
331        .fold(0.0_f64, |acc, v| acc.max(v.abs()))
332        * 1e-10;
333    let mut q = 0.0;
334    let mut used = 0usize;
335    for idx in order {
336        let lambda = evals[idx];
337        if lambda <= tol {
338            continue;
339        }
340        let v = evecs.column(idx);
341        let proj = beta.dot(&v);
342        q += proj * proj / lambda;
343        used += 1;
344        if used >= rank {
345            break;
346        }
347    }
348    (used > 0 && q.is_finite()).then_some((q.max(0.0), used))
349}
350
351fn reference_df(influence: Option<&Array2<f64>>, start: usize, end: usize) -> Option<f64> {
352    let f = influence?;
353    let f_block = block(f, start, end)?;
354    let tr = (0..f_block.nrows()).map(|i| f_block[[i, i]]).sum::<f64>();
355    let tr2 = f_block.dot(&f_block).diag().sum();
356    if tr.is_finite() && tr2.is_finite() && tr > 0.0 && tr2 > 0.0 {
357        Some((tr * tr / tr2).max(1e-12))
358    } else {
359        None
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use ndarray::array;
367    use statrs::distribution::{ChiSquared, ContinuousCDF};
368
369    #[test]
370    fn reference_df_uses_trace_correction() {
371        let beta = array![1.0, 2.0];
372        let cov = array![[2.0, 0.0], [0.0, 3.0]];
373        let f = array![[0.5, 0.0], [0.0, 0.25]];
374        let out = wood_smooth_test(SmoothTestInput {
375            beta: beta.view(),
376            covariance: &cov,
377            influence_matrix: Some(&f),
378            whitening_gram: None,
379            coeff_range: 0..2,
380            edf: 1.0,
381            nullspace_dim: 0,
382            residual_df: None,
383            scale: SmoothTestScale::Known,
384        })
385        .expect("smooth test");
386        assert!((out.ref_df - 1.8).abs() < 1e-12);
387        assert!(out.statistic > 0.0);
388        assert!((0.0..=1.0).contains(&out.p_value));
389    }
390
391    #[test]
392    fn known_scale_branch_reports_plain_wald_chi_square() {
393        let beta = array![1.0, 2.0];
394        let cov = array![[2.0, 0.0], [0.0, 3.0]];
395        let f = array![[0.5, 0.0], [0.0, 0.25]];
396        let out = wood_smooth_test(SmoothTestInput {
397            beta: beta.view(),
398            covariance: &cov,
399            influence_matrix: Some(&f),
400            whitening_gram: None,
401            coeff_range: 0..2,
402            edf: 1.0,
403            nullspace_dim: 0,
404            residual_df: None,
405            scale: SmoothTestScale::Known,
406        })
407        .expect("smooth test");
408
409        let dist = ChiSquared::new(out.ref_df).expect("chi-square");
410        let expected = 1.0 - dist.cdf(out.statistic);
411        assert!((out.p_value - expected).abs() < 1e-15);
412    }
413
414    #[test]
415    fn estimated_scale_refuses_missing_residual_degrees_of_freedom() {
416        let beta = array![1.0, 2.0];
417        let covariance = array![[2.0, 0.0], [0.0, 3.0]];
418        let result = wood_smooth_test(SmoothTestInput {
419            beta: beta.view(),
420            covariance: &covariance,
421            influence_matrix: None,
422            whitening_gram: None,
423            coeff_range: 0..2,
424            edf: 1.0,
425            nullspace_dim: 0,
426            residual_df: None,
427            scale: SmoothTestScale::Estimated,
428        });
429        assert!(
430            result.is_none(),
431            "estimated-scale inference must be omitted when denominator d.f. is unavailable"
432        );
433    }
434
435    /// Rescaling the response by `c` is `β → c·β`, `Σ → c²·Σ` (the covariance
436    /// is scale-included). The Wald statistic `T = β'Σ⁻β` is then invariant,
437    /// and — because the estimated-scale F-statistic is `T/ref_df` with no
438    /// further `φ̂` factor — so is the p-value. This is the unit-level guard
439    /// for issue #675: the historical `T/(ref_df·φ̂)` made the p-value scale
440    /// as `1/c²` even though `T` did not move.
441    #[test]
442    fn estimated_scale_pvalue_is_response_unit_invariant() {
443        let beta = array![2.5, -3.5, 1.8];
444        let cov = array![[2.0, 0.3, 0.0], [0.3, 1.5, 0.1], [0.0, 0.1, 0.9]];
445        let f = array![[0.7, 0.0, 0.0], [0.0, 0.6, 0.0], [0.0, 0.0, 0.4]];
446
447        let run = |c: f64| {
448            let beta_c = &beta * c;
449            let cov_c = &cov * (c * c);
450            wood_smooth_test(SmoothTestInput {
451                beta: beta_c.view(),
452                covariance: &cov_c,
453                influence_matrix: Some(&f),
454                whitening_gram: None,
455                coeff_range: 0..3,
456                edf: 2.0,
457                nullspace_dim: 0,
458                residual_df: Some(50.0),
459                scale: SmoothTestScale::Estimated,
460            })
461            .expect("smooth test")
462        };
463
464        let base = run(1.0);
465        assert!(base.statistic > 0.0);
466        // A non-trivial, clearly-significant p-value so the invariance check is
467        // not vacuously comparing two values pinned at a boundary.
468        assert!(base.p_value > 0.0 && base.p_value < 0.05);
469        for c in [1e-3, 0.1, 10.0, 1e3, 1e6] {
470            let scaled = run(c);
471            let rel_stat = (scaled.statistic - base.statistic).abs() / base.statistic;
472            assert!(
473                rel_stat < 1e-9,
474                "Wald statistic not scale-invariant at c={c}: {} vs {}",
475                scaled.statistic,
476                base.statistic
477            );
478            let rel_p = (scaled.p_value - base.p_value).abs() / base.p_value;
479            assert!(
480                rel_p < 1e-9,
481                "estimated-scale p-value not scale-invariant at c={c}: {} vs {}",
482                scaled.p_value,
483                base.p_value
484            );
485        }
486    }
487
488    /// A term the fit drove to the penalty boundary (coefficients ≈ 0, EDF → 0)
489    /// must read as *not* significant. The defect (#1360): the reference d.f.
490    /// fell back to `edf` and collapsed toward 0, so the χ² tail of a *zero*
491    /// statistic evaluated at ~0 d.f. degenerated to p ≈ 0 — an overwhelming
492    /// false positive for a term that was removed. The reference d.f. is now
493    /// floored at the rank actually summed (≥ 1), so a zero statistic returns
494    /// p ≈ 1.
495    #[test]
496    fn boundary_shrunk_term_is_not_significant() {
497        // Near-zero coefficients with a well-conditioned (non-degenerate)
498        // covariance: the Wald statistic is ~0 regardless of how the reference
499        // d.f. is formed.
500        let beta = array![1e-9, -2e-9, 5e-10];
501        let cov = array![[0.04, 0.0, 0.0], [0.0, 0.05, 0.0], [0.0, 0.0, 0.06]];
502        // A degenerate influence block (sign-flipped near-zero leverages) so the
503        // Wood trace correction is unavailable and the fallback is exercised.
504        let f = array![[1e-9, 0.0, 0.0], [0.0, -1e-9, 0.0], [0.0, 0.0, 1e-12]];
505        for scale in [SmoothTestScale::Known, SmoothTestScale::Estimated] {
506            let out = wood_smooth_test(SmoothTestInput {
507                beta: beta.view(),
508                covariance: &cov,
509                influence_matrix: Some(&f),
510                whitening_gram: None,
511                coeff_range: 0..3,
512                edf: 1e-6,
513                nullspace_dim: 0,
514                residual_df: Some(500.0),
515                scale,
516            })
517            .expect("boundary term still produces a result");
518            assert!(
519                out.ref_df >= 1.0,
520                "reference d.f. must not collapse below the tested rank: {}",
521                out.ref_df
522            );
523            assert!(
524                out.statistic < 1e-6,
525                "boundary statistic should be ~0: {}",
526                out.statistic
527            );
528            assert!(
529                out.p_value > 0.5,
530                "shrunk boundary term must not be significant (p={}, scale={:?})",
531                out.p_value,
532                scale
533            );
534        }
535    }
536
537    /// Flooring the reference d.f. at the tested rank must not weaken a genuinely
538    /// significant term: a large statistic with a healthy influence block keeps
539    /// its small p-value (the floor only raises a *degenerate* sub-1 d.f.).
540    #[test]
541    fn floor_does_not_blunt_a_real_signal() {
542        let beta = array![6.0, -5.0];
543        let cov = array![[1.0, 0.0], [0.0, 1.0]];
544        let f = array![[0.9, 0.0], [0.0, 0.9]];
545        let out = wood_smooth_test(SmoothTestInput {
546            beta: beta.view(),
547            covariance: &cov,
548            influence_matrix: Some(&f),
549            whitening_gram: None,
550            coeff_range: 0..2,
551            edf: 2.0,
552            nullspace_dim: 2,
553            residual_df: None,
554            scale: SmoothTestScale::Known,
555        })
556        .expect("smooth test");
557        assert!(out.statistic > 40.0, "statistic={}", out.statistic);
558        assert!(
559            out.p_value < 1e-6,
560            "a strong term must stay significant: p={}",
561            out.p_value
562        );
563    }
564
565    /// The #2142 root cause, isolated: a dominant smooth whose fitted signal
566    /// lives in the *best-determined* (small raw-variance) coefficient direction
567    /// while an orthogonal, signal-free direction carries all the raw variance.
568    /// Truncating the raw covariance to rank 1 keeps the large-variance
569    /// direction — projecting the signal onto ~0 and reporting p ≈ 1 — whereas
570    /// the design-whitened truncation keeps the least-penalized (large
571    /// whitened-variance) direction that actually holds the fit, recovering a
572    /// tiny p. Same `(β, V)`; the only difference is whether the weighted Gram
573    /// is supplied.
574    #[test]
575    fn whitening_recovers_signal_the_raw_truncation_discards() {
576        // Direction 0 (e0) is tightly determined (small posterior variance) and
577        // holds all the coefficient signal; direction 1 (e1) is loose and empty.
578        let beta = array![5.0, 0.0];
579        let cov = array![[0.01, 0.0], [0.0, 1.0]];
580        // Weighted Gram: e0 carries far more Fisher information (X'WX_00 ≫ _11),
581        // which is precisely *why* its posterior variance is small. Whitening by
582        // it makes the whitened variance of e0 (g0·V00 = 4) exceed that of e1
583        // (g1·V11 = 1), so the rank-1 cut keeps e0.
584        let gram = array![[400.0, 0.0], [0.0, 1.0]];
585
586        let raw = wood_smooth_test(SmoothTestInput {
587            beta: beta.view(),
588            covariance: &cov,
589            influence_matrix: None,
590            whitening_gram: None,
591            coeff_range: 0..2,
592            edf: 1.0,
593            nullspace_dim: 0,
594            residual_df: None,
595            scale: SmoothTestScale::Known,
596        })
597        .expect("raw smooth test");
598        assert!(
599            raw.statistic < 1e-6 && raw.p_value > 0.5,
600            "raw truncation must keep the empty large-variance direction (the bug): stat={}, p={}",
601            raw.statistic,
602            raw.p_value
603        );
604
605        let whitened = wood_smooth_test(SmoothTestInput {
606            beta: beta.view(),
607            covariance: &cov,
608            influence_matrix: None,
609            whitening_gram: Some(&gram),
610            coeff_range: 0..2,
611            edf: 1.0,
612            nullspace_dim: 0,
613            residual_df: None,
614            scale: SmoothTestScale::Known,
615        })
616        .expect("whitened smooth test");
617        assert!(
618            whitened.statistic > 100.0 && whitened.p_value < 1e-6,
619            "whitened truncation must keep the signal direction: stat={}, p={}",
620            whitened.statistic,
621            whitened.p_value
622        );
623    }
624
625    /// The Wald statistic is invariant to any uniform rescaling `G → c·G` of the
626    /// whitening Gram: `R → √c·R` scales `R·β` by `√c` and `R·V·Rᵀ` by `c`, and
627    /// the two factors cancel in `(R·β)ᵀ (R·V·Rᵀ)⁻ (R·β)`. This is why passing
628    /// the raw `X'WX` (no `φ̂`) is correct even though the covariance is
629    /// scale-included.
630    #[test]
631    fn whitening_statistic_is_invariant_to_gram_scaling() {
632        let beta = array![2.0, -1.5, 0.7];
633        let cov = array![[0.02, 0.0, 0.0], [0.0, 0.3, 0.0], [0.0, 0.0, 0.9]];
634        let gram_base = array![[50.0, 1.0, 0.0], [1.0, 8.0, 0.5], [0.0, 0.5, 2.0]];
635        let run = |c: f64| {
636            let g = &gram_base * c;
637            wood_smooth_test(SmoothTestInput {
638                beta: beta.view(),
639                covariance: &cov,
640                influence_matrix: None,
641                whitening_gram: Some(&g),
642                coeff_range: 0..3,
643                edf: 2.0,
644                nullspace_dim: 0,
645                residual_df: None,
646                scale: SmoothTestScale::Known,
647            })
648            .expect("whitened smooth test")
649        };
650        let base = run(1.0);
651        assert!(base.statistic > 0.0);
652        for c in [1e-6, 1e-2, 7.0, 1e3, 1e6] {
653            let scaled = run(c);
654            let rel = (scaled.statistic - base.statistic).abs() / base.statistic;
655            assert!(
656                rel < 1e-9,
657                "statistic not Gram-scale-invariant at c={c}: {} vs {}",
658                scaled.statistic,
659                base.statistic
660            );
661        }
662    }
663
664    /// A rank-deficient whitening Gram (e.g. a collinear/degenerate term design)
665    /// must degrade gracefully to a lower-dimensional fitted space rather than
666    /// error: the surviving direction is still tested and yields a finite result.
667    #[test]
668    fn whitening_tolerates_rank_deficient_gram() {
669        let beta = array![3.0, 1.0];
670        let cov = array![[0.05, 0.0], [0.0, 0.4]];
671        // Rank-1 Gram: only the e0 fitted direction is estimable.
672        let gram = array![[9.0, 0.0], [0.0, 0.0]];
673        let out = wood_smooth_test(SmoothTestInput {
674            beta: beta.view(),
675            covariance: &cov,
676            influence_matrix: None,
677            whitening_gram: Some(&gram),
678            coeff_range: 0..2,
679            edf: 2.0,
680            nullspace_dim: 0,
681            residual_df: None,
682            scale: SmoothTestScale::Known,
683        })
684        .expect("rank-deficient Gram still yields a result");
685        // Only one fitted direction survives, so the reference d.f. is 1.
686        assert!((out.ref_df - 1.0).abs() < 1e-9, "ref_df={}", out.ref_df);
687        assert!(out.statistic.is_finite() && out.statistic > 0.0);
688        assert!((0.0..=1.0).contains(&out.p_value));
689    }
690}