gam_models/fit_orchestration/drivers/smooth_term_lr.rs
1// The #1063 per-term smooth significance test: a genuine likelihood-ratio
2// statistic from a constrained refit, its Lawley Bartlett correction, and the
3// reference distribution it is scored against (#2672).
4//
5// Split out of `spatial_optimization.rs` under the #780 line-count gate. It is
6// `include!`d into `drivers/mod.rs` alongside the driver it came from, so it
7// keeps the same flat namespace and the same import surface — nothing here
8// changed except which file it lives in.
9
10/// Provenance tag for the smooth-term significance correction (#1063): which
11/// statistic the reported p-value is built from.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum SmoothLrCorrection {
14 /// A per-term LR statistic corrected by the full estimated-λ Lawley factor,
15 /// including the ρ̂-sampling-variation contribution from the regularized
16 /// inverse REML/LAML outer Hessian.
17 LawleyLrEstimatedLambda,
18 /// A per-term likelihood-ratio statistic `W = 2(ℓ_full − ℓ_null)` that has
19 /// been Bartlett-corrected with the fixed-λ Lawley factor `c = E[W|λ]/d`
20 /// (`W* = W/c`, referenced against `χ²_d`). This is used only when the
21 /// estimated-λ handoff is unavailable.
22 LawleyLrFixedLambda,
23 /// No second-order correction was applied — either the family has no
24 /// closed-form Lawley cumulant jets or the null refit did not converge — so
25 /// the uncorrected `χ²_d` of the raw LR statistic stands.
26 None,
27}
28
29impl SmoothLrCorrection {
30 /// The serialized provenance label surfaced in the summary table.
31 pub fn label(self) -> &'static str {
32 match self {
33 SmoothLrCorrection::LawleyLrEstimatedLambda => "lawley_lr_estimated_lambda",
34 SmoothLrCorrection::LawleyLrFixedLambda => "lawley_lr_fixed_lambda",
35 SmoothLrCorrection::None => "none",
36 }
37 }
38}
39
40/// Which lane supplied a [`SmoothLrReferenceDf`].
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum SmoothLrReferenceSource {
43 /// The statistic's own null spectrum `w`, in full, scored by Imhof
44 /// inversion of its characteristic function. This is the exact lane: the
45 /// reference IS the null law, not a distribution fitted to some of its
46 /// moments.
47 ///
48 /// The spectrum is assembled from `[H⁻¹]_jj` and the term's own λ-weighted
49 /// penalty block through the symmetric similarity
50 /// `w_j = 1 − eig(B^{1/2} S_jj B^{1/2})²` — see
51 /// `lr_tested_block` for why that is the same spectrum as
52 /// `eig(2·F_jj − F_jj²)` and why it is the better-conditioned way to reach
53 /// it.
54 NullSpectrum,
55 /// `[H⁻¹]_jj` or the penalty block was unavailable, but the
56 /// coefficient-influence matrix was, so only the first two *moments* of the
57 /// spectrum are recoverable (`tr A` and `tr A²` for `A = 2F_jj − F_jj²`,
58 /// both traces of powers of one block). The reference is then the
59 /// two-moment match `g·χ²_ν`.
60 ///
61 /// It is EXACT at both ends of the shrinkage range and wrong in between,
62 /// which is worth stating precisely because the ends are where the intuition
63 /// goes. An unpenalized term has `w ≡ 1` and the match is the textbook
64 /// `χ²_q`; a term REML has shrunk to its null space has one weight of order
65 /// one over a tail of dust — measured on a null-true `k = 12` fit, `w =
66 /// (0.322, 5.9e-7, 7.1e-8, …)` — and a single distinct weight is a scaled
67 /// chi-square exactly. The gap opens at moderate shrinkage, where several
68 /// weights are comparable and unequal: on `f_j = 1/(1 + λγ_j)` for a
69 /// second-difference penalty at `λ = 0.01`, `k = 20`, the size delivered at
70 /// a nominal `α` is `1.02×` at `0.05`, `1.11×` at `0.01`, `1.31×` at `10⁻³`
71 /// and `1.61×` at `10⁻⁴` — one-signed, anti-conservative, and worse the
72 /// deeper the tail.
73 ///
74 /// It is a surrogate for the lane above, not a different claim about the
75 /// statistic.
76 SpectralMomentMatch,
77 /// Neither the spectrum nor its moments were recoverable, so the reference
78 /// falls back to the classical unit-weight shape
79 /// `χ²_{max(edf, null_dim, 1)}` — every retained direction counted as if it
80 /// were unpenalized. It is the only reference recoverable from a scalar
81 /// EDF, and it is conservative for the same reason the whole pre-#2672
82 /// assembly was: unit weights over-state the statistic's spread.
83 UnitWeightFallback,
84}
85
86/// The null law of `W(λ̂)` when `λ̂` is CHOSEN by the outer criterion rather than
87/// given — the reference the whole-term LR statistic actually needs (#2672).
88///
89/// # The defect this exists for
90///
91/// [`SmoothLrReferenceDf`]'s spectrum is the exact null law of `W` *at a fixed*
92/// `λ`. `λ̂` is not fixed: REML picks it from a continuum, on the same data that
93/// produced `W`. Measured on a Gaussian null with `σ` known — so no Lawley term
94/// is in play and the reference is the only thing being tested — the two move
95/// together (`corr(W, Σw) = 0.94–0.96`) but not by enough, and the conditional
96/// reference over-rejects:
97///
98/// ```text
99/// α = .20 .10 .05 .01
100/// conditional .2060 .1320 .0840 .0180 n = 30, k = 12
101/// .2160 .1100 .0580 .0140 n = 100, k = 12
102/// .1850 .1025 .0650 .0150 n = 200, k = 12
103/// ```
104///
105/// It is not a mean problem, and it must not be fixed as one: on those same runs
106/// `E[W]/E[Σw] ≈ 2.4–2.5`, and dividing `W` by that ratio takes the size at
107/// `α = 0.05` from `.087` to `.0000`. The reference is not mean-matched to `W`
108/// and is not supposed to be.
109///
110/// # The replay, and why it needs no refit
111///
112/// Diagonalize the term's fitted penalty `S_jj` against the Schur-complemented
113/// information `Ĩ_jj` — the pair is symmetric-definite, so a single basis
114/// diagonalizes both, with generalized eigenvalues `ν_k = p_k/(1 − p_k)` read
115/// straight off the penalty shares `lr_tested_block` already computes. In
116/// that basis the tested block is `q` independent standard normals `u_k`, and
117/// BOTH the statistic and the criterion that selects `λ` are closed forms in
118/// them and in the scale `t = λ/λ̂`:
119///
120/// ```text
121/// W(t) = Σ_k (2f_k − f_k²) u_k² , f_k = 1/(1 + t·ν_k)
122/// V(t) = ½ Σ_k u_k² ·t·ν_k/(1 + t·ν_k)
123/// + ½ Σ_{k: ν_k > 0} log((1 + t·ν_k)/(t·ν_k)) (+ terms free of t)
124/// ```
125///
126/// So the whole selection — draw data, choose `λ̂`, read `W` — is a function of
127/// `q` numbers, and the null law of `W(λ̂)` can be generated exactly (within the
128/// same quadratic expansion the conditional law already assumes) with no design,
129/// no response and no refit. `t = 1` reproduces the conditional law, which is
130/// what makes this a strict generalization rather than a different reference.
131///
132/// # What it buys, measured
133///
134/// Same runs, same replicates, `20 000` draws per fit:
135///
136/// ```text
137/// α = .20 .10 .05 .01
138/// selection-aware .1940 .1160 .0560 .0120 n = 30, k = 12
139/// .2020 .0840 .0440 .0080 n = 100, k = 12
140/// .1775 .0925 .0425 .0075 n = 200, k = 12
141/// ```
142///
143/// Closer to nominal at every level in every cell — twelve of twelve — and the
144/// `α = 0.05` column goes from a mean of `.069` to `.047` against a per-cell
145/// Monte-Carlo standard error of `.0097`.
146///
147/// # The Monte-Carlo error is removed where it would matter
148///
149/// The replay is a simulation, so its tail is an estimate. The conditional tail
150/// is NOT — [`gam_math::probability::weighted_chi_square_sf`] evaluates it by
151/// inversion. The two are strongly dependent (the same draws, differing only in
152/// whether `t` is selected or held at one), so the replay reports the
153/// DIFFERENCE and adds it to the exact conditional value:
154///
155/// ```text
156/// p_selection = p_conditional + [ P̂(W_sel ≥ w) − P̂(W_cond ≥ w) ]
157/// ```
158///
159/// a textbook control variate. The bracket is a difference of two indicators
160/// that agree on most draws, so its variance is a fraction of either term's, and
161/// the standard error of the pair is measured per query and published in the
162/// report's own accuracy bound rather than assumed.
163#[derive(Clone)]
164pub struct SmoothLrSelectionReplay {
165 /// `ν_k = eig(Ĩ_jj⁻¹ S_jj(λ̂))`, the term's generalized penalty spectrum at
166 /// the fitted scale, ascending. `t = 1` is the fit.
167 ///
168 /// It is published on EVERY lane. The multi-scale lane used to leave it
169 /// empty — its samples come from a grid whose basis moves with `t`, so it
170 /// has no single diagonalizing spectrum to report *per grid point* — but the
171 /// FITTED point always has one, it is the object the whole replay is built
172 /// on, and a consumer asking what was replayed is asking about that. An
173 /// empty vector there was an accident of which lane ran, not a statement
174 /// about the term.
175 pub generalized: Vec<f64>,
176 /// `W(λ̂(u))` over the draws, IN DRAW ORDER.
177 selection_sample: Vec<f64>,
178 /// `W(1)` over the SAME draws, in the same order — the control variate.
179 ///
180 /// The order is the pairing, and the pairing is the whole point: sorting
181 /// either sample would leave the two counts correct and destroy the paired
182 /// difference whose variance is what makes this a control variate rather
183 /// than two independent estimates.
184 conditional_sample: Vec<f64>,
185}
186
187impl std::fmt::Debug for SmoothLrSelectionReplay {
188 /// The two samples are thousands of draws each and are never what a reader
189 /// of a failure message wants; the spectrum that generated them is.
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.debug_struct("SmoothLrSelectionReplay")
192 .field("generalized", &self.generalized)
193 .field("draws", &self.selection_sample.len())
194 .finish()
195 }
196}
197
198impl PartialEq for SmoothLrSelectionReplay {
199 fn eq(&self, other: &Self) -> bool {
200 self.generalized == other.generalized
201 && self.selection_sample == other.selection_sample
202 && self.conditional_sample == other.conditional_sample
203 }
204}
205
206/// Draws used to generate the selection replay.
207///
208/// The replay's only error is the Monte-Carlo error of the control-variate
209/// DIFFERENCE, not of a tail — the tail itself is inverted exactly. That
210/// difference is a mean of indicators that agree on all but the draws whose
211/// selected `t` moves them across the threshold, so its standard error is a
212/// fraction of `√(p(1−p)/N)`. At this budget that raw bound is `3.4e-3` at
213/// `p = 0.05`, and the measured control-variate standard error on the fixtures
214/// is between one and two orders below it. It is measured and published per
215/// query rather than assumed, so a caller never has to take this number on
216/// trust — and the cost is one `N × G` reduction per term, `~40 ms`.
217const SMOOTH_LR_SELECTION_DRAWS: usize = 4096;
218
219/// Grid resolution of the replay's own `argmin` over `ln t`.
220///
221/// The criterion is smooth in `ln t` and the statistic is smooth in the selected
222/// `t`, so the grid only has to resolve the criterion's minimizer to a fraction
223/// of the scale on which `W` changes. `0.05` in `ln t` is a 5% change in `λ`,
224/// which moves `w_k = 1 − (tν_k/(1+tν_k))²` by at most `0.025` in the worst
225/// direction and much less in every other. The span is the solver's own `ρ` box
226/// translated to the fitted point, so the replay never selects a `λ` the fit
227/// could not have.
228const SMOOTH_LR_SELECTION_LOG_STEP: f64 = 0.05;
229
230/// Total points the multi-scale BRACKET may spend, whatever `m` is.
231///
232/// This grid does not have to RESOLVE the selection — the per-draw descent in
233/// [`SmoothLrSelectionReplay::generate_multiscale`] does that — it has to find
234/// the basin each draw's minimum lives in. That is what makes a budget on the
235/// total sane: each axis gets `budget^(1/m)` points, the cost stays independent
236/// of `m`, and what degrades with `m` is the bracket's reach rather than the
237/// answer's accuracy.
238///
239/// It used to be the whole selection, and it could not carry that: at `m = 2`
240/// this is 21 points per axis over a window the box opens to 60 wide, a spacing
241/// of `3.0` in `ln λ` against the `0.05` the one-dimensional lane commits to.
242/// Measured on a whitened bending+ridge pair at the separations a null-true
243/// smooth reaches, the law that grid generates is short of the converged one by
244/// `15%` in the mean and `23%` at `q95` — see the descent's own documentation
245/// for the table.
246///
247/// `121` reproduces the same answer once the descent runs (measured, same
248/// fixture, to `0.3%`); `441` is kept because the bracket's only remaining job
249/// is not to miss a basin, and at `0.07 s` per term it is not what the replay
250/// costs.
251const SMOOTH_LR_SELECTION_GRID_BUDGET: usize = 441;
252
253/// Draws for the multi-scale replay.
254///
255/// Fewer than the one-dimensional path's, and the reason is arithmetic rather
256/// than a different accuracy target: a one-dimensional grid point costs `O(q)`
257/// per draw because the whole problem is diagonal there, and a multi-scale one
258/// costs `O(q²)` because it is not. At `q ≈ 11` and a `441`-point grid this is
259/// about `0.1 s` per term, and the standard error it leaves is measured and
260/// published per query — a coarser replay that says how coarse it is beats a
261/// finer one nobody can afford to run.
262const SMOOTH_LR_MULTISCALE_DRAWS: usize = 2048;
263
264/// The finest `ln t` the multi-scale refinement resolves.
265///
266/// It is [`SMOOTH_LR_SELECTION_LOG_STEP`] — the same resolution the
267/// one-dimensional lane commits to, and for the same reason: `0.05` in `ln t`
268/// moves a weight `w_k = 1 − (tν_k/(1 + tν_k))²` by at most `0.025`. The
269/// multi-scale lane reaches it by descending rather than by gridding, because a
270/// grid at that step over the box the solver leaves a railed `λ̂` is `10²⁴`
271/// points per axis.
272const SMOOTH_LR_SELECTION_REFINE_FLOOR: f64 = SMOOTH_LR_SELECTION_LOG_STEP;
273
274/// Criterion evaluations one draw's refinement may spend.
275///
276/// A compass search that halves its step whenever a sweep fails needs
277/// `log2(coarse_step / floor)` failed sweeps — about `7` at the widest window
278/// the box allows — plus the accepted moves in between, at `2m` evaluations per
279/// sweep. `96` covers that with room for a descent that keeps moving, and
280/// bounds the refinement at roughly a fifth of what the coarse grid already
281/// costs per draw. A draw that hits the cap keeps the best point it reached:
282/// the refinement only ever LOWERS a draw's criterion, so the budget trades
283/// resolution for time and never correctness.
284const SMOOTH_LR_SELECTION_REFINE_MAX_EVALUATIONS: usize = 96;
285
286/// What the multi-scale replay may spend, and how finely it resolves the
287/// selection it is replaying.
288///
289/// It is a value rather than three constants read at the use site because the
290/// two halves trade against each other — a coarse bracket that is refined
291/// resolves better than a fine bracket that is not, at a fraction of the cost —
292/// and that trade is only arguable if it is measurable.
293/// [`MultiscaleBudget::SHIPPED`] is what production uses;
294/// `zz_probe_multiscale_grid_budget_moves_the_selected_law_2672` sweeps both
295/// halves against it.
296#[derive(Clone, Copy)]
297struct MultiscaleBudget {
298 /// Total points the bracketing grid may spend, whatever `m` is.
299 grid: usize,
300 /// Finest `ln t` the per-draw refinement resolves.
301 ///
302 /// `f64::INFINITY` turns the refinement off entirely, which is the arm the
303 /// probe measures the bracket alone at.
304 refine_floor: f64,
305 /// Criterion evaluations one draw's refinement may spend.
306 refine_evaluations: usize,
307}
308
309impl MultiscaleBudget {
310 const SHIPPED: Self = Self {
311 grid: SMOOTH_LR_SELECTION_GRID_BUDGET,
312 refine_floor: SMOOTH_LR_SELECTION_REFINE_FLOOR,
313 refine_evaluations: SMOOTH_LR_SELECTION_REFINE_MAX_EVALUATIONS,
314 };
315}
316
317/// Scales past which the budget above would leave fewer than five points per
318/// axis — a spacing of about `15` in `ln λ` over the solver's box.
319///
320/// What that number bounds is the BRACKET, not the resolution: the per-draw
321/// descent resolves `ln t` to `0.05` on however many axes it is given, so this
322/// cut-off is the point at which the grid can no longer be trusted to put a
323/// draw in the right BASIN — five nodes over a 60-wide window is a coin toss
324/// about which minimum the descent then walks into, and a descent started in
325/// the wrong basin is worse than an honest slice. A term with more scales than
326/// this falls back to the common-scale slice, and says so in its provenance
327/// rather than pretending to a replay it did not do.
328const SMOOTH_LR_SELECTION_MAX_SCALES: usize = 4;
329
330/// The term's penalty geometry in the basis the replay's criterion lives in:
331/// whitened by the Schur-complemented information and factored into ROOTS.
332///
333/// # Why roots, and why this is not an implementation detail
334///
335/// The replayed criterion is
336///
337/// ```text
338/// V(t) = ½ Σ_j c_j² e_j/(1 + e_j) + ½ [ log|I + T(t)| − log|T(t)|₊ ],
339/// T(t) = Σ_i t_i λ̂_i · Wᵀ S_i W, e = eig T(t),
340/// ```
341///
342/// and the bracket is its whole Occam half — the only term that stops the
343/// selection running to `t → 0`. The first summand of the bracket is benign: a
344/// mode `e` carries `log(1 + e)`, so an error of `ε‖T‖` in a mode near zero
345/// costs `ε‖T‖`. The SECOND is not, and this repo has already written down why,
346/// in `penalty_logdet.rs`'s `SpectrumScale` (#2644):
347///
348/// > `S_λ = Σ_k λ_k S_k` is a SUM OF SQUARES, so forming it squares the
349/// > conditioning of the objects it is built from … Every backward-stable
350/// > factorization of the ASSEMBLED matrix therefore prices `log|S_λ|₊` to
351/// > `O(ε·κ(S_λ))`, while the same quantity taken from the stacked scaled ROOTS
352/// > costs `O(ε·√κ(S_λ))`. The outer smoothing search routinely drives
353/// > `κ(S_λ)` past `1e14` (one λ at its ceiling beside a null-space shrinkage λ
354/// > near zero is enough) …
355///
356/// The parenthesis is this fixture. A default `s(z)` is a DOUBLE-penalty smooth
357/// (wiggliness plus a null-space ridge), and a null-true smooth is exactly the
358/// fit that rails the first λ up and the second down. Measured on a whitened
359/// `q = 9` pair at the separations the box allows, the assembled route against
360/// the root route:
361///
362/// ```text
363/// ρ̂ = (0, 0) offset 20.564 vs 20.564 error 0.000
364/// ρ̂ = (12, −12) offset 29.813 vs 29.813 error 0.000
365/// ρ̂ = (18, −24) offset 19.189 vs 53.811 error −34.623
366/// ρ̂ = (29, −29) offset 8.103 vs 63.811 error −55.709
367/// ```
368///
369/// and the error is not a perturbation of the selection, it REPLACES it: the
370/// dropped modes are the ones carrying `−ln t_i`, i.e. the coercivity that makes
371/// the criterion blow up as `t_i → 0`, so their loss makes the criterion
372/// monotone in `ln t_i` and the replay picks a wall. That is the same mechanism
373/// `from_components` documents under #1237, reached from the replay's side.
374///
375/// So the geometry is carried as `R_i` with `R_iᵀ R_i = Wᵀ S_i W` for the
376/// term's λ-FREE components, plus their `ρ̂_i` and the structural rank of their
377/// SUM. Everything the replay needs at a grid point — the eigenbasis, the
378/// shares, the statistic's weights and the criterion's log-determinant — is then
379/// one thin SVD of the stacked scaled roots.
380///
381/// # Why the rank has to be structural
382///
383/// `log|T|₊` is a sum over `range(T)`, and `range(T)` does not depend on `t`
384/// (every `t_i > 0`). Deciding membership by `e_j > 0` — which is what both
385/// replay lanes did — asks a floating-point comparison to separate a structural
386/// zero from a mode `1e18` below the largest one, and it answers at random:
387/// a noise-negative mode silently drops a `log(1 + 1/e) ≈ 24` contribution, and
388/// a noise-positive one invents `log(1 + 1e16) ≈ 37`. The rank is taken once,
389/// from the λ-free stacked roots, where the spectrum is well scaled.
390struct SelectionGeometry {
391 /// `R_i`, `rank_i × q`, with `R_iᵀR_i = Wᵀ S_i W` for the term's λ-free
392 /// penalty component `i`. Rows are `√σ · uᵀ` over that COMPONENT's own
393 /// modes above its OWN relative floor, so the truncation never sees the λ
394 /// dynamic range.
395 roots: Vec<Array2<f64>>,
396 /// `ρ̂_i = ln λ̂_i`, the fitted scale of component `i`. `ln t_i = 0` is the
397 /// fit.
398 log_lambda: Vec<f64>,
399 /// `q`, the tested block's identified dimension after whitening.
400 dimension: usize,
401 /// `rank(Σ_i Wᵀ S_i W)`, `t`-independent — the index set `log|T|₊` runs
402 /// over.
403 rank: usize,
404 /// Rows of the stacked root matrix, at least `dimension` so the thin SVD's
405 /// right factor is a full orthonormal basis of the block (the padding rows
406 /// are zero and change nothing else).
407 stacked_rows: usize,
408 /// `U`, `q × rank`: an orthonormal basis of `range(Σ_i Wᵀ S_i W)`, the
409 /// `t`-free subspace `log|T|₊` runs over.
410 ///
411 /// Taken from the same UNIT stacked-roots decomposition the rank is, so the
412 /// two cannot disagree about which directions are structural.
413 range_basis: Array2<f64>,
414 /// `R_i U`, `rank_i × rank`: each component's root already expressed in the
415 /// range basis, so a scaled stack of them is `M(t)` with
416 /// `M(t)ᵀM(t) = Uᵀ T(t) U` — full rank by construction, which is what makes
417 /// its triangular factor a pseudo-determinant rather than an approximation
418 /// to one.
419 range_roots: Vec<Array2<f64>>,
420}
421
422/// The criterion and the statistic at one `t`, WITHOUT an eigenbasis.
423///
424/// # Why a second evaluator exists beside [`SelectionGeometry::at`]
425///
426/// `at` returns the full eigensystem, which is the right object when every draw
427/// is asked about the same grid point: one `O(q³)` decomposition is amortized
428/// over thousands of `O(q²)` projections. The per-draw REFINEMENT inverts that
429/// ratio — one draw per point — and an eigendecomposition per draw per step is
430/// twenty times the arithmetic the answer needs, in allocations as much as in
431/// flops.
432///
433/// Everything the replay reads at a point is available from two triangular
434/// factorizations of `r × r` objects, `r = rank(T)`:
435///
436/// ```text
437/// C(t) = Uᵀ T(t) U = RᵀR, R = qr(M(t)), M(t) = [√(t_iλ̂_i)·R_iU ; …]
438/// D = (I + C)⁻¹ C, v = Uᵀu
439/// criterion = vᵀDv + log|I + C| − log|C|
440/// statistic = ‖u‖² − ‖Dv‖²
441/// ```
442///
443/// The identities are exact, not approximations of the eigen route: `D`'s
444/// eigenvalues are the shares `f_j = e_j/(1 + e_j)`, so `vᵀDv` is
445/// `Σ_j c_j² f_j`; `‖u‖² − ‖Dv‖²` is `Σ_j c_j²(1 − f_j²) = Σ_j w_j c_j²`
446/// because `w_j = 2f̄_j − f̄_j² = 1 − f_j²`; and a direction outside `range(T)`
447/// has `f = 0`, so it drops out of the first and carries its full `c²` in the
448/// second, exactly as the eigen route's `log(1 + 0) = 0` and `w = 1` do.
449///
450/// # Where the conditioning goes
451///
452/// The two log-determinants are NOT symmetric in how much they can be trusted,
453/// and this splits them accordingly (#2644):
454///
455/// * `log|C|` is taken from the TRIANGULAR FACTOR of the scaled roots, never
456/// from an assembled sum. `κ(C)` reaches `e^{60}` on a null-true
457/// double-penalty smooth, where an assembled Cholesky has no small pivots
458/// left to speak of.
459/// * `log|I + C|` and `D` are taken from an assembled `I + C`, which is benign:
460/// a mode `e` enters as `log(1 + e)` and as `e/(1 + e)`, both of which are
461/// insensitive to an ABSOLUTE error of `ε‖C‖` in a mode near zero. That is
462/// the same split `SelectionGeometry`'s own doc draws between the bracket's
463/// two summands.
464struct SelectionFactor {
465 /// `r`, the structural rank.
466 rank: usize,
467 /// `M(t)`, overwritten in place by the Householder reduction that reads its
468 /// triangular factor.
469 stacked: Array2<f64>,
470 /// `R`'s diagonal, which the reduction overwrites in `stacked`.
471 diagonal: Vec<f64>,
472 /// The lower Cholesky factor of `I + C`.
473 factor: Array2<f64>,
474 /// `log|I + T| − log|T|₊`, the criterion's `t`-dependent Occam term.
475 offset: f64,
476 /// `C v` and then `D v`.
477 mapped: Vec<f64>,
478 /// `R v`, the intermediate of `C v = Rᵀ(R v)`.
479 projected: Vec<f64>,
480}
481
482/// One grid point of the replay: the criterion's data operator, the statistic's
483/// weights, the criterion's log-determinant offset, and the basis all three are
484/// diagonal in.
485struct SelectionPoint {
486 /// `e_j = eig T(t)`, descending.
487 eigenvalues: Vec<f64>,
488 /// `f_j = e_j/(1 + e_j)`, the criterion's data operator.
489 shares: Vec<f64>,
490 /// `w_j = 2f̄_j − f̄_j²` with `f̄ = 1 − f`, the statistic's null weights.
491 weights: Vec<f64>,
492 /// `log|I + T| − log|T|₊`, the criterion's `t`-dependent Occam term.
493 offset: f64,
494 /// Columns are the eigenvectors of `T(t)`, in the same order.
495 basis: Array2<f64>,
496}
497
498impl SelectionGeometry {
499 /// Whiten the term's λ-free penalty components by the Schur-complemented
500 /// information and factor each into its own root.
501 ///
502 /// Returns `None` when the information has no identified direction, a
503 /// decomposition refuses, or the components leave nothing penalized —
504 /// in every case the caller has nothing to replay.
505 fn whiten(
506 whitener: &Array2<f64>,
507 unit_penalties: &[Array2<f64>],
508 log_lambda: &[f64],
509 ) -> Option<Self> {
510 if unit_penalties.is_empty() || unit_penalties.len() != log_lambda.len() {
511 return None;
512 }
513 let dimension = whitener.ncols();
514 if dimension == 0 || whitener.nrows() == 0 {
515 return None;
516 }
517
518 let mut roots = Vec::with_capacity(unit_penalties.len());
519 for penalty in unit_penalties {
520 if penalty.nrows() != whitener.nrows() || penalty.ncols() != whitener.nrows() {
521 return None;
522 }
523 // `Wᵀ S W` is symmetric as a mathematical object and its two
524 // triangles differ only by summation order — but
525 // `strict_symmetric_eigh` REFUSES the input rather than symmetrizing
526 // it for the caller, which is the right contract and the reason this
527 // is explicit here. Dropping it is a silent `GeometryRefused` on
528 // every real fit.
529 let whitened = symmetrized(whitener.t().dot(penalty).dot(whitener));
530 if whitened.iter().any(|value| !value.is_finite()) {
531 return None;
532 }
533 roots.push(psd_root(&whitened)?);
534 }
535 if roots.iter().all(|root| root.nrows() == 0) {
536 return None;
537 }
538 let stacked_rows = roots.iter().map(|root| root.nrows()).sum::<usize>();
539 // `range(Σ_i S̃_i)` is what `log|T|₊` runs over, and it is `t`-free. Taken
540 // from the UNIT stacked roots, whose singular values span only the
541 // components' own conditioning — the λ ratio that makes the assembled
542 // sum unreadable is not present here at all.
543 let unit = stack_roots(&roots, &vec![0.0; roots.len()], stacked_rows.max(dimension));
544 let (_, unit_singular, unit_right) =
545 gam_linalg::faer_ndarray::FaerSvd::svd(&unit, false, true).ok()?;
546 let unit_largest = unit_singular.iter().copied().fold(0.0_f64, f64::max);
547 let rank = unit_singular
548 .iter()
549 .filter(|&&value| value > unit_largest * (dimension as f64) * f64::EPSILON * 100.0)
550 .count();
551 if rank == 0 {
552 return None;
553 }
554 // The same decomposition that decided the rank also names the subspace:
555 // the leading `rank` right singular vectors of the UNIT stack span
556 // `range(Σ_i S̃_i)`. Deciding the two from one object is what stops them
557 // disagreeing about which directions are structural.
558 let unit_right = unit_right?;
559 if unit_right.nrows() < rank || unit_right.ncols() != dimension {
560 return None;
561 }
562 let mut range_basis = Array2::<f64>::zeros((dimension, rank));
563 for column in 0..rank {
564 for row in 0..dimension {
565 range_basis[[row, column]] = unit_right[[column, row]];
566 }
567 }
568 let range_roots: Vec<Array2<f64>> =
569 roots.iter().map(|root| root.dot(&range_basis)).collect();
570 Some(Self {
571 roots,
572 log_lambda: log_lambda.to_vec(),
573 dimension,
574 rank,
575 stacked_rows: stacked_rows.max(dimension),
576 range_basis,
577 range_roots,
578 })
579 }
580
581 /// Evaluate the geometry at one grid point `ln t`.
582 ///
583 /// One thin SVD of the stacked scaled roots supplies all four quantities:
584 /// `eig T = σ²`, the eigenbasis is the right singular factor, and the
585 /// log-determinants are `Σ log(1 + σ²)` and `2 Σ_{j < rank} log σ` — the
586 /// second over the structural rank rather than over a sign test.
587 fn at(&self, log_t: &[f64]) -> Option<SelectionPoint> {
588 if log_t.len() != self.roots.len() {
589 return None;
590 }
591 let scaled: Vec<f64> = self
592 .log_lambda
593 .iter()
594 .zip(log_t.iter())
595 .map(|(rho, shift)| rho + shift)
596 .collect();
597 let stacked = stack_roots(&self.roots, &scaled, self.stacked_rows);
598 let (_, singular, right) =
599 gam_linalg::faer_ndarray::FaerSvd::svd(&stacked, false, true).ok()?;
600 // Thin SVD on a matrix with at least `dimension` rows: the right factor
601 // is `dimension × dimension`, so every direction of the block — including
602 // the ones the penalty never reaches — has a basis vector.
603 let right = right?;
604 if right.nrows() != self.dimension || singular.len() < self.dimension {
605 return None;
606 }
607 let mut eigenvalues = Vec::with_capacity(self.dimension);
608 let mut shares = Vec::with_capacity(self.dimension);
609 let mut weights = Vec::with_capacity(self.dimension);
610 let mut log_det_hessian = 0.0_f64;
611 let mut log_det_penalty = 0.0_f64;
612 for index in 0..self.dimension {
613 let sigma = singular[index];
614 if !sigma.is_finite() || sigma < 0.0 {
615 return None;
616 }
617 let eigenvalue = sigma * sigma;
618 log_det_hessian += eigenvalue.ln_1p();
619 if index < self.rank {
620 if !(sigma > 0.0) {
621 return None;
622 }
623 log_det_penalty += 2.0 * sigma.ln();
624 }
625 let fraction = if eigenvalue.is_finite() {
626 eigenvalue / (1.0 + eigenvalue)
627 } else {
628 1.0
629 };
630 let shrinkage = 1.0 - fraction;
631 eigenvalues.push(eigenvalue);
632 shares.push(fraction);
633 weights.push(2.0 * shrinkage - shrinkage * shrinkage);
634 }
635 let offset = log_det_hessian - log_det_penalty;
636 if !offset.is_finite() {
637 return None;
638 }
639 // Columns are eigenvectors: `right` is `Vᵀ` from the thin SVD, so its
640 // ROWS are the right singular vectors.
641 let mut basis = Array2::<f64>::zeros((self.dimension, self.dimension));
642 for column in 0..self.dimension {
643 for row in 0..self.dimension {
644 basis[[row, column]] = right[[column, row]];
645 }
646 }
647 Some(SelectionPoint {
648 eigenvalues,
649 shares,
650 weights,
651 offset,
652 basis,
653 })
654 }
655}
656
657impl SelectionFactor {
658 /// Buffers sized for one geometry. Allocated once per replay, never inside
659 /// the loop the refinement spends its time in.
660 fn new(geometry: &SelectionGeometry) -> Self {
661 let rank = geometry.rank;
662 let rows = geometry
663 .range_roots
664 .iter()
665 .map(|root| root.nrows())
666 .sum::<usize>()
667 .max(rank);
668 Self {
669 rank,
670 stacked: Array2::zeros((rows, rank)),
671 diagonal: vec![0.0; rank],
672 factor: Array2::zeros((rank, rank)),
673 offset: 0.0,
674 mapped: vec![0.0; rank],
675 projected: vec![0.0; rank],
676 }
677 }
678
679 /// Factor the geometry at `ln t`. `false` means the point is unusable and
680 /// the caller must not read the scores.
681 fn refactor(&mut self, geometry: &SelectionGeometry, log_t: &[f64]) -> bool {
682 if log_t.len() != geometry.range_roots.len() {
683 return false;
684 }
685 self.stacked.fill(0.0);
686 let mut offset_row = 0usize;
687 for ((root, &rho), &shift) in geometry
688 .range_roots
689 .iter()
690 .zip(geometry.log_lambda.iter())
691 .zip(log_t.iter())
692 {
693 // `exp(s/2)` rather than `sqrt(exp(s))`, so a `λ̂` at the box wall
694 // never round-trips through an intermediate that overflows.
695 let scale = (0.5 * (rho + shift)).exp();
696 if !scale.is_finite() {
697 return false;
698 }
699 for row in 0..root.nrows() {
700 for column in 0..self.rank {
701 self.stacked[[offset_row + row, column]] = scale * root[[row, column]];
702 }
703 }
704 offset_row += root.nrows();
705 }
706 let Some(log_determinant) =
707 householder_triangularize(&mut self.stacked, &mut self.diagonal)
708 else {
709 return false;
710 };
711 // `I + C` with `C = RᵀR`, assembled — the benign half (see the type's
712 // doc): an absolute `ε‖C‖` in a mode near zero moves `log(1 + e)` and
713 // `e/(1 + e)` by the same absolute amount and nothing more.
714 for row in 0..self.rank {
715 for column in 0..self.rank {
716 let mut sum = 0.0_f64;
717 for k in 0..=row.min(column) {
718 let left = if k == row {
719 self.diagonal[k]
720 } else {
721 self.stacked[[k, row]]
722 };
723 let right = if k == column {
724 self.diagonal[k]
725 } else {
726 self.stacked[[k, column]]
727 };
728 sum += left * right;
729 }
730 self.factor[[row, column]] = sum + f64::from(row == column);
731 }
732 }
733 let Some(cholesky) = gam_linalg::triangular::cholesky_factor_in_place(
734 self.factor.view(),
735 gam_linalg::triangular::CholeskyGuard::FiniteStrict,
736 ) else {
737 return false;
738 };
739 let mut log_hessian = 0.0_f64;
740 for index in 0..self.rank {
741 log_hessian += cholesky[[index, index]].ln();
742 }
743 self.factor = cholesky;
744 self.offset = 2.0 * (log_hessian - log_determinant);
745 self.offset.is_finite()
746 }
747
748 /// `(criterion, statistic)` for one draw, from its coordinates in the range
749 /// basis and the squared norm of the WHOLE draw.
750 ///
751 /// The norm carries the directions the penalty never reaches: they are
752 /// absent from `projected` (which lives in `range(T)`) and they contribute
753 /// nothing to the criterion and their full square to the statistic.
754 fn score(&mut self, projected: &[f64], norm_squared: f64) -> (f64, f64) {
755 // `R v`, then `Rᵀ(R v)` — `C v` without ever forming `C`.
756 for row in 0..self.rank {
757 let mut sum = self.diagonal[row] * projected[row];
758 for column in (row + 1)..self.rank {
759 sum += self.stacked[[row, column]] * projected[column];
760 }
761 self.projected[row] = sum;
762 }
763 for row in 0..self.rank {
764 let mut sum = self.diagonal[row] * self.projected[row];
765 for k in 0..row {
766 sum += self.stacked[[k, row]] * self.projected[k];
767 }
768 self.mapped[row] = sum;
769 }
770 // `D v = (I + C)⁻¹ (C v)`.
771 let solved =
772 gam_linalg::triangular::cholesky_solve_vector(&self.factor, self.mapped.as_slice());
773 let mut data = 0.0_f64;
774 let mut mapped_norm = 0.0_f64;
775 for row in 0..self.rank {
776 data += projected[row] * solved[row];
777 mapped_norm += solved[row] * solved[row];
778 }
779 (self.offset + data, norm_squared - mapped_norm)
780 }
781}
782
783/// Overwrite `matrix` (`n × r`, `n ≥ r`) with the Householder reduction whose
784/// triangular factor `R` satisfies `RᵀR = matrixᵀmatrix`, writing `R`'s diagonal
785/// to `diagonal` and returning `Σ_j ln|R_jj| = ½ log det(MᵀM)`.
786///
787/// The strictly-upper triangle of the leading `r × r` block holds `R`'s
788/// off-diagonal entries on return; the diagonal cells are left holding the
789/// reflector vectors and must be read from `diagonal`.
790///
791/// `None` when a column collapses — for a matrix of full column rank by
792/// construction that is a statement about the input, not a tolerance, so the
793/// caller refuses the point rather than continuing with a determinant it cannot
794/// price.
795fn householder_triangularize(matrix: &mut Array2<f64>, diagonal: &mut [f64]) -> Option<f64> {
796 let rows = matrix.nrows();
797 let columns = matrix.ncols();
798 if rows < columns || diagonal.len() != columns {
799 return None;
800 }
801 let mut log_determinant = 0.0_f64;
802 for pivot in 0..columns {
803 let mut norm_squared = 0.0_f64;
804 for row in pivot..rows {
805 norm_squared += matrix[[row, pivot]] * matrix[[row, pivot]];
806 }
807 let norm = norm_squared.sqrt();
808 if !(norm > 0.0) || !norm.is_finite() {
809 return None;
810 }
811 log_determinant += norm.ln();
812 // Reflect onto `−sign(x_pivot)·‖x‖ e₁`, the sign that avoids
813 // cancellation in `x_pivot − α`.
814 let alpha = if matrix[[pivot, pivot]] > 0.0 {
815 -norm
816 } else {
817 norm
818 };
819 diagonal[pivot] = alpha;
820 matrix[[pivot, pivot]] -= alpha;
821 let mut reflector_squared = 0.0_f64;
822 for row in pivot..rows {
823 reflector_squared += matrix[[row, pivot]] * matrix[[row, pivot]];
824 }
825 if reflector_squared <= 0.0 {
826 continue;
827 }
828 for column in (pivot + 1)..columns {
829 let mut inner = 0.0_f64;
830 for row in pivot..rows {
831 inner += matrix[[row, pivot]] * matrix[[row, column]];
832 }
833 let scale = 2.0 * inner / reflector_squared;
834 for row in pivot..rows {
835 matrix[[row, column]] -= scale * matrix[[row, pivot]];
836 }
837 }
838 }
839 log_determinant.is_finite().then_some(log_determinant)
840}
841
842/// `[√(e^{s_0}) R_0; √(e^{s_1}) R_1; …]`, zero-padded to `rows`.
843///
844/// The scale is applied as `exp(s/2)` rather than as `sqrt(exp(s))` so a `λ̂` at
845/// the box wall never round-trips through an intermediate that overflows.
846fn stack_roots(roots: &[Array2<f64>], log_scale: &[f64], rows: usize) -> Array2<f64> {
847 let columns = roots
848 .iter()
849 .map(|root| root.ncols())
850 .max()
851 .unwrap_or_default();
852 let mut stacked = Array2::<f64>::zeros((rows, columns));
853 let mut offset = 0usize;
854 for (root, &scale) in roots.iter().zip(log_scale.iter()) {
855 let factor = (0.5 * scale).exp();
856 for row in 0..root.nrows() {
857 for column in 0..root.ncols() {
858 stacked[[offset + row, column]] = factor * root[[row, column]];
859 }
860 }
861 offset += root.nrows();
862 }
863 stacked
864}
865
866/// A root `R` of a symmetric PSD `S`, `RᵀR = S`, taken from `S`'s OWN
867/// eigensystem and truncated at `S`'s own relative noise floor.
868///
869/// `S` here is always a λ-free whitened penalty component, so its spectrum is
870/// well scaled and this is a benign `O(ε)` operation — the dynamic range that
871/// makes the weighted sum hard lives in the λ's, not here. This mirrors
872/// `penalty_logdet::psd_component_root`, which is private to `gam-solve`;
873/// the contract is the same and so is the threshold.
874fn psd_root(matrix: &Array2<f64>) -> Option<Array2<f64>> {
875 let dimension = matrix.nrows();
876 if dimension == 0 {
877 return Some(Array2::zeros((0, 0)));
878 }
879 let (values, vectors) =
880 gam_linalg::faer_ndarray::strict_symmetric_eigh(matrix, faer::Side::Lower).ok()?;
881 let largest = values.iter().copied().fold(0.0_f64, |a, b| a.max(b.abs()));
882 let threshold = 100.0 * (dimension as f64) * f64::EPSILON * largest;
883 let kept: Vec<usize> = (0..dimension)
884 .filter(|&index| values[index] > threshold)
885 .collect();
886 let mut root = Array2::<f64>::zeros((kept.len(), dimension));
887 for (row, &index) in kept.iter().enumerate() {
888 let scale = values[index].sqrt();
889 for column in 0..dimension {
890 root[[row, column]] = scale * vectors[[column, index]];
891 }
892 }
893 Some(root)
894}
895
896/// The symmetric part of a matrix that is symmetric as a mathematical object.
897///
898/// A congruence `WᵀSW` and an assembled Gram are symmetric by construction and
899/// asymmetric by summation order. `strict_symmetric_eigh` validates its input
900/// rather than symmetrizing it — a deliberate contract, since a caller handing
901/// it a genuinely non-symmetric matrix has a defect — so every congruence on
902/// this path passes through here first.
903fn symmetrized(mut matrix: Array2<f64>) -> Array2<f64> {
904 let dimension = matrix.nrows();
905 for row in 0..dimension {
906 for column in 0..row {
907 let mean = 0.5 * (matrix[[row, column]] + matrix[[column, row]]);
908 matrix[[row, column]] = mean;
909 matrix[[column, row]] = mean;
910 }
911 }
912 matrix
913}
914
915/// The published order of a generalized spectrum: ascending, as
916/// [`SmoothLrSelectionReplay::generalized`] documents.
917fn ascending(mut values: Vec<f64>) -> Vec<f64> {
918 values.sort_by(|a, b| a.partial_cmp(b).expect("finite generalized spectrum"));
919 values
920}
921
922/// Why a term's reference carries no selection replay, when it carries none.
923///
924/// A missing replay is not neutral — it is the difference between pricing `λ̂`
925/// as CHOSEN and pricing it as given, which this issue measured at
926/// `size@.05 = 0.0962` against nominal `0.05`. So the reference says which step
927/// declined and why, rather than publishing a `None` a reader has to attribute
928/// by elimination. Every one of these is a statement about the FIT, not about
929/// the arithmetic: a term with nothing to select legitimately has no replay.
930#[derive(Clone, Copy, Debug, PartialEq, Eq)]
931pub enum SmoothLrSelectionDecline {
932 /// No penalty component reached the driver for this term: it is unpenalized,
933 /// or every component's `λ̂` was zero or non-finite, or the components sit
934 /// outside the tested coefficient block. Nothing was selected, so the
935 /// conditional law IS the selection law.
936 NoPenaltyComponents,
937 /// The Schur-complemented information `Ĩ_jj` has no identified direction:
938 /// every direction of the tested block has a data share `1 − p` at the
939 /// decomposition's own noise floor, so there is no basis in which the block
940 /// is standard normal. A term the penalty has absorbed entirely.
941 NoInformation,
942 /// The whitening or a component's root refused: `Ĩ_jj` has no identified
943 /// direction, a decomposition failed, or the components span nothing.
944 GeometryRefused,
945 /// Every scale's window is closed — the fit is railed against both walls of
946 /// the solver's `ρ` box at once, so there was no `λ` it could have chosen
947 /// instead.
948 WindowClosed,
949 /// A grid point could not be evaluated, so the replay would have been taken
950 /// over a grid with a hole in it. Refused whole rather than sampled partial.
951 GridRefused,
952}
953
954impl SmoothLrSelectionDecline {
955 /// The serialized label surfaced in reports and failure messages.
956 pub fn label(self) -> &'static str {
957 match self {
958 SmoothLrSelectionDecline::NoPenaltyComponents => "no_penalty_components",
959 SmoothLrSelectionDecline::NoInformation => "no_information",
960 SmoothLrSelectionDecline::GeometryRefused => "geometry_refused",
961 SmoothLrSelectionDecline::WindowClosed => "window_closed",
962 SmoothLrSelectionDecline::GridRefused => "grid_refused",
963 }
964 }
965}
966
967/// The λ̂-selection replay, or the named reason there is none.
968///
969/// This is an enum rather than an `Option` so that a consumer cannot read
970/// "no replay" without reading why — the two branches are different statements
971/// about the fit and the driver has always known which one it made.
972#[derive(Clone, Debug, PartialEq)]
973pub enum SmoothLrSelection {
974 /// `λ̂` was replayed over the box the solver left it.
975 Replayed(SmoothLrSelectionReplay),
976 /// It was not, for this reason.
977 Declined(SmoothLrSelectionDecline),
978}
979
980impl SmoothLrSelection {
981 /// The replay, when there is one.
982 pub fn replay(&self) -> Option<&SmoothLrSelectionReplay> {
983 match self {
984 SmoothLrSelection::Replayed(replay) => Some(replay),
985 SmoothLrSelection::Declined(_) => None,
986 }
987 }
988
989 /// The decline reason, when there is no replay.
990 pub fn decline(&self) -> Option<SmoothLrSelectionDecline> {
991 match self {
992 SmoothLrSelection::Replayed(_) => None,
993 SmoothLrSelection::Declined(reason) => Some(*reason),
994 }
995 }
996}
997
998impl SmoothLrSelectionReplay {
999 /// Generate the replay for one term from its whitened penalty geometry and
1000 /// the window of `ln t` the fit's own `ρ` box leaves open around the fitted
1001 /// point — ONE window per scale, because the outer search moved each `ρ_i`
1002 /// independently inside that box.
1003 ///
1004 /// Declines — with a reason — when the term has no penalized direction
1005 /// (nothing to select), the geometry could not be whitened, or every window
1006 /// is empty (the fit is railed against both walls), in which case the
1007 /// conditional law IS the selection law and the caller should use it
1008 /// unmodified.
1009 fn generate(
1010 whitener: &Array2<f64>,
1011 unit_penalties: &[Array2<f64>],
1012 log_lambda: &[f64],
1013 log_scale_windows: &[(f64, f64)],
1014 ) -> SmoothLrSelection {
1015 if unit_penalties.is_empty() || unit_penalties.len() != log_lambda.len() {
1016 return SmoothLrSelection::Declined(
1017 SmoothLrSelectionDecline::NoPenaltyComponents,
1018 );
1019 }
1020 if whitener.ncols() == 0 {
1021 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::NoInformation);
1022 }
1023 let Some(geometry) = SelectionGeometry::whiten(whitener, unit_penalties, log_lambda)
1024 else {
1025 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::GeometryRefused);
1026 };
1027 Self::from_geometry(
1028 &geometry,
1029 log_scale_windows,
1030 SMOOTH_LR_SELECTION_DRAWS,
1031 SMOOTH_LR_MULTISCALE_DRAWS,
1032 )
1033 }
1034
1035 /// Dispatch: a term selecting `m` scales inside the budget gets the
1036 /// `m`-dimensional replay; anything else gets the common-scale slice.
1037 fn from_geometry(
1038 geometry: &SelectionGeometry,
1039 log_scale_windows: &[(f64, f64)],
1040 diagonal_draws: usize,
1041 multiscale_draws: usize,
1042 ) -> SmoothLrSelection {
1043 if log_scale_windows.len() != geometry.roots.len() {
1044 return SmoothLrSelection::Declined(
1045 SmoothLrSelectionDecline::NoPenaltyComponents,
1046 );
1047 }
1048 let scales = geometry.roots.len();
1049 if (2..=SMOOTH_LR_SELECTION_MAX_SCALES).contains(&scales) {
1050 return match Self::generate_multiscale(
1051 geometry,
1052 log_scale_windows,
1053 multiscale_draws,
1054 MultiscaleBudget::SHIPPED,
1055 ) {
1056 Ok(replay) => SmoothLrSelection::Replayed(replay),
1057 // A closed multi-scale window is not the end of the story: the
1058 // common-scale slice intersects the same windows and declines
1059 // for ITSELF if there is genuinely nothing to move. Any other
1060 // refusal is about the geometry, which the slice shares, so it
1061 // stands rather than being retried.
1062 Err(SmoothLrSelectionDecline::WindowClosed) => {
1063 Self::generate_common_scale(geometry, log_scale_windows, diagonal_draws)
1064 }
1065 Err(reason) => SmoothLrSelection::Declined(reason),
1066 };
1067 }
1068 Self::generate_common_scale(geometry, log_scale_windows, diagonal_draws)
1069 }
1070
1071 /// The COMMON-SCALE replay: every scale moved together, `t_i ≡ t`.
1072 ///
1073 /// This is the whole selection when the term has one penalty, and it is the
1074 /// honest fallback when it has more scales than
1075 /// [`SMOOTH_LR_SELECTION_MAX_SCALES`] — where an `m`-dimensional grid inside
1076 /// the budget would space its axes about `15` apart in `ln λ`, which is not a
1077 /// selection, it is a coin toss.
1078 ///
1079 /// Under a common scale `T(t) = t·T(1)`, so the eigenBASIS does not move and
1080 /// the whole grid is diagonal in one decomposition: the per-point cost is
1081 /// `O(q)` rather than `O(q³)`, which is what pays for the finer `ln t` step.
1082 /// The log-determinant is exact in closed form for the same reason —
1083 /// `log|T(t)|₊ = rank·ln t + log|T(1)|₊` — so the only quantity that has to
1084 /// be priced carefully is the `t`-free constant, and it is, through the
1085 /// stacked roots at the fitted point.
1086 fn generate_common_scale(
1087 geometry: &SelectionGeometry,
1088 log_scale_windows: &[(f64, f64)],
1089 draws: usize,
1090 ) -> SmoothLrSelection {
1091 // Moving every scale together, the reachable set is the INTERSECTION of
1092 // the per-scale windows: a common shift has to keep every `ρ̂_i + ln t`
1093 // inside the solver's box at once.
1094 let (mut low, mut high) = (f64::NEG_INFINITY, f64::INFINITY);
1095 for &(window_low, window_high) in log_scale_windows {
1096 if !(window_low.is_finite() && window_high.is_finite()) {
1097 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::WindowClosed);
1098 }
1099 low = low.max(window_low);
1100 high = high.min(window_high);
1101 }
1102 if !(low.is_finite() && high.is_finite()) || high <= low {
1103 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::WindowClosed);
1104 }
1105 let zero = vec![0.0_f64; geometry.roots.len()];
1106 let Some(fitted) = geometry.at(&zero) else {
1107 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::GridRefused);
1108 };
1109 // `ν_j = eig T(1)`, descending, with the structural rank leading. Only
1110 // the leading `rank` of them are in `range(T)`; the rest are the term's
1111 // unpenalized directions and carry no log-determinant term at any `t`.
1112 let generalized = fitted.eigenvalues.clone();
1113 if !generalized.iter().take(geometry.rank).any(|&nu| nu > 0.0) {
1114 return SmoothLrSelection::Declined(SmoothLrSelectionDecline::GeometryRefused);
1115 }
1116 let constant: f64 = generalized
1117 .iter()
1118 .take(geometry.rank)
1119 .map(|nu| nu.ln())
1120 .sum();
1121
1122 let steps = (((high - low) / SMOOTH_LR_SELECTION_LOG_STEP).ceil() as usize).max(1);
1123 // The fitted point is an explicit extra node. It has to be there twice
1124 // over: the SELECTION must be able to choose the scale the fit chose —
1125 // for the observed data it IS that scale, by construction — and the
1126 // control variate's conditional arm is read there, not at whichever node
1127 // happens to be nearest.
1128 let mut log_grid: Vec<f64> = (0..=steps)
1129 .map(|step| low + (high - low) * (step as f64) / (steps as f64))
1130 .collect();
1131 log_grid.push(0.0);
1132 let fitted_index = log_grid.len() - 1;
1133
1134 let dimension = geometry.dimension;
1135 // Draws first, then ONE pass per grid point carrying a running argmin,
1136 // rather than one pass per draw over the whole grid: the grid's share
1137 // and weight vectors are then read once each per point instead of once
1138 // per (draw, point), and nothing but `draws` scalars is retained.
1139 let mut squares = vec![0.0_f64; draws * dimension];
1140 let mut row = vec![0.0_f64; dimension];
1141 let mut stream = SelectionDrawStream::new(dimension, draws);
1142 for draw in 0..draws {
1143 stream.fill_chi_square_ones(&mut row);
1144 squares[draw * dimension..(draw + 1) * dimension].copy_from_slice(&row);
1145 }
1146
1147 let mut best_criterion = vec![f64::INFINITY; draws];
1148 let mut selection_sample = vec![0.0_f64; draws];
1149 let mut conditional_sample = vec![0.0_f64; draws];
1150 let mut share = vec![0.0_f64; dimension];
1151 let mut weight = vec![0.0_f64; dimension];
1152 for (index, &log_t) in log_grid.iter().enumerate() {
1153 let t = log_t.exp();
1154 // `log|I + tT(1)|`, over EVERY direction: an unpenalized one carries
1155 // `log(1 + 0) = 0` and is neither special-cased nor dropped.
1156 let mut log_det_hessian = 0.0_f64;
1157 for (column, &nu) in generalized.iter().enumerate() {
1158 let scaled = t * nu;
1159 let fraction = if scaled.is_finite() {
1160 scaled / (1.0 + scaled)
1161 } else {
1162 1.0
1163 };
1164 share[column] = fraction;
1165 let shrinkage = 1.0 - fraction;
1166 weight[column] = 2.0 * shrinkage - shrinkage * shrinkage;
1167 log_det_hessian += scaled.ln_1p();
1168 }
1169 // `log|T(t)|₊ = rank·ln t + Σ_{j < rank} ln ν_j`, over the STRUCTURAL
1170 // rank. Deciding that index set by `ν_j > 0` is what put a spurious
1171 // `−ln t` per roundoff-positive null direction into the criterion.
1172 let determinant = log_det_hessian - (geometry.rank as f64 * log_t + constant);
1173 for draw in 0..draws {
1174 let coordinates = &squares[draw * dimension..(draw + 1) * dimension];
1175 let mut criterion = determinant;
1176 let mut statistic = 0.0_f64;
1177 for column in 0..dimension {
1178 criterion += coordinates[column] * share[column];
1179 statistic += coordinates[column] * weight[column];
1180 }
1181 if criterion < best_criterion[draw] {
1182 best_criterion[draw] = criterion;
1183 selection_sample[draw] = statistic;
1184 }
1185 if index == fitted_index {
1186 conditional_sample[draw] = statistic;
1187 }
1188 }
1189 }
1190 SmoothLrSelection::Replayed(Self {
1191 generalized: ascending(generalized),
1192 selection_sample,
1193 conditional_sample,
1194 })
1195 }
1196
1197 /// Replay a term whose `λ̂` is a VECTOR, over each of its scales separately.
1198 ///
1199 /// A single-penalty term's selection is one-dimensional and diagonalizes
1200 /// (that is [`Self::generate_common_scale`]). A term with `m` penalties — a
1201 /// double-penalty smooth is `m = 2`, a tensor product more — selects `m`
1202 /// scales, and no single basis diagonalizes `m` penalties against the
1203 /// information at once. Scaling all of them together is a one-dimensional
1204 /// SLICE of that selection, and the slice is not enough: on a two-penalty
1205 /// Gaussian null,
1206 ///
1207 /// ```text
1208 /// α = .20 .10 .05 .01
1209 /// conditional (no replay) .4080 .3400 .2800 .1800
1210 /// replay, common scale only .2840 .1440 .0720 .0040
1211 /// replay, both scales .0440 .0120 .0080 .0040
1212 /// ```
1213 ///
1214 /// so the term's own `m` scales are gridded independently here. The absolute
1215 /// numbers in that table are from a harness whose own outer optimizer is a
1216 /// Nelder–Mead on a flat two-dimensional REML surface and are not to be read
1217 /// as calibration figures; the ORDERING is what it establishes, and the
1218 /// ordering is that the missing dimensions matter more than anything else
1219 /// measured on this issue.
1220 ///
1221 /// Each axis carries its OWN window. The reachable set for scale `i` is
1222 /// `ln t_i ∈ [−RHO_BOUND − ρ̂_i, RHO_BOUND − ρ̂_i]`, and those `m` intervals
1223 /// are only equal when the `m` fitted scales are. Handing this grid the
1224 /// COMMON-shift intersection — which is what it used to receive — truncates
1225 /// every axis to the narrowest one and empties the whole replay as soon as
1226 /// one `λ̂` rails, which for a null-true double-penalty smooth is the normal
1227 /// state and not a corner case.
1228 ///
1229 /// # The grid is a BRACKET, and the selection is a DESCENT
1230 ///
1231 /// `SMOOTH_LR_SELECTION_GRID_BUDGET^(1/m)` points per axis is a bounded cost
1232 /// and an unbounded error. At `m = 2` it is 21 points over a window the box
1233 /// opens to 60 wide — `3.0` in `ln λ` — while the fit whose selection this
1234 /// replays had a continuum, and the one-dimensional lane next door commits
1235 /// to `0.05`. A grid that cannot find the criterion's minimum returns a law
1236 /// that is selected LESS than the statistic it is the reference for, and
1237 /// that error has one sign: it under-disperses, so the tail it is read at is
1238 /// too thin and the test over-rejects. Measured on a whitened bending+ridge
1239 /// pair at the `ρ̂` separations a null-true `s(z)` reaches, 2048 draws:
1240 ///
1241 /// ```text
1242 /// arm grid per_axis spacing E[W(t̂)] sd q95 wall
1243 /// grid only 441 21 3.000 2.1334 2.9094 7.1898 0.10s
1244 /// grid only 1681 41 1.500 2.4212 3.3266 9.4427 0.38s
1245 /// grid only 6561 81 0.750 2.4928 3.3656 9.2994 1.48s
1246 /// grid only 25921 161 0.375 2.5192 3.3783 9.3892 5.80s
1247 /// grid + descent 441 21 3.000 2.5258 3.3779 9.3278 0.50s
1248 /// grid + descent 121 11 6.000 2.5258 3.3779 9.3278 0.52s
1249 /// ```
1250 ///
1251 /// The shipped budget was `15%` short in the mean and `23%` short at `q95`,
1252 /// which is where `α = 0.05` is read. Sixty times the grid does not fix it —
1253 /// `25921` points is still `0.375` — because the grid is the wrong
1254 /// instrument. Each draw now DESCENDS the criterion from its own bracket
1255 /// node, by a compass search that halves its step whenever a sweep fails,
1256 /// down to the same `0.05` floor the diagonal lane uses. That reproduces the
1257 /// `161²` law to `0.3%` from a bracket of 121 points, i.e. by making the
1258 /// grid smaller rather than larger.
1259 ///
1260 /// Past [`SMOOTH_LR_SELECTION_MAX_SCALES`] scales the bracket would be four
1261 /// points per axis, which is not a bracket, and the common-scale slice is
1262 /// used instead.
1263 fn generate_multiscale(
1264 geometry: &SelectionGeometry,
1265 log_scale_windows: &[(f64, f64)],
1266 draws: usize,
1267 budget: MultiscaleBudget,
1268 ) -> Result<Self, SmoothLrSelectionDecline> {
1269 let scales = geometry.roots.len();
1270 if scales < 2 || scales > SMOOTH_LR_SELECTION_MAX_SCALES {
1271 return Err(SmoothLrSelectionDecline::GeometryRefused);
1272 }
1273 if log_scale_windows.len() != scales {
1274 return Err(SmoothLrSelectionDecline::NoPenaltyComponents);
1275 }
1276 // One axis per scale, budgeted so the total point count does not grow
1277 // with `m`. A scale whose own window is empty — its `λ̂` railed against
1278 // both walls at once — contributes a single node at the fitted point
1279 // rather than sinking the whole replay.
1280 let per_axis = ((budget.grid as f64).powf(1.0 / scales as f64).floor() as usize).max(2);
1281 let mut axes = Vec::<Vec<f64>>::with_capacity(scales);
1282 let mut movable = 0usize;
1283 for &(low, high) in log_scale_windows {
1284 if !(low.is_finite() && high.is_finite()) {
1285 return Err(SmoothLrSelectionDecline::WindowClosed);
1286 }
1287 if high <= low {
1288 axes.push(vec![0.0]);
1289 continue;
1290 }
1291 movable += 1;
1292 axes.push(
1293 (0..per_axis)
1294 .map(|step| low + (high - low) * (step as f64) / ((per_axis - 1) as f64))
1295 .collect(),
1296 );
1297 }
1298 if movable == 0 {
1299 return Err(SmoothLrSelectionDecline::WindowClosed);
1300 }
1301
1302 // The grid gets ONE extra point: the fitted `λ̂` itself, `ln t_i = 0` on
1303 // every axis. It has to be there twice over. The selection must be able
1304 // to choose the scale the fit chose — for the observed data it IS that
1305 // scale, by construction — and the control variate's conditional arm has
1306 // to be read AT it, not at whichever node happens to be nearest. With
1307 // `441^(1/2) = 21` points over a span of `35` the nearest node can be
1308 // `0.9` away in `ln λ`, a factor of 2.4, and the "conditional" sample
1309 // would then be a different law from the one whose tail the shift is
1310 // added to.
1311 let points = axes.iter().map(|axis| axis.len()).product::<usize>() + 1;
1312 let fitted_index = points - 1;
1313
1314 // The grid is STREAMED rather than materialized, and every draw is
1315 // projected through one grid point at a time with a single matrix
1316 // product.
1317 //
1318 // The arithmetic is identical — `draws × points × q²` either way — but
1319 // the shape is not. The per-draw loop it replaces read
1320 // `basis[[row, column]]` with `row` innermost, i.e. a strided,
1321 // bounds-checked walk down a column, `draws × q²` times per point; this
1322 // is one `(draws × q)·(q × q)` `dot` per point followed by a contiguous
1323 // row reduction. It also drops the `points × q × q` of stored bases,
1324 // which at `441` points and `q = 11` was the bulk of the replay's
1325 // footprint.
1326 let dimension = geometry.dimension;
1327 let mut normals = Array2::<f64>::zeros((draws, dimension));
1328 let mut row = vec![0.0_f64; dimension];
1329 let mut stream = SelectionDrawStream::new(dimension, draws);
1330 for draw in 0..draws {
1331 stream.fill_normals(&mut row);
1332 for column in 0..dimension {
1333 normals[[draw, column]] = row[column];
1334 }
1335 }
1336
1337 let mut best_criterion = vec![f64::INFINITY; draws];
1338 let mut selection_sample = vec![0.0_f64; draws];
1339 let mut conditional_sample = vec![0.0_f64; draws];
1340 // Where each draw's own selection landed on the coarse grid — the
1341 // bracket the refinement below descends from. One `m`-vector per draw.
1342 let mut best_log_t = vec![0.0_f64; draws * scales];
1343 let mut generalized = Vec::new();
1344 let mut log_t = vec![0.0_f64; scales];
1345 for point in 0..points {
1346 let mut remainder = point;
1347 for (scale, axis) in axes.iter().enumerate() {
1348 log_t[scale] = if point == fitted_index {
1349 0.0
1350 } else {
1351 let step = remainder % axis.len();
1352 remainder /= axis.len();
1353 axis[step]
1354 };
1355 }
1356 let evaluated = geometry
1357 .at(&log_t)
1358 .ok_or(SmoothLrSelectionDecline::GridRefused)?;
1359 if point == fitted_index {
1360 generalized = ascending(evaluated.eigenvalues.clone());
1361 }
1362 let projected = normals.dot(&evaluated.basis);
1363 for draw in 0..draws {
1364 let coordinates = projected.row(draw);
1365 let mut criterion = evaluated.offset;
1366 let mut statistic = 0.0_f64;
1367 for column in 0..dimension {
1368 let square = coordinates[column] * coordinates[column];
1369 criterion += square * evaluated.shares[column];
1370 statistic += square * evaluated.weights[column];
1371 }
1372 if criterion < best_criterion[draw] {
1373 best_criterion[draw] = criterion;
1374 selection_sample[draw] = statistic;
1375 best_log_t[draw * scales..(draw + 1) * scales].copy_from_slice(&log_t);
1376 }
1377 if point == fitted_index {
1378 conditional_sample[draw] = statistic;
1379 }
1380 }
1381 }
1382
1383 // THE GRID IS A BRACKET, NOT THE SELECTION. Every draw now descends the
1384 // criterion from its own coarse node, which is what makes the replayed
1385 // `λ̂` the same KIND of object as the fitted one.
1386 let coarse_step: Vec<f64> = axes
1387 .iter()
1388 .zip(log_scale_windows.iter())
1389 .map(|(axis, &(low, high))| {
1390 if axis.len() < 2 {
1391 0.0
1392 } else {
1393 // HALF the grid spacing, because a full step lands on the
1394 // neighbouring node — a point the grid has already scored
1395 // and this draw has already rejected. The first sweep would
1396 // be four guaranteed misses.
1397 0.5 * (high - low) / (axis.len() - 1) as f64
1398 }
1399 })
1400 .collect();
1401 if coarse_step.iter().any(|&size| size > budget.refine_floor) {
1402 // The draw's coordinates in the range basis, and the squared norm
1403 // of the whole draw — both `t`-free, so both are formed once.
1404 let range_coordinates = normals.dot(&geometry.range_basis);
1405 let mut factor = SelectionFactor::new(geometry);
1406 let mut trial = vec![0.0_f64; scales];
1407 let mut coordinates = vec![0.0_f64; geometry.rank];
1408 for draw in 0..draws {
1409 let norm_squared = normals
1410 .row(draw)
1411 .iter()
1412 .map(|value| value * value)
1413 .sum::<f64>();
1414 for column in 0..geometry.rank {
1415 coordinates[column] = range_coordinates[[draw, column]];
1416 }
1417 let current = &mut best_log_t[draw * scales..(draw + 1) * scales];
1418 // The baseline is re-read THROUGH THE REFINEMENT'S OWN
1419 // arithmetic. The grid priced this same point with the eigen
1420 // route; the two agree to roundoff, and comparing a trial
1421 // against the other route's rounding would accept or reject
1422 // moves on `1e-16`.
1423 if !factor.refactor(geometry, current) {
1424 continue;
1425 }
1426 let (mut value, mut statistic) = factor.score(&coordinates, norm_squared);
1427 let mut step = coarse_step.clone();
1428 let mut evaluations = 0usize;
1429 while evaluations < budget.refine_evaluations
1430 && step.iter().any(|&size| size > budget.refine_floor)
1431 {
1432 let mut improved = false;
1433 for scale in 0..scales {
1434 if !(step[scale] > budget.refine_floor) {
1435 continue;
1436 }
1437 let (low, high) = log_scale_windows[scale];
1438 for direction in [-1.0_f64, 1.0] {
1439 let moved = (current[scale] + direction * step[scale]).clamp(low, high);
1440 if moved == current[scale] {
1441 continue;
1442 }
1443 trial.copy_from_slice(current);
1444 trial[scale] = moved;
1445 if !factor.refactor(geometry, &trial) {
1446 continue;
1447 }
1448 evaluations += 1;
1449 let (criterion, moved_statistic) =
1450 factor.score(&coordinates, norm_squared);
1451 if criterion < value {
1452 value = criterion;
1453 statistic = moved_statistic;
1454 current[scale] = moved;
1455 improved = true;
1456 }
1457 if evaluations >= budget.refine_evaluations {
1458 break;
1459 }
1460 }
1461 if evaluations >= budget.refine_evaluations {
1462 break;
1463 }
1464 }
1465 if !improved {
1466 for size in step.iter_mut() {
1467 *size *= 0.5;
1468 }
1469 }
1470 }
1471 selection_sample[draw] = statistic;
1472 }
1473 }
1474
1475 Ok(Self {
1476 generalized,
1477 selection_sample,
1478 conditional_sample,
1479 })
1480 }
1481 /// `E[W(λ̂)]` under the replayed SELECTION law — the mean of the reference
1482 /// this term's p-value is actually read from.
1483 ///
1484 /// It is published because it is the only quantity that makes the
1485 /// construction checkable at the level of a moment. `ref_df = Σ_j w_j` is
1486 /// the CONDITIONAL mean `E[W | λ̂]`, and under a null DGP the empirical mean
1487 /// of `W` does NOT converge to it: `λ̂` is picked from the same data, so the
1488 /// pairing is per-replicate and the unconditional means need not agree —
1489 /// measured on this issue's own fixture at a ratio of `2.34`. Reading that
1490 /// ratio as a defect is the mistake this thread made twice. What the
1491 /// empirical mean IS comparable to is this number.
1492 pub fn selection_mean(&self) -> f64 {
1493 Self::mean(&self.selection_sample)
1494 }
1495
1496 /// `E[W | λ̂]` on the SAME draws — the conditional law's mean, for the
1497 /// paired comparison that shows what the selection did.
1498 pub fn conditional_mean(&self) -> f64 {
1499 Self::mean(&self.conditional_sample)
1500 }
1501
1502 /// The Monte-Carlo standard error of [`Self::selection_mean`], from the
1503 /// sample's own spread rather than from an assumed shape.
1504 pub fn selection_mean_standard_error(&self) -> f64 {
1505 let draws = self.selection_sample.len();
1506 if draws < 2 {
1507 return f64::NAN;
1508 }
1509 let mean = self.selection_mean();
1510 let variance = self
1511 .selection_sample
1512 .iter()
1513 .map(|value| (value - mean) * (value - mean))
1514 .sum::<f64>()
1515 / draws as f64;
1516 (variance / draws as f64).sqrt()
1517 }
1518
1519 fn mean(sample: &[f64]) -> f64 {
1520 if sample.is_empty() {
1521 return f64::NAN;
1522 }
1523 sample.iter().sum::<f64>() / sample.len() as f64
1524 }
1525
1526 /// `(shift, standard_error)`: how much the selection moves the tail at
1527 /// `statistic`, and the Monte-Carlo standard error of that shift.
1528 ///
1529 /// The shift is `P̂(W_sel ≥ x) − P̂(W_cond ≥ x)` on shared draws. Its variance
1530 /// is that of the paired indicator DIFFERENCE `d_i ∈ {−1, 0, +1}`, which is
1531 /// zero on every draw whose selected `t` did not move it across `x` — that
1532 /// is the control variate, and it is why the standard error is a fraction of
1533 /// the naive `√(p(1−p)/N)`.
1534 fn tail_shift(&self, statistic: f64) -> (f64, f64) {
1535 let draws = self.selection_sample.len();
1536 if draws == 0 {
1537 return (0.0, 0.0);
1538 }
1539 let mut sum = 0.0_f64;
1540 let mut sum_squares = 0.0_f64;
1541 for (&selected, &held) in self
1542 .selection_sample
1543 .iter()
1544 .zip(self.conditional_sample.iter())
1545 {
1546 let difference = f64::from(selected >= statistic) - f64::from(held >= statistic);
1547 sum += difference;
1548 sum_squares += difference * difference;
1549 }
1550 let count = draws as f64;
1551 let shift = sum / count;
1552 // `d_i ∈ {−1, 0, +1}` and is zero on every draw whose selected `t` left
1553 // it on the same side of `statistic` — which is most of them. That is
1554 // the control variate, and this is its own sample variance rather than
1555 // the `√(p(1−p)/N)` of either term alone.
1556 let variance = (sum_squares / count - shift * shift).max(0.0);
1557 (shift, (variance / count).sqrt())
1558 }
1559}
1560
1561/// Deterministic `χ²_1` draws for the selection replay.
1562///
1563/// A p-value must not depend on a thread count, a machine or a run (#1017), so
1564/// the replay cannot take draws from a shared or seeded-at-startup generator. It
1565/// uses a counter-based stream instead: SplitMix64 on an index, mapped through
1566/// [`gam_math::probability::standard_normal_quantile`] and squared. Same
1567/// spectrum, same window, same numbers, everywhere, forever.
1568///
1569/// The stream is STRATIFIED per coordinate: draw `i` of coordinate `k` takes its
1570/// uniform from the `i`-th of `N` equal bins, in an order permuted per
1571/// coordinate. That is a Latin hypercube, and for a functional that is nearly a
1572/// sum over coordinates — which `W = Σ_k w_k u_k²` is exactly — it removes the
1573/// part of the Monte-Carlo error the bins already account for.
1574struct SelectionDrawStream {
1575 /// The `N` stratum midpoints, mapped through the normal quantile and
1576 /// squared. Every coordinate draws from THIS set — only the order differs —
1577 /// so the quantile is evaluated `N` times per term rather than `N × q`.
1578 values: Vec<f64>,
1579 /// The same strata as SIGNED normal quantiles.
1580 signed: Vec<f64>,
1581 /// One permutation of `0..N` per coordinate.
1582 permutations: Vec<Vec<u32>>,
1583 index: usize,
1584}
1585
1586impl SelectionDrawStream {
1587 fn new(dimension: usize, draws: usize) -> Self {
1588 let signed: Vec<f64> = (0..draws)
1589 .map(|bin| {
1590 // Bin midpoint: never `0` or `1`, so the quantile is finite.
1591 let uniform = (bin as f64 + 0.5) / draws as f64;
1592 gam_math::probability::standard_normal_quantile(uniform)
1593 .expect("a bin midpoint is strictly inside (0, 1)")
1594 })
1595 .collect();
1596 let values: Vec<f64> = signed.iter().map(|normal| normal * normal).collect();
1597 let mut permutations = Vec::with_capacity(dimension);
1598 for coordinate in 0..dimension {
1599 let mut order: Vec<u32> = (0..draws as u32).collect();
1600 // Fisher–Yates with a counter-based stream keyed by the coordinate.
1601 let mut state = 0x9E37_79B9_7F4A_7C15_u64
1602 ^ (coordinate as u64).wrapping_mul(0x94D0_49BB_1331_11EB);
1603 for position in (1..order.len()).rev() {
1604 state = split_mix64(state);
1605 let pick = (state % (position as u64 + 1)) as usize;
1606 order.swap(position, pick);
1607 }
1608 permutations.push(order);
1609 }
1610 Self {
1611 values,
1612 signed,
1613 permutations,
1614 index: 0,
1615 }
1616 }
1617
1618 /// Fill one draw. The `zip` is the length contract: the stream writes one
1619 /// value per coordinate it was built for and nothing beyond, so a
1620 /// mis-sized buffer is a short write rather than an assertion.
1621 fn fill_chi_square_ones(&mut self, out: &mut [f64]) {
1622 for (slot, permutation) in out.iter_mut().zip(self.permutations.iter()) {
1623 *slot = self.values[permutation[self.index] as usize];
1624 }
1625 self.index += 1;
1626 }
1627
1628 /// The same stratified draw as SIGNED normals, for the multi-scale replay:
1629 /// there the tested block is not diagonal at every grid point, so the
1630 /// quadratic form needs the vector and not its coordinatewise squares. The
1631 /// sign is taken from the stratum's own side of the median, which is what
1632 /// makes it the normal quantile rather than its absolute value.
1633 fn fill_normals(&mut self, out: &mut [f64]) {
1634 for (slot, permutation) in out.iter_mut().zip(self.permutations.iter()) {
1635 *slot = self.signed[permutation[self.index] as usize];
1636 }
1637 self.index += 1;
1638 }
1639}
1640
1641/// SplitMix64, used only to permute the strata. Any full-period mixer would do;
1642/// what matters is that it is a pure function of an index.
1643#[inline]
1644fn split_mix64(state: u64) -> u64 {
1645 let mut z = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1646 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1647 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1648 z ^ (z >> 31)
1649}
1650
1651/// The reference distribution [`SmoothTermLrInference`] scores its statistic
1652/// against, reported as the two spectral moments it is built from (#2672).
1653///
1654/// # What the statistic's null law actually is
1655///
1656/// Expand the log-likelihood quadratically about the unpenalized MLE `β̃` and
1657/// write `I = X'WX`, `S` for the penalty, `H = I + S`, `j` for the tested block
1658/// and `n` for the retained one. The penalized fit is `β̂ = Fβ̃` with
1659/// `F = H⁻¹I`, and the null fit is the retained block's own projection, so
1660///
1661/// ```text
1662/// W = β̃_j' (Ĩ_jj − N) β̃_j , β̃_j ~ N(0, Ĩ_jj⁻¹)
1663/// ```
1664///
1665/// with `Ĩ_jj = I_jj − I_jn I_nn⁻¹ I_nj` the Schur complement and
1666/// `N = [S H⁻¹ I H⁻¹ S]_jj`. Setting `H̃ = Ĩ_jj + S_jj` and `P = H̃⁻¹S_jj`, that
1667/// collapses to `(Ĩ_jj − N)Ĩ_jj⁻¹ = H̃(I − P²)H̃⁻¹`, and — because the block of
1668/// the GLOBAL influence matrix equals the Schur-complement influence,
1669/// `F_jj = H̃⁻¹Ĩ_jj = I − P` — the eigenvalues are exactly `2F_jj − F_jj²`.
1670/// So
1671///
1672/// ```text
1673/// W = Σ_j w_j χ²_1 , w = eig(2·F_jj − F_jj²) ∈ (0, 1]^q.
1674/// ```
1675///
1676/// # Consequences, and what this replaced
1677///
1678/// `Σ w_j = 2 tr(F_jj) − tr(F_jj²)` is Wood's `edf1`. So `edf1` is not a
1679/// citation here, it is the statistic's first-order null MEAN, derived. What it
1680/// is not is a chi-square degrees of freedom: `Var(W) = 2 Σ w_j²` against the
1681/// mean-matched `χ²_{Σw}`'s `2 Σ w_j`, and `w_j ≤ 1`, so a mean-matched chi-square
1682/// is over-dispersed for every penalized term and the test is conservative by
1683/// construction.
1684///
1685/// # Why the reference is the spectrum and not two of its moments
1686///
1687/// Matching the second moment as well — `W ≈ g·χ²_ν` with `ν = (Σw)²/Σw²`,
1688/// `g = Σw²/Σw` — fixes the *shape* with no free constant, and is EXACT whenever
1689/// the weights are equal (which includes the classical unpenalized case
1690/// `w ≡ 1 ⇒ ν = q, g = 1 ⇒ χ²_q`). It is not exact otherwise, and the error is
1691/// one-signed and grows with the depth of the tail, which is the half of the
1692/// p-value range that decides anything. Measured against the exact law on the
1693/// shrinkage spectrum `f_j = 1/(1 + λγ_j)` of a second-difference penalty, over
1694/// six decades of `λ` and `k ∈ {6, 12, 20}`, the size a two-moment reference
1695/// actually delivers at a nominal `α` is
1696///
1697/// ```text
1698/// α = 0.05 0.99 – 1.02 × α = 1e-3 1.01 – 1.31 ×
1699/// α = 0.01 1.00 – 1.11 × α = 1e-4 1.14 – 1.61 ×
1700/// ```
1701///
1702/// i.e. it is fine where the test is least discriminating and up to 61%
1703/// anti-conservative where it is most.
1704///
1705/// Where the gap lives matters as much as its size, and it is not where the
1706/// intuition puts it. The surrogate is exact at BOTH ends of the shrinkage
1707/// range — `w ≡ 1` unpenalized, and a single distinct weight once REML has
1708/// shrunk a term to its null space (measured on a null-true `k = 12` fit:
1709/// `w = (0.322, 5.9e-7, 7.1e-8, …)`, where the two references agree to eight
1710/// figures). It opens in the middle, at moderate shrinkage, which is exactly
1711/// where a smooth term carrying real signal sits. Nothing about the statistic requires that
1712/// trade: the weights are the parameters of an exactly invertible
1713/// characteristic function, and [`gam_math::probability::weighted_chi_square_sf`]
1714/// inverts it (Imhof) with a *returned* truncation bound of `1e-11` — eight
1715/// orders below the smallest tail any of the numbers above resolves. So the
1716/// reference is `P(Σ_j w_j χ²_1 > W)` itself, and the `(ν, g)` pair survives only
1717/// as a two-number summary of the spectrum's shape, published for continuity and
1718/// no longer consulted when the spectrum is known.
1719///
1720/// The one-moment reference this replaced sits at the far end of the same axis,
1721/// with the same sign: on a spectrum shaped like a shrunk smooth
1722/// (`0.08, 0.02, 0.005, 0.001, 2e-4`), at `x = 8·Σw` the exact tail is `1.4e-3`
1723/// and the mean-matched chi-square reports `3.6e-2` — 26× conservative.
1724///
1725/// # What went away with the mean-only reference
1726///
1727/// Three things, and none of them needed a replacement:
1728///
1729/// * `+ tr(X'WX · J Var(ρ̂) Jᵀ)/φ`, the Wood–Pya–Säfken smoothing-parameter
1730/// inflation added under #1872. That is a *coefficient-covariance* correction
1731/// for AIC; it is not a term in this statistic's null law, and it is largest
1732/// exactly where the outer criterion is flattest — i.e. where the term has the
1733/// LEAST effective d.f. Measured on the #2672 fixture: a replicate with
1734/// `edf = 0.070` was handed `rho_uncertainty = 1.79`, twenty-five times the
1735/// term's own effective d.f. It was holding the size up by an unrelated
1736/// mechanism. λ̂'s sampling variation enters `E[W]` through the estimated-λ
1737/// Lawley shift already applied as the Bartlett factor, at the `O(n⁻¹)` order
1738/// it belongs to.
1739/// * `.max(edf)` and `.max(null_dim)`. Both are automatic: `w_j = 1` exactly on
1740/// an unpenalized direction, so `Σ w_j ≥ null_dim` by construction, and `Σ w_j`
1741/// dominates `tr(F_jj) = edf` because `w_j = 2f_j − f_j² ≥ f_j` for `f_j ∈ [0,1]`.
1742/// * `.max(1.0)`, the #1766 degeneracy floor. It existed because `χ²_d` with
1743/// `d → 0` reports any positive `W` as maximally significant. The scaled
1744/// reference cannot degenerate that way: as REML shrinks a term the weights and
1745/// the statistic collapse *together*, `W/g` stays `O(1)`, and `ν → q`. The floor
1746/// was a patch on the wrong shape, not on a missing quantity.
1747///
1748/// # And what all of it is the reference FOR
1749///
1750/// Everything above describes the law of `Q`, the statistic a KNOWN-scale
1751/// likelihood ratio is. A profiled Gaussian's `W` is not `Q` — it is
1752/// `n·ln(1 + Q/V) + B`, with `V` the residual sum of squares the same fit
1753/// estimated its `σ̂` from. Scoring `W` against `Q`'s law is anti-conservative
1754/// at `O(1/ν)`: measured `size@.05 = 0.0792` pooled over 480 replicates at
1755/// `n ∈ {30, 50}` against a nominal `0.05`, with the Lawley factor inert
1756/// throughout, so the whole miss belonged here. [`Self::profiled_scale`] is
1757/// that channel and [`SmoothLrProfiledScale`] is the derivation; it is `None`
1758/// on every family whose dispersion is already inside the IRLS weight.
1759#[derive(Clone, Debug, PartialEq)]
1760pub struct SmoothLrReferenceDf {
1761 /// The null spectrum itself, `w_j ∈ [0, 1]`, sorted descending — the whole
1762 /// reference on the [`SmoothLrReferenceSource::NullSpectrum`] lane. Empty on
1763 /// the two lanes that could not reach it, which is exactly the condition
1764 /// under which [`Self::tail_probability`] falls back to the `(ν, g)` pair.
1765 pub weights: Vec<f64>,
1766 /// First spectral moment `Σ_j w_j = 2·tr(F_jj) − tr(F_jj²)` — Wood's `edf1`,
1767 /// and exactly the statistic's first-order null mean `E[W|λ]`. This is the
1768 /// `d` the Lawley Bartlett factor `c = 1 + Δε/d` is denominated in.
1769 pub mean: f64,
1770 /// Second spectral moment `Σ_j w_j² = tr((2F_jj − F_jj²)²)`, i.e. `Var(W)/2`.
1771 pub second_moment: f64,
1772 /// Shape of the two-moment SUMMARY `ν = mean²/second_moment`. It is what the
1773 /// reference used to be, and it is still what the reference is on the
1774 /// [`SmoothLrReferenceSource::SpectralMomentMatch`] and
1775 /// [`SmoothLrReferenceSource::UnitWeightFallback`] lanes; on the exact lane
1776 /// it is a published descriptor of the spectrum's shape and is not consulted.
1777 pub chi_square_df: f64,
1778 /// Scale of that summary, `g = second_moment/mean`. Same status as
1779 /// [`Self::chi_square_df`].
1780 pub scale: f64,
1781 /// The agreement between the two independently-assembled routes to this
1782 /// spectrum, when the fit supplied the inputs for both: the larger of the
1783 /// two relative residuals between `(Σw, Σw²)` read off `[H⁻¹]_jj S_jj` and
1784 /// `(tr A, tr A²)` read off the influence block, `A = 2F_jj − F_jj²`.
1785 ///
1786 /// The two are the same object by an algebraic identity that depends on the
1787 /// penalty being block-diagonal by term AND on `Vb`, `F` and `S` being
1788 /// published in one coefficient basis. Neither is checkable by inspection,
1789 /// and both have been wrong here before (`#2672`'s similarity-map drop, its
1790 /// internal-basis first-order correction, and its block-local
1791 /// `coeff_range`). So the driver measures the identity on every fit that can
1792 /// support it and publishes the number rather than assuming it.
1793 ///
1794 /// `None` when only one route was available — which is a statement about the
1795 /// fit, not a failure.
1796 pub moment_residual: Option<f64>,
1797 /// The term's conditional effective degrees of freedom `tr(F_jj)`
1798 /// (`per_term_edf`), reported for continuity with the summary table and used
1799 /// as the fallback base when neither the spectrum nor its moments are
1800 /// available.
1801 pub edf: f64,
1802 /// The term's joint unpenalized null-space dimension `dim(∩_k null(S_k))`,
1803 /// reported because it is the analytic lower bound on `mean` and therefore
1804 /// the cheapest check that the spectrum was assembled on the right block.
1805 pub null_dim: usize,
1806 /// Which lane supplied the reference.
1807 pub source: SmoothLrReferenceSource,
1808 /// The λ̂-selection replay, or the NAMED reason there is none (#2672).
1809 ///
1810 /// A decline means the conditional law IS the selection law here — nothing
1811 /// was selected — and the tail is read from [`Self::weights`] alone. It is
1812 /// an enum rather than an `Option` because a missing replay is a statement
1813 /// about the fit, and a reader who does not have to look at which statement
1814 /// will not.
1815 pub selection: SmoothLrSelection,
1816 /// The relative resolution of the statistic this reference will be asked
1817 /// about — the fit's own outer convergence tolerance (`FitOptions::tol`).
1818 ///
1819 /// `W = 2(ℓ_full − ℓ_null)` is a difference of two SEPARATELY converged
1820 /// optimizations, so it is not known better than that, and a p-value cannot
1821 /// be more accurate than the statistic it is read from. See
1822 /// [`Self::tail_probability_with_bound`] for what this is used for and why
1823 /// it is not a numerical-accuracy knob.
1824 pub statistic_resolution: f64,
1825 /// The ESTIMATED-SCALE channel, present exactly when the fit profiled its
1826 /// own Gaussian dispersion out of a residual sum of squares (#2672).
1827 ///
1828 /// `None` is a statement about the family, not a missing measurement: every
1829 /// other likelihood on this path carries its dispersion in the IRLS weight
1830 /// and its `W` is not a function of a second, independently-estimated
1831 /// scalar. See [`SmoothLrProfiledScale`].
1832 pub profiled_scale: Option<SmoothLrProfiledScale>,
1833}
1834
1835/// What the reference needs in order to score a statistic whose SCALE was
1836/// estimated from the same residuals (#2672).
1837///
1838/// # The statistic is a ratio, exactly
1839///
1840/// gam's profiled Gaussian log-likelihood is `ℓ = −½[n·ln 2π + n·ln(D/ν) −
1841/// Σ ln w_i + ν]` with `D` the weighted residual sum of squares and `ν` the
1842/// residual degrees of freedom it divides by — so the whole-term LR statistic
1843/// is, with no expansion anywhere,
1844///
1845/// ```text
1846/// W = 2(ℓ_full − ℓ_null) = n·ln(D_0/D_f) + n·ln(ν_f/ν_0) + (ν_0 − ν_f)
1847/// = n·ln(1 + Q/V) + B,
1848/// Q = (D_0 − D_f)/σ², V = D_f/σ², B = n·ln(ν_f/ν_0) + (ν_0 − ν_f).
1849/// ```
1850///
1851/// `Q` is what [`SmoothLrReferenceDf::weights`] is the null spectrum OF, and
1852/// the known-scale reference scores it directly. The shipped reference
1853/// therefore answers the question "how extreme is `Q`" when the question asked
1854/// was "how extreme is `n·ln(1 + Q/V) + B`" — and `V` is a random variable of
1855/// the same data, with mean `ν` and spread `√(2ν)`. Both the mean shift
1856/// `ν/(ν−2)` and the extra spread push the test anti-conservative, and both are
1857/// `O(1/ν)`: invisible at `n = 1000`, worth `0.03` in size at `n = 30`. It is
1858/// the same reason mgcv's smooth-term p-values take an `F` reference when the
1859/// scale is estimated and a `χ²` when it is known.
1860///
1861/// # Inverting it costs nothing, because the map is monotone
1862///
1863/// `W > w ⟺ Q/V > exp((w − B)/n) − 1`, so
1864///
1865/// ```text
1866/// P(W > w) = P( Q − c(w)·V > 0 ), c(w) = expm1((w − B)/n),
1867/// ```
1868///
1869/// a linear combination of independent chi-squares with a NEGATIVE weight,
1870/// evaluated at zero — which is exactly
1871/// [`gam_math::probability::signed_weighted_chi_square_sf_to_tolerance`]. There
1872/// is no expansion, no `κ`-convention to pick, and no separate `F`-family
1873/// approximation: `n` and `ν` appear where the log-likelihood actually put
1874/// them.
1875///
1876/// # Where the residual law comes from
1877///
1878/// The same spectral object the numerator uses, taken over the whole model
1879/// instead of over the tested block. The profiled Gaussian's hat matrix
1880/// `A = X H⁻¹X'W` is symmetric in the whitened coordinates the weighted RSS is
1881/// a sum of squares in (`X̃ = W^{1/2}X`, where `ε̃ = W^{1/2}ε` has covariance
1882/// `σ²I` because `Var(y_i) = σ²/w_i`), with eigenvalues `f_i = 1 − p_i` — the
1883/// `p_i` being the penalty shares `lr_tested_block` returns, which are
1884/// unchanged by the whitening since `H⁻¹S` is — and `n − p` further zeros. The
1885/// true mean is annihilated because it lies in the penalty's null space. So
1886///
1887/// ```text
1888/// V = ε̃'(I − Ã)²ε̃ ~ Σ_i p_i²·χ²_1 + χ²_{n−p},
1889/// ```
1890///
1891/// exact at fixed `λ`, with `n` the POSITIVE-WEIGHT row count in both places.
1892/// The `n − p` unit directions are folded into ONE term with `n − p` degrees of
1893/// freedom, which is what keeps an `n`-sized reference the same cost as a
1894/// `p`-sized one.
1895///
1896/// # What is approximated, stated plainly
1897///
1898/// Three things, all inherited rather than introduced, and none of them the
1899/// `O(1/ν)` term this channel exists to remove.
1900///
1901/// * `Q`'s spectrum is the reference's own claim, unchanged.
1902/// * `Q` and `V` are taken INDEPENDENT — exact for the unpenalized linear model
1903/// by Cochran, approximate under penalization, and the same independence
1904/// every `F` reference for a penalized smooth rests on.
1905/// * `V` is taken CENTRAL. It is exactly central when the mean lies in the
1906/// penalty's null space, which is what the tested term being null gives on
1907/// its own block; a DIFFERENT term in the model carrying real signal that the
1908/// penalty shrinks adds a non-centrality. That inflates `V`, which inflates
1909/// the p-value — so the residual runs conservative, in the direction a test
1910/// is allowed to be wrong.
1911#[derive(Clone, Debug, PartialEq)]
1912pub struct SmoothLrProfiledScale {
1913 /// `n` — the multiplier the profiled `ln σ̂²` carries in the log-likelihood.
1914 pub observations: f64,
1915 /// `B = n·ln(ν_f/ν_0) + (ν_0 − ν_f)`, the part of `W` that is a function of
1916 /// the two fits' residual degrees of freedom and of nothing random.
1917 ///
1918 /// It is NOT negligible and it is not the same sign as the rest: on the
1919 /// `n = 30` Gaussian cells it runs `−0.13` to `−0.61`.
1920 pub deterministic_offset: f64,
1921 /// `p_i²` over the whole model's penalty shares — the non-trivial half of
1922 /// the residual quadratic form's spectrum.
1923 pub residual_weights: Vec<f64>,
1924 /// `n − p`, the residual directions no design column reaches. Each carries
1925 /// weight exactly one, so they are one term with this many degrees of
1926 /// freedom rather than this many terms.
1927 pub residual_unit_dimension: f64,
1928}
1929
1930/// Accumulated-roundoff floor on the requested tail accuracy.
1931///
1932/// The Imhof value is assembled as `0.5 + I/π` over `N` panels, so its own
1933/// arithmetic error is about `ε√N` — at the `10⁵`-panel scale this reference
1934/// reaches, `1e-13`. Asking the quadrature for a bound below that buys panels,
1935/// not digits.
1936const SMOOTH_LR_TAIL_ROUNDOFF_FLOOR: f64 = 1e-13;
1937
1938/// Ceiling on the requested tail accuracy.
1939///
1940/// The derived request degenerates in one place: as `W → 0` the reference's
1941/// density diverges for `ν < 2`, so "how far does the p-value move when `W`
1942/// moves by its own resolution" becomes unbounded — while the p-value there is
1943/// within `1e-3` of one and nothing depends on it. This rail is the statement
1944/// that a probability is reported to at least three decimals whatever the
1945/// derivation says; it binds nowhere else, because `density · ΔW` falls below it
1946/// as soon as `W` leaves the origin.
1947const SMOOTH_LR_TAIL_COARSEST: f64 = 1e-3;
1948
1949impl SmoothLrReferenceDf {
1950 /// `P(W > statistic)` under this reference.
1951 ///
1952 /// On the exact lane this is `P(Σ_j w_j χ²_1 > W)` by Imhof inversion; on the
1953 /// two surrogate lanes it is the two-moment `P(χ²_ν > W/g)`. Both are
1954 /// scale-equivariant in the same way, which is what lets the Bartlett
1955 /// correction be applied as `W/c` on either.
1956 ///
1957 /// A non-finite statistic propagates as `NaN` rather than being scored: the
1958 /// LR statistic is `NaN` exactly when the null refit did not produce a finite
1959 /// log-likelihood, and there is no p-value for a test that was not run.
1960 pub fn tail_probability(&self, statistic: f64) -> f64 {
1961 self.tail_probability_with_bound(statistic).0
1962 }
1963
1964 /// The CONDITIONAL tail — the fixed-`λ` law alone, with the λ̂-selection
1965 /// replay held out. This is what [`Self::tail_probability`] returns when
1966 /// nothing was selected, and it is the reference the replay corrects.
1967 pub fn conditional_tail_probability(&self, statistic: f64) -> f64 {
1968 self.conditional_tail_with_bound(statistic).0
1969 }
1970
1971 /// [`Self::tail_probability`] with the certified absolute bound the
1972 /// quadrature achieved on it.
1973 ///
1974 /// # How accurately the tail is resolved, and why that is derived
1975 ///
1976 /// Imhof's truncation point grows like `ε^{-2/(2+m)}` in the number `m` of
1977 /// weights active at it. A shrunk penalized smooth has ONE weight of order
1978 /// one over a tail of tiny ones, so `m = 1` across the whole useful range
1979 /// and the cost is `ε^{-2/3}`: at `gam-math`'s default `ε = 1e-11` a single
1980 /// p-value on a realistic spectrum measures **0.13 s to 3.3 s**. That is not
1981 /// an accuracy anyone asked for — it is the library's default standing in
1982 /// for a statement about what this particular answer is for.
1983 ///
1984 /// The statement is available. `W = 2(ℓ_full − ℓ_null)` is a difference of
1985 /// two separately-converged optimizations, so it is known to about
1986 /// `ΔW = tol · (W + E[W])` — the fit's own convergence tolerance on the
1987 /// natural scale of the statistic. A p-value is a deterministic function of
1988 /// `W`, so it is known to `|S(W) − S(W + ΔW)|` no matter how well the
1989 /// integral is done. **That** is what the quadrature is asked for, and it is
1990 /// evaluated through the two-moment summary — the distribution that used to
1991 /// BE the reference, which costs nothing and is within a factor of 1.6 of
1992 /// the exact tail everywhere it was measured, so it is an excellent scale
1993 /// for a derivative it is not being asked to be the value of.
1994 ///
1995 /// Resolving finer than this is arithmetic on the fit's own noise; resolving
1996 /// coarser would add some. The achieved bound is returned rather than
1997 /// assumed, so a consumer can see the accuracy instead of inheriting it.
1998 pub fn tail_probability_with_bound(&self, statistic: f64) -> (f64, f64) {
1999 let (conditional, bound) = self.conditional_tail_with_bound(statistic);
2000 let Some(replay) = self.selection.replay() else {
2001 return (conditional, bound);
2002 };
2003 if !conditional.is_finite() {
2004 return (conditional, bound);
2005 }
2006 let (shift, standard_error) = replay.tail_shift(self.selection_threshold(statistic));
2007 (
2008 (conditional + shift).clamp(0.0, 1.0),
2009 bound + 2.0 * standard_error,
2010 )
2011 }
2012
2013 /// The Monte-Carlo standard error the selection replay contributes at this
2014 /// statistic, or zero when nothing was replayed.
2015 ///
2016 /// This is a `O(draws)` pass over two samples, four orders cheaper than the
2017 /// quadrature it is used to budget.
2018 fn selection_standard_error(&self, statistic: f64) -> f64 {
2019 self.selection.replay().map_or(0.0, |replay| {
2020 replay.tail_shift(self.selection_threshold(statistic)).1
2021 })
2022 }
2023
2024 /// The threshold the λ̂-selection replay has to be asked about, which is not
2025 /// the statistic itself once the scale is profiled.
2026 ///
2027 /// The replay samples the statistic's KNOWN-SCALE law `Q` under two ways of
2028 /// choosing `λ`, and its shift is the difference of the two tails at a
2029 /// `Q`-threshold. With an estimated scale the event `W > w` is
2030 /// `Q > c(w)·V`, so the `Q`-threshold is random; the selection correction is
2031 /// `E_V[Δ(c(w)·V)]`, and evaluating `Δ` at `E[V]` is the first-order term of
2032 /// that expectation. `E[V] = Σ_i v_i + (n − p)` is the residual law's own
2033 /// mean, already carried.
2034 ///
2035 /// As `ν → ∞` this returns the statistic: `B → 0`, `E[V]/n → 1`, and
2036 /// `expm1(w/n)·n → w`. So the correction composes with the known-scale
2037 /// behaviour rather than replacing it.
2038 fn selection_threshold(&self, statistic: f64) -> f64 {
2039 let Some(scale) = self.profiled_scale.as_ref() else {
2040 return statistic;
2041 };
2042 let residual_mean: f64 = scale.residual_weights.iter().sum::<f64>()
2043 + scale.residual_unit_dimension;
2044 let ratio = ((statistic - scale.deterministic_offset) / scale.observations).exp_m1();
2045 ratio.max(0.0) * residual_mean
2046 }
2047
2048 /// The statistic's OWN null law, as a list of `λ_j·χ²_{h_j}` terms: the
2049 /// exact spectrum where the fit supplied one, and the two-moment summary
2050 /// `(g, ν)` where it did not.
2051 ///
2052 /// The two lanes are one object here rather than two branches because the
2053 /// summary is not an approximation of a different shape — it is the same
2054 /// linear combination with one term. Reading it this way is what lets the
2055 /// profiled-scale route below apply on every lane instead of only on the
2056 /// lane that reached the spectrum.
2057 fn null_law_terms(&self) -> Vec<gam_math::probability::WeightedChiSquareTerm> {
2058 use gam_math::probability::WeightedChiSquareTerm;
2059 if self.weights.is_empty() {
2060 return vec![WeightedChiSquareTerm {
2061 weight: self.scale,
2062 degrees_of_freedom: self.chi_square_df,
2063 }];
2064 }
2065 self.weights
2066 .iter()
2067 .map(|&weight| WeightedChiSquareTerm {
2068 weight,
2069 degrees_of_freedom: 1.0,
2070 })
2071 .collect()
2072 }
2073
2074 fn conditional_tail_with_bound(&self, statistic: f64) -> (f64, f64) {
2075 if !statistic.is_finite() {
2076 return (f64::NAN, f64::NAN);
2077 }
2078 let summary =
2079 |w: f64| gam_math::probability::chi_square_sf(w / self.scale, self.chi_square_df);
2080 if self.weights.is_empty() && self.profiled_scale.is_none() {
2081 // The summary IS the reference on the two degraded lanes, and it is
2082 // a closed form: no truncation, so no bound to report.
2083 return (summary(statistic), 0.0);
2084 }
2085 let derived = if self.statistic_resolution.is_finite() && self.statistic_resolution > 0.0 {
2086 let delta = self.statistic_resolution * (statistic.abs() + self.mean.abs());
2087 (summary(statistic) - summary(statistic + delta)).abs()
2088 } else {
2089 // A reference built without a fit behind it (a unit test, a
2090 // hand-assembled spectrum) has no statistic resolution to derive
2091 // from, so it gets `gam-math`'s own default rather than a guess.
2092 gam_math::probability::WEIGHTED_CHI_SQUARE_TOLERANCE
2093 };
2094 // AND NO FINER THAN THE ANSWER'S OWN NOISE. The published accuracy of a
2095 // replayed p-value is `quadrature + 2·se`, where `se` is the selection
2096 // shift's Monte-Carlo standard error. Resolving the conditional half
2097 // below `se` cannot improve that sum — it is arithmetic on a number the
2098 // other term has already blurred — while Imhof's truncation point grows
2099 // like `ε^{-2/3}`, so the request is what the cost is made of. Asking
2100 // for exactly `se` caps the published bound at `3·se` against an
2101 // irreducible `2·se`, i.e. within 1.5x of an infinitely accurate
2102 // quadrature, and it is a DERIVED request rather than a budget: with no
2103 // replay the floor is zero and the statistic's own resolution stands.
2104 //
2105 // Measured: with `FitOptions::tol = 1e-10` the derived request is ~1e-10
2106 // — essentially `gam-math`'s strict default — and the module's own table
2107 // puts that at 0.13-3.3 s PER P-VALUE. The driver evaluates three or
2108 // four per term, and `null_simulation_size_is_calibrated_small_n` runs
2109 // 960 of them: it did not finish in 4000 s at the commit this repair
2110 // was measured against, against nextest's 600 s kill.
2111 let tolerance = derived
2112 .max(self.selection_standard_error(statistic))
2113 .clamp(SMOOTH_LR_TAIL_ROUNDOFF_FLOOR, SMOOTH_LR_TAIL_COARSEST);
2114 let mut terms = self.null_law_terms();
2115 let Some(scale) = self.profiled_scale.as_ref() else {
2116 return gam_math::probability::signed_weighted_chi_square_sf_to_tolerance(
2117 &terms, statistic, tolerance,
2118 );
2119 };
2120 // `W > w ⟺ Q/V > expm1((w − B)/n)`, so the tail is the SIGNED
2121 // combination `Q − c·V` at zero. See [`SmoothLrProfiledScale`].
2122 let ratio = ((statistic - scale.deterministic_offset) / scale.observations).exp_m1();
2123 if !ratio.is_finite() {
2124 return (f64::NAN, f64::NAN);
2125 }
2126 if ratio <= 0.0 {
2127 // `W` is at or under the value the two fits' degrees of freedom
2128 // alone produce. `Q ≥ 0` and `V > 0`, so `Q − cV ≥ 0` with
2129 // certainty and the statistic is not evidence of anything.
2130 return (1.0, 0.0);
2131 }
2132 terms.extend(scale.residual_weights.iter().map(|&weight| {
2133 gam_math::probability::WeightedChiSquareTerm {
2134 weight: -ratio * weight,
2135 degrees_of_freedom: 1.0,
2136 }
2137 }));
2138 if scale.residual_unit_dimension > 0.0 {
2139 terms.push(gam_math::probability::WeightedChiSquareTerm {
2140 weight: -ratio,
2141 degrees_of_freedom: scale.residual_unit_dimension,
2142 });
2143 }
2144 gam_math::probability::signed_weighted_chi_square_sf_to_tolerance(&terms, 0.0, tolerance)
2145 }
2146}
2147
2148/// The Bartlett-corrected per-term significance report for one penalized smooth
2149/// term (#1063). Unlike the summary table's Wood rank-truncated **Wald**
2150/// statistic, this is a genuine **likelihood-ratio** statistic from a
2151/// constrained refit (the smooth dropped), so the exact Lawley LR Bartlett
2152/// factor corrects the right quantity.
2153#[derive(Clone, Debug)]
2154pub struct SmoothTermLrInference {
2155 /// Smooth-term name (matches the summary row).
2156 pub name: String,
2157 /// Smooth-term index within `resolvedspec.smooth_terms`.
2158 pub term_idx: usize,
2159 /// The uncorrected likelihood-ratio statistic `W = 2(ℓ_full − ℓ_null)`,
2160 /// floored at zero (a non-negative LR by construction).
2161 pub statistic_lr: f64,
2162 /// The statistic's first-order null mean `d = E[W|λ] = Σ_j w_j`, which is
2163 /// Wood's `edf1 = 2·tr(F_bb) − tr(F_bb²)` exactly (see
2164 /// [`SmoothLrReferenceDf`] for why that is a derivation and not a citation).
2165 /// This is the `d` the Lawley Bartlett factor `c = 1 + Δε/d` is denominated
2166 /// in. It is **not** a chi-square degrees of freedom — the reference the
2167 /// p-values are read from is [`Self::ref_df_provenance`]'s
2168 /// `chi_square_df`/`scale` pair, which coincides with `ref_df` only when the
2169 /// tested block is unpenalized.
2170 pub ref_df: f64,
2171 /// The reference distribution itself: both spectral moments of the null law,
2172 /// the `(ν, g)` pair resolved from them, and which lane supplied it (#2672).
2173 pub ref_df_provenance: SmoothLrReferenceDf,
2174 /// Lawley LR Bartlett factor `c = E[W]/d = 1 + Δε/d` when computable, else
2175 /// `1.0` (no correction).
2176 pub bartlett_factor: f64,
2177 /// Fixed-λ conditional factor `c_cond = 1 + Δε(ρ̂)/d` when the estimated-λ
2178 /// correction was applied. `None` means the applied factor was either the
2179 /// fixed-λ factor itself or no Lawley correction was available.
2180 pub bartlett_factor_conditional: Option<f64>,
2181 /// Increment in Lawley's LR mean shift due solely to ρ̂ sampling variation,
2182 /// `0.5 * tr(H_Δε Cov(ρ̂))`, when estimated-λ correction was applied.
2183 pub rho_variation_shift: Option<f64>,
2184 /// Bartlett-corrected statistic `W* = W / c`.
2185 pub statistic_corrected: f64,
2186 /// Uncorrected tail probability `P(χ²_ν > W/g)` under the null law's own
2187 /// two-moment reference.
2188 pub p_value_uncorrected: f64,
2189 /// Corrected tail probability `P(χ²_ν > W*/g)`; equals the uncorrected value
2190 /// when no correction was applied. Dividing the statistic by `c` and scaling
2191 /// every spectral weight by `c` are the same operation on this reference, so
2192 /// the Bartlett correction composes without a second convention.
2193 pub p_value_corrected: f64,
2194 /// Whether the second-order correction is **material** (#939 deliverable 4):
2195 /// the per-test diagnostic "is `n` too small for first-order inference
2196 /// *here*?". `true` when a correction was applied and it moves the result by
2197 /// more than [`SMOOTH_LR_MATERIAL_THRESHOLD`] — measured as the larger of the
2198 /// relative Bartlett-factor distance from one `|c − 1|` and the relative
2199 /// p-value change `|p* − p| / max(p, p*, ε)`. `false` when `correction` is
2200 /// [`SmoothLrCorrection::None`] (no correction was applied).
2201 pub material: bool,
2202 /// Which statistic the corrected p-value is built from.
2203 pub correction: SmoothLrCorrection,
2204 /// The CONDITIONAL tail of the corrected statistic — the p-value the
2205 /// fixed-`λ` law alone would report, before the λ̂-selection replay moves it
2206 /// (#2672).
2207 ///
2208 /// Published so the correction is visible rather than folded in:
2209 /// `p_value_corrected − p_value_conditional` is exactly what treating `λ̂` as
2210 /// chosen rather than given is worth on this fit, and it is the quantity a
2211 /// reader should be shown if they are going to be asked to accept it. Equal
2212 /// to `p_value_corrected` when no selection was possible.
2213 pub p_value_conditional: f64,
2214 /// Certified absolute accuracy of the two published p-values — the larger of
2215 /// the two truncation bounds the tail quadrature achieved (#2672).
2216 ///
2217 /// `0.0` on the closed-form lanes (a degraded reference, or a spectrum whose
2218 /// weights are all equal) because there is no truncation to bound. On the
2219 /// Imhof lane it is what the sweep reached against the accuracy
2220 /// [`SmoothLrReferenceDf::tail_probability_with_bound`] derived from the
2221 /// fit's own convergence tolerance, so a consumer reads the accuracy rather
2222 /// than inheriting it. A value large enough to matter means the quadrature
2223 /// hit its panel backstop, which is a statement about the spectrum's spread
2224 /// and not a defect in the p-value's derivation.
2225 pub p_value_bound: f64,
2226}
2227
2228/// The materiality threshold for [`SmoothTermLrInference::material`] (#939
2229/// deliverable 4): a correction is flagged material when it changes the result
2230/// by more than 10%.
2231pub const SMOOTH_LR_MATERIAL_THRESHOLD: f64 = 0.10;
2232
2233/// Build `S_b = lambda_b * S_b^unit` as global `p_total x p_total` matrices in
2234/// exactly the fitted rho/lambda ordering. This is the narrow handoff the
2235/// estimated-lambda Lawley correction needs: the same `design.penalties` order
2236/// already paired with `fit.lambdas`, without changing #740's outer-Hessian
2237/// algebra or the production penalty assembly.
2238fn fitted_rho_penalty_components(
2239 penalties: &[BlockwisePenalty],
2240 lambdas: &[f64],
2241 p_total: usize,
2242) -> Result<Vec<gam_terms::inference::lawley::RhoPenaltyComponent>, EstimationError> {
2243 if penalties.len() != lambdas.len() {
2244 return Err(EstimationError::InvalidInput(format!(
2245 "smooth_term_lr_inference: penalty/lambda count mismatch ({} penalties, {} lambdas)",
2246 penalties.len(),
2247 lambdas.len()
2248 )));
2249 }
2250 let mut components = Vec::with_capacity(penalties.len());
2251 for (idx, (penalty, &lambda)) in penalties.iter().zip(lambdas.iter()).enumerate() {
2252 if !(lambda.is_finite() && lambda >= 0.0) {
2253 return Err(EstimationError::InvalidInput(format!(
2254 "smooth_term_lr_inference: lambda[{idx}] is invalid: {lambda}"
2255 )));
2256 }
2257 let r = &penalty.col_range;
2258 if r.end > p_total {
2259 return Err(EstimationError::InvalidInput(format!(
2260 "smooth_term_lr_inference: penalty[{idx}] range {:?} exceeds coefficient dimension {p_total}",
2261 r
2262 )));
2263 }
2264 let mut s_component = Array2::<f64>::zeros((p_total, p_total));
2265 s_component
2266 .slice_mut(s![r.start..r.end, r.start..r.end])
2267 .scaled_add(lambda, &penalty.local);
2268 components.push(gam_terms::inference::lawley::RhoPenaltyComponent { s_component });
2269 }
2270 Ok(components)
2271}
2272
2273/// The end-to-end per-term likelihood-ratio significance report for every
2274/// penalized (shape-unconstrained) smooth term in a fitted model, magically
2275/// Bartlett-corrected when the family carries closed-form Lawley cumulant jets
2276/// (#1063, follow-up to #939).
2277///
2278/// # Why an LR statistic (not the summary Wald)
2279///
2280/// The summary table's `wood_smooth_test` is Wood's rank-truncated **Wald**
2281/// statistic `T = β̂'Σ̂⁻β̂`. Lawley's ε corrects the **likelihood-ratio**
2282/// statistic, and under penalization the Wald form is already a weighted χ²
2283/// whose second-order mean is *not* `d + Δε` — dividing `T` by the LR factor
2284/// would correct the wrong statistic. The principled route (#1063 Option 1) is
2285/// to compute a real per-term LR statistic by a constrained refit and correct
2286/// *that*:
2287///
2288/// ```text
2289/// W = 2(ℓ_full − ℓ_null), W* = W / c, c = 1 + Δε/d, p = P(χ²_d > W*).
2290/// ```
2291///
2292/// # Method
2293///
2294/// 1. Fit the full model and read `ℓ_full` and the per-term coefficient ranges /
2295/// EDF / influence block. The full design's column layout fixes the tested
2296/// block for the Lawley factor.
2297/// 2. For each penalized smooth term, refit a null model with that term dropped
2298/// from the spec; `W = max(2(ℓ_full − ℓ_null), 0)`.
2299/// 3. The reference d.f. `d` is the Wood truncation `tr(F)²/tr(F²)` on the
2300/// term's influence block (the same `ref_df` the summary Wald row reports),
2301/// floored at `max(edf, null_dim, 1)`: this LR test drops the whole term, so
2302/// `d` is at least the dimension the term spans when present (its null-space
2303/// dimension, never below 1). The non-symmetric `tr(F²)` can collapse toward
2304/// 0 at a shrunk-to-null fit and violate that bound — see the inline note at
2305/// the `ref_df` binding.
2306/// 4. When the family has closed-form cumulant jets, evaluate Lawley's ε at the
2307/// **null** linear predictor (an expectation evaluated at the null fit), fold
2308/// the full λ-scaled penalty `S_λ` into the information, and Bartlett-correct
2309/// `W` with [`gam_terms::inference::lawley::lawley_lr_bartlett_factor`]. The
2310/// null annihilates the tested block's penalty (`S_λ β₀ = 0` on that block),
2311/// so the penalized Lawley expansion applies verbatim.
2312/// 5. Otherwise (no closed-form jets, or a null refit that did not converge) the
2313/// uncorrected `χ²_d` stands with provenance `none` — never weakened.
2314///
2315/// Random-effect smooths and shape-constrained smooths are skipped (their tests
2316/// are not a central-χ² LR), matching the summary table's policy.
2317pub fn smooth_term_lr_inference_forspec(
2318 data: ArrayView2<'_, f64>,
2319 y: ArrayView1<'_, f64>,
2320 weights: ArrayView1<'_, f64>,
2321 offset: ArrayView1<'_, f64>,
2322 resolvedspec: &TermCollectionSpec,
2323 family: LikelihoodSpec,
2324 options: &FitOptions,
2325) -> Result<Vec<SmoothTermLrInference>, EstimationError> {
2326 use gam_terms::inference::lawley::{
2327 LAWLEY_PAIR_MATRIX_MAX_ROWS, known_scale_expected_jets_with_dispersion,
2328 lawley_lr_bartlett_factor, lawley_lr_mean_shift_with_rho_variation,
2329 };
2330
2331 let n = data.nrows();
2332 // Full fit: ℓ_full, the per-term coefficient ranges/EDF/influence, and the
2333 // full design whose column layout fixes each tested block for Lawley.
2334 let full = fit_term_collection_forspec(
2335 data,
2336 y,
2337 weights,
2338 offset,
2339 resolvedspec,
2340 family.clone(),
2341 options,
2342 )?;
2343 let ll_full = full.fit.log_likelihood;
2344 let p_total = full.design.design.ncols();
2345 let lambdas = full.fit.lambdas.as_slice().ok_or_else(|| {
2346 EstimationError::InvalidInput(
2347 "smooth_term_lr_inference: non-contiguous lambda vector".to_string(),
2348 )
2349 })?;
2350 let s_lambda = weighted_blockwise_penalty_sum(&full.design.penalties, lambdas, p_total);
2351 let rho_penalty_components =
2352 fitted_rho_penalty_components(&full.design.penalties, lambdas, p_total)?;
2353 let rho_covariance = full.fit.artifacts.rho_covariance.as_ref().filter(|cov| {
2354 cov.nrows() == rho_penalty_components.len() && cov.ncols() == rho_penalty_components.len()
2355 });
2356 // Full design as a dense n×p array for the Lawley pair-matrix reduction.
2357 let full_design_dense = full.design.design.to_dense();
2358 let influence = full.fit.coefficient_influence();
2359 // `H⁻¹`, unscaled: `beta_covariance()` publishes `Vb = H⁻¹·scale`, and the
2360 // scale is the family's own documented coefficient-covariance multiplier
2361 // (`σ̂²` for the profiled Gaussian, `1` for every family whose IRLS weight
2362 // already carries the dispersion). The null spectrum is `1 − eig(H⁻¹_jj
2363 // S_jj)²`, a product of two matrices in reciprocal units, so the multiplier
2364 // has to come off exactly here or every weight is wrong by that factor.
2365 // A family with no scalar multiplier (custom/GAMLSS) yields `None` and the
2366 // reference drops to the two-moment rung, which needs no scale at all.
2367 let hessian_inverse = full
2368 .fit
2369 .coefficient_covariance_scale()
2370 .ok()
2371 .filter(|scale| scale.is_finite() && *scale > 0.0)
2372 .zip(full.fit.beta_covariance())
2373 .map(|(scale, covariance)| covariance.mapv(|value| value / scale));
2374 // `SmoothTerm::coeff_range` is BLOCK-LOCAL — 0-based within the smooth block
2375 // — while the global coefficient layout is `[intercept | linear | random |
2376 // smooth]`. Every consumer that indexes a global object with it has to shift
2377 // by `smooth_start` first (`smooth_term_summary.rs`, the constraint audit and
2378 // the anisotropic provider all do). This driver did not, and it indexes FOUR
2379 // global objects with it: the influence matrix `F` (both the per-term EDF
2380 // trace and Wood's `edf1`), the weighted Gram and correction inside the WPS
2381 // trace, and — worst — the `tested` column set handed to Lawley, which
2382 // decides WHICH HYPOTHESIS the mean shift is computed for.
2383 //
2384 // This is the #1360 defect in a fourth place: the window slides one column
2385 // per preceding parametric column, folding the intercept and the linear
2386 // terms into the smooth's block and dropping as many real smooth columns off
2387 // the end. It is never zero — the intercept alone makes `smooth_start ≥ 1`.
2388 //
2389 // It was invisible because three of the four consumers were degraded to
2390 // index-free fallbacks: `coefficient_influence` was `None` on every model
2391 // with a conditioned parametric column (fixed alongside this, #2672), so
2392 // `per_term_edf` fell through to the penalty-block-trace channel — which is
2393 // indexed by PENALTY block, not by coefficient, and is therefore correct —
2394 // and `wood_reference_df` returned `None` outright. Restoring `F` is what
2395 // made the offset observable: on this issue's `y ~ x + s(z)` fixture the
2396 // per-term EDF of a null smooth jumped from `0.054` (penalty-trace channel,
2397 // correct) to `2.040` (influence trace over columns `0..9`), which is the
2398 // unpenalized intercept's `1` plus the parametric `x`'s `1` plus the smooth's
2399 // own `0.04` — the offset read off the arithmetic.
2400 let smooth_start = p_total.saturating_sub(full.design.smooth.total_smooth_cols());
2401 let fitted_likelihood = resolved_likelihood_for_fit(&full.fit)?;
2402 let family_disp = lawley_dispersion_for_family(&fitted_likelihood, &full.fit)?;
2403 // The estimated-scale channel (#2672), assembled once because every part of
2404 // it except the deterministic offset is a property of the FULL fit and not
2405 // of which term is being tested.
2406 //
2407 // The observation count is the POSITIVE-WEIGHT row count, not the raw one.
2408 // That is the count the optimizer's own `φ̂ = weighted_rss/(n − edf)` uses
2409 // (#584: a zero-weight row is exactly an absent row, and counting it in the
2410 // denominator while the numerator excludes it biases `φ̂` low), and it is
2411 // also the count that multiplies `ln σ̂²` in the log-likelihood, since a
2412 // zero-weight row's `−½(… − ln w_i …)` term is not summable at all. The two
2413 // have to be the same number or `W = n·ln(D_0/D_f) + B` is not an identity.
2414 let profiled_observations = weights.iter().filter(|weight| **weight > 0.0).count();
2415 let profiled_residual_shares = profiled_scale_residual_shares(
2416 &fitted_likelihood,
2417 hessian_inverse.as_ref(),
2418 &s_lambda,
2419 p_total,
2420 )?;
2421 let full_residual_df = profiled_residual_shares
2422 .as_ref()
2423 .and(profiled_residual_degrees_of_freedom(
2424 &full.fit,
2425 profiled_observations,
2426 ));
2427 // `(v, h)`: the non-trivial residual weights and the degrees of freedom of
2428 // the weight-one block. On the exact rung that block is the `n − p`
2429 // directions no column reaches; on the summary rung the whole residual law
2430 // is folded into it at the fit's own `ν`.
2431 let profiled_residual = profiled_residual_shares.zip(full_residual_df).map(
2432 |(shares, residual_df)| match shares {
2433 Some(spectrum) => (spectrum, profiled_observations.saturating_sub(p_total) as f64),
2434 None => (Vec::new(), residual_df),
2435 },
2436 );
2437
2438 let mut out = Vec::<SmoothTermLrInference>::new();
2439 for (term_idx, design_term) in full.design.smooth.terms.iter().enumerate() {
2440 let penalty_range = full
2441 .design
2442 .smooth_term_penalty_range(term_idx)
2443 .map_err(EstimationError::InvalidInput)?;
2444 let (block_start, k) = penalty_range
2445 .map(|range| (range.start, range.len()))
2446 .unwrap_or((0, 0));
2447 // Shape-constrained smooths get no central-χ² LR (cone-projected
2448 // boundary test); the summary table skips them too.
2449 if design_term.shape != ShapeConstraint::None {
2450 continue;
2451 }
2452 // Shifted into the GLOBAL coefficient layout — see `smooth_start` above.
2453 let coeff_range = (smooth_start + design_term.coeff_range.start)
2454 ..(smooth_start + design_term.coeff_range.end);
2455 if coeff_range.start >= coeff_range.end || coeff_range.end > p_total {
2456 continue;
2457 }
2458 // Per-term EDF for the χ² reference df FALLBACK (used only when the
2459 // influence matrix `F` is unavailable). Route through `per_term_edf`,
2460 // which uses the ADDITIVE per-block trace channel
2461 // (`|coeff_range| − Σ_{kk∈term} tr_kk`) and caps at the model total,
2462 // rather than the raw `edf_by_block` block-sum `Σ_{kk}(rank_kk − tr_kk)`.
2463 // For a multi-penalty term (te/ti/double-penalty) the penalties share one
2464 // coefficient range, so the rank-based block-sum OVER-COUNTS the term EDF
2465 // (Σ rank_kk > |coeff_range|) and would inflate the LR reference df,
2466 // biasing the smooth-term test conservative on large/sparse fits where `F`
2467 // is not materialised. (Same per-block over-count class as the multinomial
2468 // `edf_per_class` fix.)
2469 let edf = full.fit.per_term_edf(coeff_range.clone(), block_start, k);
2470 // The term's **joint** unpenalized null-space dimension: the coefficient
2471 // directions penalized by *no* active penalty — the polynomial part a
2472 // penalized smooth always carries when present, which no penalty can
2473 // shrink. This is `dim(∩_k null(S_k)) = p_local − rank(Σ_k S_k)`, the
2474 // INTERSECTION of the per-penalty null spaces, computed by
2475 // `wald_unpenalized_dim()` — the very same scalar the summary Wald test
2476 // (`wood_smooth_test`) floors its reference d.f. at, so the LR and Wald
2477 // tests reference a consistent d.f.
2478 //
2479 // It must NOT be `nullspace_dims.iter().sum()`: that *unions* the null
2480 // spaces (the #1360 defect — see `joint_unpenalized_dim`'s docs). A
2481 // double-penalty smooth carries a bending penalty (null space = its
2482 // polynomial part) plus a complementary null-space ridge (which penalizes
2483 // exactly that polynomial part), so the two null spaces are disjoint and
2484 // the joint null space is EMPTY (dim 0) — yet the per-penalty dims sum to
2485 // ~`p_local`. Flooring `ref_df` at that sum pins it to the full basis
2486 // dimension for every fit (e.g. 11 for a k=12 s(x)), making the LR test
2487 // badly conservative for genuine moderate signals while only accidentally
2488 // masking the collapse.
2489 let null_dim = design_term.wald_unpenalized_dim();
2490 // The reference the whole-term LR statistic is scored against: the first
2491 // two moments of its OWN null law, not a chi-square fitted to its mean.
2492 // See `lr_null_reference` for the derivation and for what this replaced.
2493 // The term's own penalties restricted to the tested coefficient block,
2494 // carried λ-FREE with their `ρ̂_i = ln λ̂_i` alongside. The separation is
2495 // not bookkeeping: the replay's criterion needs `log|Σ_i λ_i S_i|₊`, and
2496 // that quantity is only computable from the components and their scales
2497 // — an assembled sum has already lost it (#2644). See
2498 // [`SelectionGeometry`].
2499 let mut term_penalties = Vec::<Array2<f64>>::new();
2500 let mut term_log_lambda = Vec::<f64>::new();
2501 for (blockwise, &lambda) in full
2502 .design
2503 .penalties
2504 .get(block_start..block_start + k)
2505 .into_iter()
2506 .flatten()
2507 .zip(lambdas[block_start..(block_start + k).min(lambdas.len())].iter())
2508 {
2509 let range = &blockwise.col_range;
2510 if range.start < coeff_range.start || range.end > coeff_range.end {
2511 continue;
2512 }
2513 if !(lambda.is_finite() && lambda > 0.0) {
2514 continue;
2515 }
2516 let mut local = Array2::<f64>::zeros((coeff_range.len(), coeff_range.len()));
2517 let offset = range.start - coeff_range.start;
2518 let width = range.end - range.start;
2519 for row in 0..width {
2520 for column in 0..width {
2521 local[[offset + row, offset + column]] = blockwise.local[[row, column]];
2522 }
2523 }
2524 term_penalties.push(local);
2525 term_log_lambda.push(lambda.ln());
2526 }
2527 // ONE window per scale. The outer search moved each `ρ_i` independently
2528 // inside its box, so scale `i` could reach `ln t_i ∈ [−B − ρ̂_i, B − ρ̂_i]`
2529 // and no further. The single COMMON-shift window this used to compute is
2530 // the intersection of those intervals: correct for a slice that moves
2531 // every scale together (which is what `generate_common_scale` still
2532 // derives from these), and wrong for a grid that moves them separately.
2533 // On a null-true double-penalty smooth at `ρ̂ = (18, −24)` each axis has
2534 // ~50 of room and the intersection leaves 18; when one `λ̂` rails the
2535 // intersection is EMPTY and the replay was declined outright.
2536 let reach = 2.0 * gam_solve::estimate::RHO_BOUND;
2537 let log_scale_windows: Vec<(f64, f64)> = term_log_lambda
2538 .iter()
2539 .map(|&rho| {
2540 // Clamped to the box's own full width: no `ρ` can move further
2541 // than from one wall to the other, and a `λ̂` that underflowed to
2542 // zero would otherwise put `ln t` at `±744` and `t` at infinity.
2543 (
2544 (-gam_solve::estimate::RHO_BOUND - rho).clamp(-reach, reach),
2545 (gam_solve::estimate::RHO_BOUND - rho).clamp(-reach, reach),
2546 )
2547 })
2548 .collect();
2549 let reference = lr_null_reference(
2550 influence,
2551 hessian_inverse.as_ref(),
2552 Some(&s_lambda),
2553 &coeff_range,
2554 edf,
2555 null_dim,
2556 options.tol,
2557 &log_scale_windows,
2558 &term_penalties,
2559 &term_log_lambda,
2560 );
2561 let mut reference = reference;
2562 let ref_df = reference.mean;
2563 if !(ref_df.is_finite()
2564 && ref_df > 0.0
2565 && reference.chi_square_df.is_finite()
2566 && reference.chi_square_df > 0.0
2567 && reference.scale.is_finite()
2568 && reference.scale > 0.0)
2569 {
2570 continue;
2571 }
2572
2573 // Null model: drop this smooth term from the spec and refit. The term's
2574 // name pins which spec entry to remove (design and spec share names).
2575 let mut null_spec = resolvedspec.clone();
2576 let Some(spec_pos) = null_spec
2577 .smooth_terms
2578 .iter()
2579 .position(|t| t.name == design_term.name)
2580 else {
2581 continue;
2582 };
2583 null_spec.smooth_terms.remove(spec_pos);
2584 let null_fit = fit_term_collection_forspec(
2585 data,
2586 y,
2587 weights,
2588 offset,
2589 &null_spec,
2590 family.clone(),
2591 options,
2592 );
2593 let (statistic_lr, eta_null, null_residual_df) = match null_fit {
2594 Ok(null) if null.fit.log_likelihood.is_finite() => {
2595 let w = (2.0 * (ll_full - null.fit.log_likelihood)).max(0.0);
2596 // η at the null fit: X_null β_null + affine_offset + offset
2597 // (per-row linear predictor; design-layout independent — Lawley
2598 // reads it on the full design rows). `compose_offset` folds the
2599 // design's fixed affine channel (non-zero endpoint anchor,
2600 // #2297) into the user offset.
2601 let null_offset = null
2602 .design
2603 .compose_offset(offset, "smooth likelihood-ratio null model")
2604 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2605 let mut eta = null.design.design.dot(&null.fit.beta);
2606 eta += &null_offset;
2607 let residual_df =
2608 profiled_residual_degrees_of_freedom(&null.fit, profiled_observations);
2609 (w, Some(eta), residual_df)
2610 }
2611 _ => (f64::NAN, None, None),
2612 };
2613
2614 // The estimated-scale channel needs BOTH fits' residual degrees of
2615 // freedom, so it is completed here rather than where the rest of the
2616 // reference was built: `B = n·ln(ν_f/ν_0) + (ν_0 − ν_f)` is the only
2617 // part of `W` that is a function of the null refit and of nothing
2618 // random (#2672).
2619 if let Some((residual_weights, residual_unit_dimension)) = profiled_residual.as_ref()
2620 && let (Some(full_df), Some(null_df)) = (full_residual_df, null_residual_df)
2621 && full_df > 0.0
2622 && null_df > 0.0
2623 {
2624 let observations = profiled_observations as f64;
2625 reference.profiled_scale = Some(SmoothLrProfiledScale {
2626 observations,
2627 deterministic_offset: observations * (full_df / null_df).ln()
2628 + (null_df - full_df),
2629 residual_weights: residual_weights.clone(),
2630 residual_unit_dimension: *residual_unit_dimension,
2631 });
2632 }
2633 let ref_df_provenance = reference.clone();
2634
2635 let (p_uncorrected, mut p_bound) = reference.tail_probability_with_bound(statistic_lr);
2636 let mut p_conditional = reference.conditional_tail_probability(statistic_lr);
2637
2638 // Magic Bartlett correction: only when the LR statistic is finite, the
2639 // family has closed-form jets, n is in the resolvable regime, and the
2640 // factor is computable. Otherwise the uncorrected χ² stands.
2641 let mut bartlett_factor = 1.0;
2642 let mut bartlett_factor_conditional = None;
2643 let mut rho_variation_shift = None;
2644 let mut statistic_corrected = statistic_lr;
2645 let mut p_corrected = p_uncorrected;
2646 let mut correction = SmoothLrCorrection::None;
2647 if let (Some(eta), true, true) = (
2648 eta_null.as_ref(),
2649 statistic_lr.is_finite(),
2650 n <= LAWLEY_PAIR_MATRIX_MAX_ROWS,
2651 ) {
2652 let kappas: Option<Vec<_>> = (0..n)
2653 .map(|i| {
2654 known_scale_expected_jets_with_dispersion(
2655 &fitted_likelihood.spec,
2656 eta[i],
2657 family_disp,
2658 )
2659 .and_then(|jets| jets.kappas().ok())
2660 })
2661 .collect();
2662 if let Some(kappas) = kappas {
2663 let fixed_factor = lawley_lr_bartlett_factor(
2664 full_design_dense.view(),
2665 &kappas,
2666 Some(s_lambda.view()),
2667 coeff_range.clone(),
2668 ref_df,
2669 );
2670 if let Ok(c_cond) = fixed_factor
2671 && c_cond.is_finite()
2672 && c_cond > 0.0
2673 {
2674 let mut c_applied = c_cond;
2675 correction = SmoothLrCorrection::LawleyLrFixedLambda;
2676 if let Some(cov) = rho_covariance
2677 && let Ok(total_shift) = lawley_lr_mean_shift_with_rho_variation(
2678 full_design_dense.view(),
2679 &kappas,
2680 s_lambda.view(),
2681 coeff_range.clone(),
2682 &rho_penalty_components,
2683 cov.view(),
2684 )
2685 {
2686 let mean_w = ref_df + total_shift;
2687 if let Some(c_est) =
2688 gam_terms::inference::higher_order::bartlett_factor_from_mean(
2689 mean_w, ref_df,
2690 )
2691 && c_est.is_finite()
2692 && c_est > 0.0
2693 {
2694 let conditional_shift = (c_cond - 1.0) * ref_df;
2695 c_applied = c_est;
2696 bartlett_factor_conditional = Some(c_cond);
2697 rho_variation_shift = Some(total_shift - conditional_shift);
2698 correction = SmoothLrCorrection::LawleyLrEstimatedLambda;
2699 }
2700 }
2701 bartlett_factor = c_applied;
2702 statistic_corrected = statistic_lr / c_applied;
2703 // `W* = W/c` and "rescale every spectral weight by `c`" are
2704 // the same operation on this reference — the law is exactly
2705 // scale-equivariant — so the correction composes with the
2706 // scaled reference without a second convention.
2707 let (corrected, corrected_bound) =
2708 reference.tail_probability_with_bound(statistic_corrected);
2709 p_corrected = corrected;
2710 p_conditional = reference.conditional_tail_probability(statistic_corrected);
2711 p_bound = p_bound.max(corrected_bound);
2712 }
2713 }
2714 }
2715
2716 // Materiality (#939 deliverable 4): only when a correction was actually
2717 // applied, flagged when it moves the result by more than the 10%
2718 // threshold — by the Bartlett factor's distance from one OR the relative
2719 // p-value shift, whichever is larger (a factor near one can still flip a
2720 // p-value sitting on the α boundary, and vice versa).
2721 let material = match correction {
2722 SmoothLrCorrection::LawleyLrEstimatedLambda
2723 | SmoothLrCorrection::LawleyLrFixedLambda => {
2724 let factor_move = (bartlett_factor - 1.0).abs();
2725 let p_denom = p_uncorrected.max(p_corrected).max(f64::MIN_POSITIVE);
2726 let p_move = if p_uncorrected.is_finite() && p_corrected.is_finite() {
2727 (p_corrected - p_uncorrected).abs() / p_denom
2728 } else {
2729 0.0
2730 };
2731 factor_move > SMOOTH_LR_MATERIAL_THRESHOLD || p_move > SMOOTH_LR_MATERIAL_THRESHOLD
2732 }
2733 SmoothLrCorrection::None => false,
2734 };
2735
2736 out.push(SmoothTermLrInference {
2737 name: design_term.name.clone(),
2738 term_idx,
2739 statistic_lr,
2740 ref_df,
2741 ref_df_provenance,
2742 bartlett_factor,
2743 bartlett_factor_conditional,
2744 rho_variation_shift,
2745 statistic_corrected,
2746 p_value_uncorrected: p_uncorrected,
2747 p_value_corrected: p_corrected,
2748 material,
2749 correction,
2750 p_value_conditional: p_conditional,
2751 p_value_bound: p_bound,
2752 });
2753 }
2754 Ok(out)
2755}
2756
2757/// The residual degrees of freedom the fit's profiled `σ̂` ACTUALLY divided by,
2758/// read back as `D/σ̂²` from two published fields.
2759///
2760/// Not recomputed as `n − edf_total`. That is what the optimizer uses on one of
2761/// its two branches — the other, taken when inference is off, divides by `n` —
2762/// and a reference that assumed the branch would be silently wrong on the other
2763/// one. `D` is the weighted residual sum of squares (the Gaussian deviance) and
2764/// `σ̂² = D/ν` by construction, so `ν = D/σ̂²` inverts the fit's own convention
2765/// whatever it was.
2766///
2767/// The inversion is exact rather than approximate, and the reason is a
2768/// type-level one: `ProfiledGaussian` implies the IDENTITY link, because
2769/// `gam_spec`'s `legal_cell_kind` admits no other Gaussian cell — a
2770/// `(Gaussian, log)` model is not constructible rather than merely unusual. The
2771/// optimizer's weighted-RSS channel is wired for the identity link, so on every
2772/// fit that reaches this function `D` IS `Σ w_i(y_i − μ_i)²` and `σ̂² = D/ν`
2773/// holds by construction.
2774///
2775/// `None` when the two fields cannot produce a residual degrees of freedom that
2776/// is finite, positive, and no larger than the sample size — a statement that
2777/// this fit's `σ̂` is not the profiled residual one the identity above assumes,
2778/// which is exactly when the estimated-scale channel must stay switched off.
2779fn profiled_residual_degrees_of_freedom(fit: &UnifiedFitResult, n: usize) -> Option<f64> {
2780 let variance = fit.standard_deviation * fit.standard_deviation;
2781 if !(variance.is_finite() && variance > 0.0 && fit.deviance.is_finite() && fit.deviance > 0.0) {
2782 return None;
2783 }
2784 let residual_df = fit.deviance / variance;
2785 // The `n` ceiling carries a tolerance because `ν = D/σ̂²` is a ratio of two
2786 // separately-rounded published numbers, and the branch that divides by `n`
2787 // exactly lands on the ceiling.
2788 let ceiling = n as f64 * (1.0 + 8.0 * f64::EPSILON);
2789 (residual_df.is_finite() && residual_df > 0.0 && residual_df <= ceiling).then_some(residual_df)
2790}
2791
2792/// The residual quadratic form's spectrum, `v_i = p_i²` over the WHOLE model's
2793/// penalty shares — or `None` when the family does not profile a Gaussian scale
2794/// out of a residual sum of squares.
2795///
2796/// This is `lr_tested_block` over the full coefficient range rather than over
2797/// a term's: the same self-adjoint decomposition and the same `[0, 1]` shares,
2798/// deliberately not a second route to the same object. See
2799/// [`SmoothLrProfiledScale`] for why `V ~ Σ_i p_i²·χ²_1 + χ²_{n−p}`.
2800///
2801/// `Some(None)` — the family profiles a scale but the shares are unreachable —
2802/// is a rung, not a refusal, on the same ladder the numerator's reference
2803/// already has: the fit that cannot publish `H⁻¹` is the fit whose numerator is
2804/// also a two-moment summary. What the caller does with it is fold the whole
2805/// residual law into `V ~ χ²_{ν}` at the fit's own `ν`, which is mgcv's `F`
2806/// reference exactly. Two things are then inexact rather than exact, both
2807/// bounded by `Σ_i f_i(1 − f_i)` — the count of PARTIALLY shrunk directions,
2808/// zero at both ends of the shrinkage range: `E[V] = tr((I−A)²) = ν −
2809/// Σ f(1−f)` rather than `ν`, and `Var(V)/2 = Σv² ≤ Σv` so `χ²_ν`
2810/// over-disperses. That is strictly better than the known-scale reference,
2811/// which is what the alternative — dropping the channel — would silently
2812/// restore.
2813fn profiled_scale_residual_shares(
2814 likelihood: &gam_spec::GlmLikelihoodSpec,
2815 hessian_inverse: Option<&Array2<f64>>,
2816 penalty: &Array2<f64>,
2817 p_total: usize,
2818) -> Result<Option<Option<Vec<f64>>>, EstimationError> {
2819 let resolved = likelihood
2820 .resolved_scale()
2821 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2822 if !matches!(resolved, gam_spec::ResolvedLikelihoodScale::ProfiledGaussian) {
2823 return Ok(None);
2824 }
2825 Ok(Some(
2826 lr_tested_block(hessian_inverse, Some(penalty), &(0..p_total))
2827 .map(|block| block.shares.iter().map(|share| share * share).collect()),
2828 ))
2829}
2830
2831fn resolved_likelihood_for_fit(
2832 fit: &UnifiedFitResult,
2833) -> Result<gam_spec::GlmLikelihoodSpec, EstimationError> {
2834 let spec = fit.likelihood_family.as_ref().ok_or_else(|| {
2835 EstimationError::InvalidInput(
2836 "smooth-term LR inference requires an engine-level GLM likelihood".to_string(),
2837 )
2838 })?;
2839 gam_spec::GlmLikelihoodSpec::try_new(spec.clone(), fit.likelihood_scale.clone())
2840 .map_err(|error| EstimationError::InvalidInput(error.to_string()))
2841}
2842
2843/// The response dispersion `phi` Lawley needs for cumulant scaling. This is
2844/// deliberately distinct from the coefficient-covariance multiplier used by
2845/// the WPS trace below: Gamma Lawley uses `1 / shape`, while its PIRLS Hessian
2846/// already carries `shape` and therefore has covariance multiplier one.
2847fn lawley_dispersion_for_family(
2848 likelihood: &gam_spec::GlmLikelihoodSpec,
2849 fit: &UnifiedFitResult,
2850) -> Result<f64, EstimationError> {
2851 let profiled_standard_deviation = matches!(
2852 likelihood
2853 .resolved_scale()
2854 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?,
2855 gam_spec::ResolvedLikelihoodScale::ProfiledGaussian
2856 )
2857 .then_some(fit.standard_deviation);
2858 gam_solve::estimate::dispersion_from_likelihood(likelihood, profiled_standard_deviation)
2859 .map(|dispersion| dispersion.phi())
2860}
2861
2862/// The reference distribution for the whole-term LR statistic: its own null
2863/// spectrum `w` when that is recoverable, and the two-moment summary of it when
2864/// only the moments are.
2865///
2866/// The derivation, and why the spectrum rather than two of its moments, is on
2867/// [`SmoothLrReferenceDf`]. What is worth stating at the code is the ladder, and
2868/// that each rung is a strictly weaker instrument on the SAME quantity rather
2869/// than a different claim:
2870///
2871/// 1. **The spectrum** (`lr_tested_block`) — needs `[H⁻¹]_jj`
2872/// and the term's λ-weighted penalty block. Exact.
2873/// 2. **Its first two moments** ([`lr_null_spectral_moments`]) — needs only the
2874/// coefficient-influence block, because with `A = 2F − F²`
2875///
2876/// ```text
2877/// Σ w = tr A = 2·tr F − tr F²
2878/// Σ w² = tr A² = 4·tr F² − 4·tr F³ + tr F⁴
2879/// ```
2880///
2881/// are traces of powers of one `q × q` block. Reading the weights THEMSELVES
2882/// off `F_jj` is what rung 1 avoids: `F_jj = H̃⁻¹Ĩ_jj` is not symmetric, so it
2883/// would need a general eigensolver, while rung 1 reaches the same spectrum
2884/// through a self-adjoint one.
2885/// 3. **A scalar EDF** — `χ²_{max(edf, null_dim, 1)}`, the unit-weight shape.
2886///
2887/// The lane taken is tagged in the returned provenance, so a consumer can tell
2888/// an exact reference from a summary of one instead of inferring it from the
2889/// numbers.
2890///
2891/// `term_penalties` are the term's λ-FREE penalty components on the tested
2892/// block and `term_log_lambda` their fitted `ρ̂_i`, carried separately rather
2893/// than pre-multiplied. That is not bookkeeping: the selection replay's
2894/// criterion needs `log|Σ_i λ_i S_i|₊`, and an assembled sum has already lost
2895/// it whenever the `λ_i` separate (#2644 — see [`SelectionGeometry`]).
2896/// `log_scale_windows` carries ONE window per component, because the outer
2897/// search moved each `ρ_i` independently inside its own box.
2898fn lr_null_reference(
2899 influence: Option<&Array2<f64>>,
2900 hessian_inverse: Option<&Array2<f64>>,
2901 penalty: Option<&Array2<f64>>,
2902 coeff_range: &Range<usize>,
2903 edf: f64,
2904 null_dim: usize,
2905 statistic_resolution: f64,
2906 log_scale_windows: &[(f64, f64)],
2907 term_penalties: &[Array2<f64>],
2908 term_log_lambda: &[f64],
2909) -> SmoothLrReferenceDf {
2910 let from_moments = |mean: f64, second_moment: f64, source| SmoothLrReferenceDf {
2911 weights: Vec::new(),
2912 mean,
2913 second_moment,
2914 chi_square_df: mean * mean / second_moment,
2915 scale: second_moment / mean,
2916 moment_residual: None,
2917 edf,
2918 null_dim,
2919 source,
2920 // The degraded lanes do not have the spectrum, so they cannot have the
2921 // geometry the replay is built from either.
2922 selection: SmoothLrSelection::Declined(SmoothLrSelectionDecline::GeometryRefused),
2923 statistic_resolution,
2924 // Completed by the caller once the null refit has produced the second
2925 // residual degrees of freedom `B` needs (#2672).
2926 profiled_scale: None,
2927 };
2928 let unit_weight = || {
2929 let df = edf.max(null_dim as f64).max(1.0);
2930 from_moments(df, df, SmoothLrReferenceSource::UnitWeightFallback)
2931 };
2932 let influence_moments = lr_null_spectral_moments(influence, coeff_range);
2933
2934 // Rung 1 — the spectrum itself.
2935 if let Some(block) = lr_tested_block(hessian_inverse, penalty, coeff_range) {
2936 let mut weights: Vec<f64> = block.shares.iter().map(|&p| 1.0 - p * p).collect();
2937 weights.sort_by(|a, b| b.partial_cmp(a).expect("finite weights"));
2938 let mean: f64 = weights.iter().sum();
2939 let second_moment: f64 = weights.iter().map(|w| w * w).sum();
2940 if mean.is_finite() && mean > 0.0 && second_moment.is_finite() && second_moment > 0.0 {
2941 // The identity check, measured rather than assumed. Denominated
2942 // relatively and floored at one so a term shrunk to nothing does not
2943 // report a huge residual for a difference of `1e-16`.
2944 let moment_residual = influence_moments.map(|[trace_mean, trace_second]| {
2945 let first = (mean - trace_mean).abs() / mean.abs().max(1.0);
2946 let second = (second_moment - trace_second).abs() / second_moment.abs().max(1.0);
2947 first.max(second)
2948 });
2949 return SmoothLrReferenceDf {
2950 weights,
2951 mean,
2952 second_moment,
2953 chi_square_df: mean * mean / second_moment,
2954 scale: second_moment / mean,
2955 moment_residual,
2956 edf,
2957 null_dim,
2958 source: SmoothLrReferenceSource::NullSpectrum,
2959 // One entry point for both lanes. `generate` whitens the term's
2960 // λ-free components by the Schur-complemented information,
2961 // factors them into roots, and dispatches on how many scales the
2962 // term actually selects. The generalized spectrum it reports is
2963 // read off that geometry rather than reconstructed as
2964 // `p_k/(1 − p_k)` from the penalty shares — a share is a number
2965 // in `[0, 1]`, so a structural zero and a `1e-17` of roundoff are
2966 // one machine epsilon apart there, and the criterion's
2967 // log-determinant is the one place that difference is worth
2968 // `log(1 + 1e17)`.
2969 selection: SmoothLrSelectionReplay::generate(
2970 &block.whitener,
2971 term_penalties,
2972 term_log_lambda,
2973 log_scale_windows,
2974 ),
2975 statistic_resolution,
2976 profiled_scale: None,
2977 };
2978 }
2979 }
2980
2981 // Rung 2 — two moments of it, off the influence block.
2982 let Some([mean, second_moment]) = influence_moments else {
2983 return unit_weight();
2984 };
2985 if !(mean.is_finite() && mean > 0.0 && second_moment.is_finite() && second_moment > 0.0) {
2986 return unit_weight();
2987 }
2988 from_moments(
2989 mean,
2990 second_moment,
2991 SmoothLrReferenceSource::SpectralMomentMatch,
2992 )
2993}
2994
2995/// The term's PENALTY SHARES `p = eig([H⁻¹]_jj · S_jj) ∈ [0, 1]`, sorted
2996/// ascending — the one object every reference on this path is a function of.
2997///
2998/// The null weights are `w_j = 1 − p_j²` (see below), and the generalized
2999/// eigenvalues that drive the selection replay are `ν_j = p_j/(1 − p_j)`, so a
3000/// single self-adjoint decomposition yields both.
3001///
3002/// # Why this is the same spectrum as `eig(2·F_jj − F_jj²)`
3003///
3004/// The penalty is block-diagonal by term, so `S_kj = 0` for `k ≠ j` and the
3005/// tested block of the GLOBAL shrinkage map factors exactly:
3006///
3007/// ```text
3008/// (I − F)_jj = [H⁻¹S]_jj = Σ_k [H⁻¹]_jk S_kj = [H⁻¹]_jj S_jj =: P.
3009/// ```
3010///
3011/// Therefore `F_jj = I − P` and `2F_jj − F_jj² = I − (I − F_jj)² = I − P²`, so
3012/// `w = 1 − eig(P)²` with no approximation anywhere — the same object the trace
3013/// identities in [`lr_null_spectral_moments`] summarise, arrived at without
3014/// forming a non-symmetric matrix.
3015///
3016/// # Why it is reachable with a self-adjoint eigensolver
3017///
3018/// `P = B S` with `B = [H⁻¹]_jj` symmetric PSD (a principal submatrix of the
3019/// inverse of a PD Hessian) and `S = S_jj` symmetric PSD. A product of two
3020/// symmetric PSD matrices is not symmetric, but it is similar to one:
3021///
3022/// ```text
3023/// B^{-1/2} (B S) B^{1/2} = B^{1/2} S B^{1/2},
3024/// ```
3025///
3026/// which is symmetric PSD and is what this computes — via `B = UΛUᵀ` and
3027/// `B^{1/2} = UΛ^{1/2}Uᵀ` rather than a Cholesky, so a `B` that is singular in
3028/// some direction (an exactly-unpenalized fit, a rank-deficient block) is a
3029/// zero eigenvalue rather than a factorization failure. The eigenvalues are real
3030/// and lie in `[0, 1]` because `F_jj = (Ĩ_jj + S_jj)⁻¹Ĩ_jj` has eigenvalues
3031/// `c/(c + s)`; they are clamped to that interval against roundoff, and the
3032/// clamp is the ONLY place a value is altered.
3033///
3034/// Returns `None` when either matrix is absent, the block does not fit inside
3035/// them, or the self-adjoint decomposition refuses — the caller then drops to
3036/// the two-moment rung rather than scoring against a spectrum it could not
3037/// compute.
3038/// The tested block's penalty shares AND the whitener the replay needs, from
3039/// ONE self-adjoint decomposition and with no matrix cancellation anywhere.
3040pub(crate) struct LrTestedBlock {
3041 /// `p = eig(B^{1/2} S_jj B^{1/2}) ∈ [0, 1]`, ascending.
3042 shares: Vec<f64>,
3043 /// `W` (`q × dimension`) with `W Wᵀ = Ĩ_jj⁻¹` on the directions the
3044 /// Schur-complemented information can see, i.e. those with `1 − p > 0`.
3045 ///
3046 /// # Why this is not `Ĩ^{-1/2}` computed from `Ĩ`
3047 ///
3048 /// `Ĩ_jj = ([H⁻¹]_jj)⁻¹ − S_jj` is the object the derivation is stated
3049 /// against, and forming it that way is a CANCELLATION of two matrices whose
3050 /// ratio is `1/(1 − p)`: at `p = 1 − 1e-12` — an ordinary heavily-shrunk
3051 /// direction of a null-true smooth — the difference is roundoff amplified
3052 /// twelve orders, and the explicit inverse that produces the first term has
3053 /// already amplified it once more. Measured on this issue's own `n = 60`
3054 /// fixture, that route handed the whitening a spectrum whose largest
3055 /// eigenvalue was spurious, and the relative floor then discarded EVERY
3056 /// direction the data could see: `q` went `11 → 9` and the replayed law's
3057 /// mean went `0.96 → 0.0000` while the reference's stayed at `0.96`. The
3058 /// control variate was then a difference of two different laws.
3059 ///
3060 /// The same object is available with no cancellation at all. With
3061 /// `A = B^{1/2} S B^{1/2} = QΛQᵀ` and `Λ = diag(p)`,
3062 ///
3063 /// ```text
3064 /// B^{1/2} Ĩ B^{1/2} = B^{1/2}(B⁻¹ − S)B^{1/2} = I − A = Q(I − Λ)Qᵀ,
3065 /// ```
3066 ///
3067 /// so `Ĩ⁻¹ = B^{1/2}Q(I − Λ)⁻¹QᵀB^{1/2}` and `W = B^{1/2}Q(I − Λ)^{-1/2}`.
3068 /// The only subtraction left is the SCALAR `1 − p`, which loses digits
3069 /// exactly when the direction is genuinely unidentified — and that is then a
3070 /// statement about the fit rather than an artifact. As a check on the whole
3071 /// construction, the whitened total penalty at `λ̂` comes out
3072 /// `(I − Λ)^{-1/2}Λ(I − Λ)^{-1/2} = diag(p/(1 − p))`, i.e. exactly the
3073 /// generalized spectrum, diagonal, for free.
3074 whitener: Array2<f64>,
3075}
3076
3077/// Directions the Schur-complemented information cannot separate from the
3078/// penalty at all.
3079///
3080/// `1 − p` is the share of a direction the DATA holds, on a scale where one is
3081/// unpenalized and zero is fully absorbed by the penalty. It is an eigenvalue of
3082/// `I − A` with `‖A‖ ≤ 1`, so its own noise floor is absolute rather than
3083/// relative: `p·ε` for the decomposition, with the same safety factor
3084/// `positive_eigenvalue_threshold` uses.
3085const SMOOTH_LR_IDENTIFIED_SHARE_FLOOR_FACTOR: f64 = 100.0;
3086
3087fn lr_tested_block(
3088 hessian_inverse: Option<&Array2<f64>>,
3089 penalty: Option<&Array2<f64>>,
3090 coeff_range: &Range<usize>,
3091) -> Option<LrTestedBlock> {
3092 let (h_inv, s_lambda) = (hessian_inverse?, penalty?);
3093 let (start, end) = (coeff_range.start, coeff_range.end);
3094 if start >= end
3095 || end > h_inv.nrows()
3096 || end > h_inv.ncols()
3097 || end > s_lambda.nrows()
3098 || end > s_lambda.ncols()
3099 {
3100 return None;
3101 }
3102 // Both blocks are symmetric as mathematical objects; the halves of an
3103 // assembled Gram/inverse differ only by summation order. Symmetrize
3104 // explicitly so the self-adjoint entry point receives the matrix it is being
3105 // asked about rather than one triangle's rounding of it.
3106 let b = symmetrized(h_inv.slice(s![start..end, start..end]).to_owned());
3107 let s = symmetrized(s_lambda.slice(s![start..end, start..end]).to_owned());
3108 if b.iter().chain(s.iter()).any(|value| !value.is_finite()) {
3109 return None;
3110 }
3111 let dimension = end - start;
3112
3113 let (b_eigenvalues, b_vectors) =
3114 gam_linalg::faer_ndarray::strict_symmetric_eigh(&b, faer::Side::Lower).ok()?;
3115 // `B^{1/2} = U Λ^{1/2} Uᵀ`. A tiny negative eigenvalue is roundoff on a PSD
3116 // matrix, so its square root is zero rather than an error.
3117 let mut root_scaled = b_vectors.clone();
3118 for (mut column, &eigenvalue) in root_scaled.columns_mut().into_iter().zip(b_eigenvalues.iter())
3119 {
3120 let root = eigenvalue.max(0.0).sqrt();
3121 column.mapv_inplace(|value| value * root);
3122 }
3123 let b_root = root_scaled.dot(&b_vectors.t());
3124 let similar = symmetrized(b_root.dot(&s).dot(&b_root));
3125 let (shrinkage, shrinkage_vectors) =
3126 gam_linalg::faer_ndarray::strict_symmetric_eigh(&similar, faer::Side::Lower).ok()?;
3127
3128 let shares: Vec<f64> = shrinkage.iter().map(|&p| p.clamp(0.0, 1.0)).collect();
3129 if shares.iter().any(|p| !p.is_finite()) {
3130 return None;
3131 }
3132 // `W = B^{1/2} Q (I − Λ)^{-1/2}` over the identified directions. `A`'s
3133 // spectrum lives in `[0, 1]`, so the floor on `1 − p` is absolute.
3134 let floor = SMOOTH_LR_IDENTIFIED_SHARE_FLOOR_FACTOR * (dimension as f64) * f64::EPSILON;
3135 let kept: Vec<usize> = (0..shares.len())
3136 .filter(|&index| 1.0 - shares[index] > floor)
3137 .collect();
3138 let mut whitener = Array2::<f64>::zeros((dimension, kept.len()));
3139 for (column, &index) in kept.iter().enumerate() {
3140 let scale = (1.0 - shares[index]).sqrt();
3141 for row in 0..dimension {
3142 whitener[[row, column]] = shrinkage_vectors[[row, index]] / scale;
3143 }
3144 }
3145 let whitener = b_root.dot(&whitener);
3146 if whitener.iter().any(|value| !value.is_finite()) {
3147 return None;
3148 }
3149
3150 let mut shares = shares;
3151 shares.sort_by(|a, b| a.partial_cmp(b).expect("finite shrinkage"));
3152 Some(LrTestedBlock { shares, whitener })
3153}
3154
3155/// `[tr A, tr A²]` for `A = 2·F_jj − F_jj²` on the tested coefficient block.
3156///
3157/// Returns `None` when the influence matrix is absent, the block is outside it,
3158/// or either trace is non-finite — the caller then falls back to the unit-weight
3159/// shape rather than scoring against a spectrum it could not compute.
3160fn lr_null_spectral_moments(
3161 influence: Option<&Array2<f64>>,
3162 coeff_range: &Range<usize>,
3163) -> Option<[f64; 2]> {
3164 let f = influence?;
3165 let (start, end) = (coeff_range.start, coeff_range.end);
3166 if start >= end || end > f.nrows() || end > f.ncols() {
3167 return None;
3168 }
3169 let block = f.slice(s![start..end, start..end]).to_owned();
3170 let squared = block.dot(&block);
3171 let cubed = squared.dot(&block);
3172 let quartic = squared.dot(&squared);
3173 let trace = |m: &Array2<f64>| (0..m.nrows()).map(|i| m[[i, i]]).sum::<f64>();
3174 let (t1, t2, t3, t4) = (
3175 trace(&block),
3176 trace(&squared),
3177 trace(&cubed),
3178 trace(&quartic),
3179 );
3180 let mean = 2.0 * t1 - t2;
3181 let second_moment = 4.0 * t2 - 4.0 * t3 + t4;
3182 (mean.is_finite() && second_moment.is_finite()).then_some([mean, second_moment])
3183}
3184
3185#[cfg(test)]
3186mod lr_null_reference_tests {
3187 use super::{
3188 SmoothLrReferenceSource, lr_null_reference, lr_null_spectral_moments,
3189 lr_tested_block,
3190 };
3191 use ndarray::Array2;
3192
3193 /// No selection window: these unit tests are about the CONDITIONAL law, so
3194 /// they hold `λ` fixed and the replay is inert. The replay's own behaviour
3195 /// is pinned separately.
3196 const WINDOW: &[(f64, f64)] = &[];
3197
3198 /// `M⁻¹` for a symmetric PD `M`, through the same self-adjoint entry point
3199 /// the production path uses. The tests need an inverse only to BUILD the two
3200 /// inputs (`H⁻¹` and `F = H⁻¹(H − S)`) from one `H`; nothing under test reads
3201 /// it.
3202 fn symmetric_inverse(matrix: &Array2<f64>) -> Array2<f64> {
3203 let (values, vectors) =
3204 gam_linalg::faer_ndarray::strict_symmetric_eigh(matrix, faer::Side::Lower)
3205 .expect("symmetric PD inverse");
3206 let mut scaled = vectors.clone();
3207 for (mut column, &value) in scaled.columns_mut().into_iter().zip(values.iter()) {
3208 column.mapv_inplace(|entry| entry / value);
3209 }
3210 scaled.dot(&vectors.t())
3211 }
3212
3213 /// A diagonal influence block has `F_jj` eigenvalues on the diagonal, so the
3214 /// spectrum is `2f − f²` term by term and both moments are hand-computable.
3215 /// This is the identity the whole reference rests on; it is checked against
3216 /// the definition rather than against another implementation of itself.
3217 #[test]
3218 fn the_spectral_moments_are_the_weights_of_the_null_law() {
3219 let f_diag = [0.9_f64, 0.5, 0.2, 0.05];
3220 let mut influence = Array2::<f64>::zeros((6, 6));
3221 // Deliberately offset: the block is columns 2..6, and rows/columns
3222 // outside it carry values that must not leak into either trace.
3223 influence[[0, 0]] = 7.0;
3224 influence[[1, 1]] = -3.0;
3225 influence[[0, 3]] = 11.0;
3226 influence[[5, 1]] = -2.0;
3227 for (i, &f) in f_diag.iter().enumerate() {
3228 influence[[2 + i, 2 + i]] = f;
3229 }
3230 let [mean, second] =
3231 lr_null_spectral_moments(Some(&influence), &(2..6)).expect("moments available");
3232 let weights: Vec<f64> = f_diag.iter().map(|f| 2.0 * f - f * f).collect();
3233 let want_mean: f64 = weights.iter().sum();
3234 let want_second: f64 = weights.iter().map(|w| w * w).sum();
3235 assert!(
3236 (mean - want_mean).abs() < 1e-12 && (second - want_second).abs() < 1e-12,
3237 "moments ({mean}, {second}) vs weights {weights:?} -> ({want_mean}, {want_second})"
3238 );
3239 }
3240
3241 /// THE IDENTITY THE EXACT LANE RESTS ON, on a design where every block is
3242 /// coupled to every other: the spectrum read off `[H⁻¹]_jj` and `S_jj` has
3243 /// the same two moments as the spectrum read off the influence block, which
3244 /// are computed by completely different arithmetic (two self-adjoint
3245 /// decompositions versus four traces of powers of a non-symmetric matrix).
3246 ///
3247 /// `(I − F)_jj = [H⁻¹]_jj S_jj` is only true because the penalty is
3248 /// block-diagonal by term, so the fixture puts a SEPARATE penalty on the
3249 /// retained block as well: the identity must survive other terms being
3250 /// penalized (that is the difference between the Schur complement of the
3251 /// penalized retained block and of the unpenalized one), and it must fail if
3252 /// anyone ever lets a penalty couple two terms.
3253 #[test]
3254 fn the_penalty_spectrum_and_the_influence_moments_are_the_same_object() {
3255 let (retained, tested) = (3usize, 5usize);
3256 let p = retained + tested;
3257 // A dense SPD Gram with real cross-block coupling.
3258 let mut gram = Array2::<f64>::zeros((p, p));
3259 for row in 0..p {
3260 for col in 0..p {
3261 gram[[row, col]] = 1.0 / (1.0 + (row as f64 - col as f64).abs())
3262 + if row == col { 0.75 } else { 0.0 };
3263 }
3264 }
3265 for lambda in [0.0_f64, 1e-3, 1.0, 25.0, 1e4, 1e7] {
3266 // Block-diagonal penalty: a second-difference block on the tested
3267 // term and an unrelated ridge on the retained one.
3268 let mut penalty = Array2::<f64>::zeros((p, p));
3269 for row in 0..retained {
3270 penalty[[row, row]] = 0.3;
3271 }
3272 for row in 0..tested.saturating_sub(2) {
3273 for (offset_a, coefficient_a) in [(0usize, 1.0_f64), (1, -2.0), (2, 1.0)] {
3274 for (offset_b, coefficient_b) in [(0usize, 1.0_f64), (1, -2.0), (2, 1.0)] {
3275 penalty[[retained + row + offset_a, retained + row + offset_b]] +=
3276 lambda * coefficient_a * coefficient_b;
3277 }
3278 }
3279 }
3280 let hessian = &gram + &penalty;
3281 let hessian_inverse = symmetric_inverse(&hessian);
3282 let influence = hessian_inverse.dot(&gram);
3283
3284 let weights =
3285 lr_tested_block(Some(&hessian_inverse), Some(&penalty), &(retained..p))
3286 .map(|block| block.shares)
3287 .map(|shares| shares.iter().map(|&q| 1.0 - q * q).collect::<Vec<f64>>())
3288 .expect("spectrum available");
3289 let [mean, second] = lr_null_spectral_moments(Some(&influence), &(retained..p))
3290 .expect("moments available");
3291 let spectrum_mean: f64 = weights.iter().sum();
3292 let spectrum_second: f64 = weights.iter().map(|w| w * w).sum();
3293 // `1e-7` relative, not roundoff: at `λ = 1e7` the INFLUENCE route
3294 // is what loses the digits — `2·trF − trF²` differences two nearly
3295 // equal quantities while `trF → 0` — and it comes in at `2.5e-9`
3296 // relative there against `<1e-15` at every smaller `λ`. That
3297 // asymmetry is one of the reasons the penalty route is the primary
3298 // one; the bar is set where the WEAKER of the two routes lives.
3299 assert!(
3300 (spectrum_mean - mean).abs() < 1e-7 * mean.abs().max(1.0)
3301 && (spectrum_second - second).abs() < 1e-7 * second.abs().max(1.0),
3302 "lambda={lambda}: spectrum moments ({spectrum_mean}, {spectrum_second}) \
3303 disagree with influence-trace moments ({mean}, {second})"
3304 );
3305 assert!(
3306 weights.iter().all(|w| (0.0..=1.0).contains(w)),
3307 "lambda={lambda}: weights escaped [0,1]: {weights:?}"
3308 );
3309 assert!(
3310 weights.windows(2).all(|pair| pair[0] >= pair[1]),
3311 "lambda={lambda}: weights are not sorted descending: {weights:?}"
3312 );
3313 }
3314 }
3315
3316 /// The identity that makes this a strict generalization rather than a
3317 /// replacement: an UNPENALIZED tested block has `F_jj = I`, every weight is
3318 /// one, and the reference must be the textbook `χ²_q` — exactly, not
3319 /// approximately, on the EXACT lane as well as on the moment lane.
3320 #[test]
3321 fn an_unpenalized_block_is_exactly_the_classical_chi_square() {
3322 let q = 5;
3323 let identity = Array2::<f64>::eye(q);
3324 let zero_penalty = Array2::<f64>::zeros((q, q));
3325 let reference = lr_null_reference(
3326 Some(&identity),
3327 Some(&identity),
3328 Some(&zero_penalty),
3329 &(0..q),
3330 0.0,
3331 q,
3332 0.0,
3333 WINDOW,
3334 &[],
3335 &[],
3336 );
3337 assert_eq!(reference.source, SmoothLrReferenceSource::NullSpectrum);
3338 assert_eq!(reference.weights, vec![1.0; q]);
3339 assert_eq!(reference.chi_square_df, q as f64);
3340 assert_eq!(reference.scale, 1.0);
3341 assert_eq!(reference.mean, q as f64);
3342 // The exact lane resolves equal weights through its own closed form, so
3343 // this is the classical value bit for bit rather than to a tolerance.
3344 for statistic in [0.5_f64, 3.0, 11.07, 40.0] {
3345 assert_eq!(
3346 reference.tail_probability(statistic),
3347 gam_math::probability::chi_square_sf(statistic, q as f64)
3348 );
3349 }
3350 }
3351
3352 /// Equal shrinkage is the other exact case: `p_j ≡ p` gives `w_j ≡ 1 − p²`,
3353 /// so the law is a SCALED `χ²_q` and BOTH lanes reproduce it exactly. This is
3354 /// what makes the scale a real parameter rather than a fudge, and it is the
3355 /// case in which the two lanes must not be distinguishable.
3356 #[test]
3357 fn equal_shrinkage_is_the_exact_scaled_chi_square() {
3358 let q = 6;
3359 let f = 0.4_f64;
3360 let w = 2.0 * f - f * f;
3361 let influence = Array2::<f64>::eye(q) * f;
3362 // `P = B·S = (1 − f)·I` on the block: B = I, S = (1 − f)·I.
3363 let hessian_inverse = Array2::<f64>::eye(q);
3364 let penalty = Array2::<f64>::eye(q) * (1.0 - f);
3365 for reference in [
3366 lr_null_reference(
3367 Some(&influence),
3368 Some(&hessian_inverse),
3369 Some(&penalty),
3370 &(0..q),
3371 f * q as f64,
3372 0,
3373 0.0,
3374 WINDOW,
3375 &[],
3376 &[],
3377 ),
3378 lr_null_reference(Some(&influence), None, None, &(0..q), f * q as f64, 0, 0.0, WINDOW, &[], &[]),
3379 ] {
3380 assert!((reference.chi_square_df - q as f64).abs() < 1e-12);
3381 assert!((reference.scale - w).abs() < 1e-12);
3382 for statistic in [0.2_f64, 2.0, 9.0] {
3383 let want = gam_math::probability::chi_square_sf(statistic / w, q as f64);
3384 assert!(
3385 (reference.tail_probability(statistic) - want).abs() < 1e-12,
3386 "{:?}: {} vs {want}",
3387 reference.source,
3388 reference.tail_probability(statistic)
3389 );
3390 }
3391 }
3392 }
3393
3394 /// The reason the exact lane exists, pinned as a measurement rather than a
3395 /// preference: on a spread spectrum the two-moment summary is
3396 /// ANTI-conservative, one-signed, and worse the deeper the tail — so a fit
3397 /// that lands on the moment lane is not merely less precise, it rejects too
3398 /// often, and the gap grows exactly where a p-value is being used to claim
3399 /// something.
3400 ///
3401 /// The two references here are built from the SAME spectrum, so nothing but
3402 /// the shape of the reference differs between the arms.
3403 #[test]
3404 fn the_two_moment_summary_is_anti_conservative_in_the_tail() {
3405 // A shrunk smooth: one unpenalized direction and a geometric tail.
3406 let shrinkage = [0.0_f64, 0.55, 0.85, 0.96, 0.995];
3407 let q = shrinkage.len();
3408 let hessian_inverse = Array2::<f64>::eye(q);
3409 let mut penalty = Array2::<f64>::zeros((q, q));
3410 let mut influence = Array2::<f64>::zeros((q, q));
3411 for (index, &p) in shrinkage.iter().enumerate() {
3412 penalty[[index, index]] = p;
3413 influence[[index, index]] = 1.0 - p;
3414 }
3415 let exact = lr_null_reference(
3416 Some(&influence),
3417 Some(&hessian_inverse),
3418 Some(&penalty),
3419 &(0..q),
3420 0.0,
3421 1,
3422 0.0,
3423 WINDOW,
3424 &[],
3425 &[],
3426 );
3427 let summary = lr_null_reference(Some(&influence), None, None, &(0..q), 0.0, 1, 0.0, WINDOW, &[], &[]);
3428 assert_eq!(exact.source, SmoothLrReferenceSource::NullSpectrum);
3429 assert_eq!(summary.source, SmoothLrReferenceSource::SpectralMomentMatch);
3430 // Same spectrum, so the two moments agree to roundoff; only the shape
3431 // read off them differs.
3432 assert!((exact.mean - summary.mean).abs() < 1e-12);
3433 assert!((exact.second_moment - summary.second_moment).abs() < 1e-12);
3434
3435 let mut previous_ratio = 0.99_f64;
3436 for &alpha in &[5e-2_f64, 1e-2, 1e-3, 1e-4] {
3437 // The statistic at which the SUMMARY reports exactly `alpha`, found
3438 // by bisecting its own (monotone) tail rather than by a quantile
3439 // routine, so the two arms are compared through one interface.
3440 let (mut low, mut high) = (0.0_f64, 1.0_f64);
3441 while summary.tail_probability(high) > alpha {
3442 high *= 2.0;
3443 assert!(high < 1e6, "alpha={alpha}: the summary tail never fell below it");
3444 }
3445 for _ in 0..200 {
3446 let middle = 0.5 * (low + high);
3447 if summary.tail_probability(middle) > alpha {
3448 low = middle;
3449 } else {
3450 high = middle;
3451 }
3452 }
3453 let statistic = 0.5 * (low + high);
3454 let exact_tail = exact.tail_probability(statistic);
3455 let ratio = exact_tail / alpha;
3456 // At `α = 0.05` the two are within a percent of each other — the
3457 // surrogate's error is a TAIL error, and this is where it is
3458 // smallest. The claim is the SHAPE of the error, so the bar here is
3459 // that it has not gone the other way, and the growth assertion
3460 // below is what carries it.
3461 assert!(
3462 ratio > 0.99,
3463 "alpha={alpha}: the summary reports a materially LARGER tail than the \
3464 law (exact {exact_tail} at its own alpha); the error is supposed to be \
3465 one-signed the other way"
3466 );
3467 assert!(
3468 ratio >= previous_ratio - 1e-9,
3469 "alpha={alpha}: the summary's error must not shrink as the tail deepens \
3470 ({ratio} after {previous_ratio})"
3471 );
3472 previous_ratio = ratio;
3473 }
3474 assert!(
3475 previous_ratio > 1.3,
3476 "at alpha=1e-4 the summary should be materially anti-conservative on this \
3477 spectrum; measured {previous_ratio}x"
3478 );
3479 }
3480
3481 /// The floors #1766 needed against a collapsing `χ²_d` are structural here:
3482 /// as the term shrinks, `W` and the reference scale collapse TOGETHER, so
3483 /// the tail probability of a statistic proportional to the weights stays
3484 /// put instead of running to zero. Asserted across six orders of shrinkage.
3485 #[test]
3486 fn a_collapsing_term_does_not_degenerate_the_reference() {
3487 let q = 5;
3488 let mut previous: Option<f64> = None;
3489 for exponent in 0..7 {
3490 let f = 10f64.powi(-exponent);
3491 let influence = Array2::<f64>::eye(q) * f;
3492 let hessian_inverse = Array2::<f64>::eye(q);
3493 let penalty = Array2::<f64>::eye(q) * (1.0 - f);
3494 let reference = lr_null_reference(
3495 Some(&influence),
3496 Some(&hessian_inverse),
3497 Some(&penalty),
3498 &(0..q),
3499 f * q as f64,
3500 0,
3501 0.0,
3502 WINDOW,
3503 &[],
3504 &[],
3505 );
3506 // A statistic drawn at the reference's own mean.
3507 let tail = reference.tail_probability(reference.mean);
3508 assert!(
3509 tail > 0.3 && tail < 0.6,
3510 "f=1e-{exponent}: tail at the mean is {tail}, mean={}",
3511 reference.mean
3512 );
3513 if let Some(prev) = previous {
3514 assert!(
3515 (tail - prev).abs() < 1e-9,
3516 "f=1e-{exponent}: the tail at the mean moved {prev} -> {tail} under pure rescaling"
3517 );
3518 }
3519 previous = Some(tail);
3520 }
3521 }
3522
3523 /// Each rung of the ladder degrades to the next and SAYS so. A consumer that
3524 /// cannot tell an exact reference from a summary of one, or a summary from a
3525 /// scalar-EDF fallback, cannot reason about the number it was handed.
3526 #[test]
3527 fn each_missing_input_degrades_exactly_one_rung_and_visibly() {
3528 let q = 4;
3529 let influence = Array2::<f64>::eye(q) * 0.5;
3530 let hessian_inverse = Array2::<f64>::eye(q);
3531 let penalty = Array2::<f64>::eye(q) * 0.5;
3532
3533 // Everything present: the exact lane, carrying weights.
3534 let exact = lr_null_reference(
3535 Some(&influence),
3536 Some(&hessian_inverse),
3537 Some(&penalty),
3538 &(0..q),
3539 2.0,
3540 1,
3541 0.0,
3542 WINDOW,
3543 &[],
3544 &[],
3545 );
3546 assert_eq!(exact.source, SmoothLrReferenceSource::NullSpectrum);
3547 assert_eq!(exact.weights.len(), q);
3548
3549 // No `H⁻¹` (or no penalty): the moments off `F`, and NO weights — which
3550 // is exactly the condition `tail_probability` switches on.
3551 for degraded in [
3552 lr_null_reference(Some(&influence), None, Some(&penalty), &(0..q), 2.0, 1, 0.0, WINDOW, &[], &[]),
3553 lr_null_reference(
3554 Some(&influence),
3555 Some(&hessian_inverse),
3556 None,
3557 &(0..q),
3558 2.0,
3559 1,
3560 0.0,
3561 WINDOW,
3562 &[],
3563 &[],
3564 ),
3565 ] {
3566 assert_eq!(degraded.source, SmoothLrReferenceSource::SpectralMomentMatch);
3567 assert!(degraded.weights.is_empty());
3568 assert!((degraded.mean - exact.mean).abs() < 1e-12);
3569 }
3570
3571 // Nothing at all: the unit-weight shape with its `max(edf, null_dim, 1)`.
3572 let fallback = lr_null_reference(None, None, None, &(0..q), 2.5, 1, 0.0, WINDOW, &[], &[]);
3573 assert_eq!(fallback.source, SmoothLrReferenceSource::UnitWeightFallback);
3574 assert!(fallback.weights.is_empty());
3575 assert_eq!(fallback.chi_square_df, 2.5);
3576 assert_eq!(fallback.scale, 1.0);
3577 // The `max(edf, null_dim, 1)` shape is retained only on this lane.
3578 assert_eq!(
3579 lr_null_reference(None, None, None, &(0..4), 0.01, 3, 0.0, WINDOW, &[], &[]).chi_square_df,
3580 3.0
3581 );
3582 assert_eq!(
3583 lr_null_reference(None, None, None, &(0..4), 0.01, 0, 0.0, WINDOW, &[], &[]).chi_square_df,
3584 1.0
3585 );
3586 }
3587}
3588
3589/// The ESTIMATED-SCALE channel (#2672): the reference a profiled Gaussian's
3590/// `W = n·ln(1 + Q/V) + B` actually has.
3591#[cfg(test)]
3592mod profiled_scale_reference_tests {
3593 use super::{
3594 SmoothLrProfiledScale, SmoothLrReferenceDf, SmoothLrReferenceSource, SmoothLrSelection,
3595 SmoothLrSelectionDecline,
3596 };
3597
3598 /// A reference carrying an explicit spectrum, no selection replay, and the
3599 /// strictest tail accuracy the clamp allows.
3600 fn reference(weights: Vec<f64>, profiled_scale: Option<SmoothLrProfiledScale>) -> SmoothLrReferenceDf {
3601 let mean: f64 = weights.iter().sum();
3602 let second_moment: f64 = weights.iter().map(|w| w * w).sum();
3603 SmoothLrReferenceDf {
3604 weights,
3605 mean,
3606 second_moment,
3607 chi_square_df: mean * mean / second_moment,
3608 scale: second_moment / mean,
3609 moment_residual: None,
3610 edf: mean,
3611 null_dim: 0,
3612 source: SmoothLrReferenceSource::NullSpectrum,
3613 selection: SmoothLrSelection::Declined(SmoothLrSelectionDecline::GeometryRefused),
3614 statistic_resolution: 0.0,
3615 profiled_scale,
3616 }
3617 }
3618
3619 /// When the tested block's spectrum is FLAT the ratio law is a Fisher-
3620 /// Snedecor tail in closed form, so the whole channel — the `expm1`
3621 /// inversion, the residual spectrum, the multiplicity fold — is checkable
3622 /// against `fisher_snedecor_sf` with nothing shared but the arithmetic.
3623 ///
3624 /// `Q = g·χ²_q` and `V = χ²_ν`, so
3625 /// `P(W > w) = P(g·χ²_q > c·χ²_ν) = P(F_{q,ν} > c·ν/(g·q))`.
3626 #[test]
3627 fn a_flat_spectrum_makes_the_profiled_reference_an_f_tail() {
3628 for &(q, scale) in &[(1usize, 1.0_f64), (4, 1.0), (4, 0.37), (9, 2.5)] {
3629 for &nu in &[8.0_f64, 26.0, 191.0] {
3630 let observations = nu + q as f64;
3631 let subject = reference(
3632 vec![scale; q],
3633 Some(SmoothLrProfiledScale {
3634 observations,
3635 deterministic_offset: 0.0,
3636 // `V ~ χ²_ν` exactly: no partially-shrunk direction, so
3637 // the whole residual law is the unit-multiplicity term.
3638 residual_weights: Vec::new(),
3639 residual_unit_dimension: nu,
3640 }),
3641 );
3642 for &statistic in &[0.05_f64, 0.8, 3.0, 12.0, 30.0] {
3643 let (got, bound) = subject.tail_probability_with_bound(statistic);
3644 let ratio = (statistic / observations).exp_m1();
3645 let want = gam_math::probability::fisher_snedecor_sf(
3646 ratio * nu / (scale * q as f64),
3647 q as f64,
3648 nu,
3649 );
3650 assert!(
3651 (got - want).abs() <= bound + 1e-9,
3652 "q={q} g={scale} ν={nu} W={statistic}: {got} vs F-tail {want} \
3653 (certified {bound:.3e})"
3654 );
3655 }
3656 }
3657 }
3658 }
3659
3660 /// The correction only ever costs power, never buys it: dividing by an
3661 /// independent mean-one variate adds spread, so the profiled tail is above
3662 /// the known-scale one everywhere in the upper tail. This is the SIGN of the
3663 /// whole change, asserted as a property.
3664 #[test]
3665 fn the_profiled_reference_is_more_conservative_than_the_known_scale_one() {
3666 let weights = vec![0.95_f64, 0.62, 0.31, 0.14, 0.05];
3667 let known = reference(weights.clone(), None);
3668 for &nu in &[10.0_f64, 26.0, 100.0] {
3669 let profiled = reference(
3670 weights.clone(),
3671 Some(SmoothLrProfiledScale {
3672 observations: nu + 5.0,
3673 deterministic_offset: 0.0,
3674 residual_weights: vec![0.81, 0.49],
3675 residual_unit_dimension: nu,
3676 }),
3677 );
3678 for &statistic in &[3.0_f64, 6.0, 11.0, 20.0] {
3679 let bare = known.tail_probability(statistic);
3680 let scaled = profiled.tail_probability(statistic);
3681 if bare > 0.4 {
3682 continue;
3683 }
3684 assert!(
3685 scaled > bare,
3686 "ν={nu} W={statistic}: profiled tail {scaled} must exceed the \
3687 known-scale {bare}"
3688 );
3689 }
3690 }
3691 }
3692
3693 /// And it converges back onto the known-scale reference as the residual
3694 /// degrees of freedom grow, which is what makes this a refinement of the
3695 /// large-`n` behaviour rather than a change to it.
3696 #[test]
3697 fn the_profiled_reference_converges_to_the_known_scale_one() {
3698 let weights = vec![0.95_f64, 0.62, 0.31, 0.14, 0.05];
3699 let known = reference(weights.clone(), None);
3700 for &statistic in &[2.0_f64, 5.0, 10.0] {
3701 let target = known.tail_probability(statistic);
3702 let mut previous = f64::INFINITY;
3703 for &nu in &[100.0_f64, 1_000.0, 10_000.0, 100_000.0] {
3704 let profiled = reference(
3705 weights.clone(),
3706 Some(SmoothLrProfiledScale {
3707 observations: nu + 5.0,
3708 deterministic_offset: 0.0,
3709 residual_weights: Vec::new(),
3710 residual_unit_dimension: nu,
3711 }),
3712 );
3713 let gap = (profiled.tail_probability(statistic) - target).abs();
3714 assert!(gap < previous, "W={statistic} ν={nu}: {gap:.3e} vs {previous:.3e}");
3715 previous = gap;
3716 }
3717 assert!(
3718 previous < 1e-3,
3719 "W={statistic}: still {previous:.3e} from the known-scale tail at ν = 100000"
3720 );
3721 }
3722 }
3723
3724 /// The deterministic offset is part of the statistic, not a nuisance: a `W`
3725 /// at or below it corresponds to `Q/V ≤ 0`, which cannot happen, so the
3726 /// p-value is exactly one rather than something the quadrature invents.
3727 #[test]
3728 fn a_statistic_under_the_deterministic_offset_is_not_evidence() {
3729 let subject = reference(
3730 vec![1.0_f64, 0.5],
3731 Some(SmoothLrProfiledScale {
3732 observations: 30.0,
3733 deterministic_offset: -0.61,
3734 residual_weights: vec![0.25],
3735 residual_unit_dimension: 24.0,
3736 }),
3737 );
3738 for &statistic in &[-5.0_f64, -0.61, -0.7] {
3739 let (got, bound) = subject.tail_probability_with_bound(statistic);
3740 assert_eq!(got, 1.0, "W={statistic}");
3741 assert_eq!(bound, 0.0);
3742 }
3743 // And it is a strict boundary: just above the offset the tail is still
3744 // essentially one, but it is no longer the exact branch.
3745 let (just_above, _) = subject.tail_probability_with_bound(-0.6099);
3746 assert!(just_above < 1.0 && just_above > 0.999, "{just_above}");
3747 }
3748
3749 /// The offset SHIFTS the reference, in the direction its sign says. A
3750 /// negative `B` — which is what a null model with fewer effective degrees of
3751 /// freedom produces — makes the same `W` more extreme.
3752 #[test]
3753 fn the_deterministic_offset_moves_the_reference_the_way_its_sign_says() {
3754 let weights = vec![0.9_f64, 0.4, 0.1];
3755 let build = |offset: f64| {
3756 reference(
3757 weights.clone(),
3758 Some(SmoothLrProfiledScale {
3759 observations: 50.0,
3760 deterministic_offset: offset,
3761 residual_weights: vec![0.36, 0.04],
3762 residual_unit_dimension: 44.0,
3763 }),
3764 )
3765 };
3766 for &statistic in &[2.0_f64, 5.0, 9.0] {
3767 let neutral = build(0.0).tail_probability(statistic);
3768 let negative = build(-0.5).tail_probability(statistic);
3769 let positive = build(0.5).tail_probability(statistic);
3770 assert!(
3771 negative < neutral && neutral < positive,
3772 "W={statistic}: {negative} / {neutral} / {positive} are not ordered by the offset"
3773 );
3774 }
3775 }
3776
3777 /// WHAT THE CORRECTION CANNOT COST: discriminating power.
3778 ///
3779 /// `c(w) = expm1((w − B)/n)` is strictly increasing in `w`, and
3780 /// `P(Q − c·V > 0)` is strictly decreasing in `c`, so the profiled tail is
3781 /// strictly decreasing in the statistic — exactly like the known-scale one.
3782 /// Two strictly decreasing functions of the same `W` order the same data the
3783 /// same way, so at a MATCHED size the two references are the same test:
3784 /// everything the correction changes is calibration, and the power it gives
3785 /// up is precisely the over-rejection it removes and nothing else.
3786 ///
3787 /// Measured offline at `n = 40, k = 6` on a planted alternative: raw power
3788 /// `0.6675 → 0.6150` against a null size that moved `0.0642 → 0.0542`. This
3789 /// test is that argument's premise, asserted rather than assumed, across the
3790 /// whole statistic range including the exact branch at the bottom.
3791 #[test]
3792 fn the_profiled_reference_is_strictly_decreasing_in_the_statistic() {
3793 let subject = reference(
3794 vec![0.95_f64, 0.62, 0.31, 0.14, 0.05],
3795 Some(SmoothLrProfiledScale {
3796 observations: 40.0,
3797 deterministic_offset: -0.17,
3798 residual_weights: vec![0.9, 0.5, 0.2, 0.01],
3799 residual_unit_dimension: 33.0,
3800 }),
3801 );
3802 let mut previous = f64::INFINITY;
3803 let mut statistic = -2.0_f64;
3804 let (mut highest, mut lowest) = (f64::NEG_INFINITY, f64::INFINITY);
3805 while statistic < 60.0 {
3806 let tail = subject.tail_probability(statistic);
3807 assert!(
3808 (0.0..=1.0).contains(&tail),
3809 "W={statistic}: {tail} is not a probability"
3810 );
3811 assert!(
3812 tail <= previous + 1e-9,
3813 "W={statistic}: the tail rose from {previous} to {tail}"
3814 );
3815 previous = tail;
3816 highest = highest.max(tail);
3817 lowest = lowest.min(tail);
3818 statistic += 0.25;
3819 }
3820 // ANTI-VACUITY, stated as the property rather than as a step count. A
3821 // tail that is flat is monotone and orders nothing, and "it moved
3822 // strictly N times" needs an N nobody can derive — the first draft used
3823 // `> 100` against a sweep that delivers 98, which is a tuned constant
3824 // failing rather than a claim failing. What the argument actually needs
3825 // is that the reference SPANS the range a p-value is read in, so that
3826 // is what is asserted.
3827 assert!(
3828 highest > 0.99 && lowest < 0.01,
3829 "the reference spans only [{lowest}, {highest}] over the sweep — a tail that never \
3830 reaches either end cannot order the data across the range a p-value is read in"
3831 );
3832 }
3833
3834 /// The channel is available on the DEGRADED lanes too, because the two-moment
3835 /// summary is a one-term linear combination rather than a different shape.
3836 /// Without it, a fit that could not reach its own spectrum would silently
3837 /// fall back to the known-scale reference — the exact defect being removed.
3838 #[test]
3839 fn the_two_moment_lane_also_carries_the_estimated_scale() {
3840 let mut subject = reference(Vec::new(), None);
3841 subject.mean = 3.0;
3842 subject.second_moment = 2.4;
3843 subject.chi_square_df = 3.75;
3844 subject.scale = 0.8;
3845 let bare = subject.tail_probability(6.0);
3846 subject.profiled_scale = Some(SmoothLrProfiledScale {
3847 observations: 40.0,
3848 deterministic_offset: 0.0,
3849 residual_weights: Vec::new(),
3850 residual_unit_dimension: 34.0,
3851 });
3852 let scaled = subject.tail_probability(6.0);
3853 let want = gam_math::probability::fisher_snedecor_sf(
3854 (6.0_f64 / 40.0).exp_m1() * 34.0 / (0.8 * 3.75),
3855 3.75,
3856 34.0,
3857 );
3858 assert!(
3859 (scaled - want).abs() < 1e-9,
3860 "the two-moment lane's ratio tail {scaled} is not the F tail {want}"
3861 );
3862 assert!(scaled > bare, "{scaled} must be above the known-scale {bare}");
3863 }
3864}
3865
3866#[cfg(test)]
3867mod selection_replay_tests {
3868 use super::{
3869 MultiscaleBudget, SMOOTH_LR_SELECTION_DRAWS, SMOOTH_LR_SELECTION_GRID_BUDGET,
3870 SMOOTH_LR_SELECTION_MAX_SCALES, SMOOTH_LR_SELECTION_REFINE_FLOOR,
3871 SMOOTH_LR_SELECTION_REFINE_MAX_EVALUATIONS, SelectionFactor, SelectionGeometry,
3872 SmoothLrSelection, SmoothLrSelectionDecline, SmoothLrSelectionReplay,
3873 };
3874 use ndarray::Array2;
3875
3876 /// A shrunk-smooth generalized spectrum: one direction the data can still
3877 /// see and a geometric tail the penalty has taken.
3878 fn spectrum() -> Vec<f64> {
3879 vec![0.3_f64, 1.0, 4.0, 20.0, 120.0, 900.0]
3880 }
3881
3882 /// The geometry a bare generalized spectrum corresponds to: unit
3883 /// information, one penalty whose whitened form is `diag(ν)`, fitted at
3884 /// `λ̂ = 1`. `eig(Ĩ⁻¹S) = ν` exactly, so this is the identity map from the
3885 /// spectrum these tests are written in terms of onto the object the replay
3886 /// is built from.
3887 fn diagonal(spectrum: &[f64]) -> SelectionGeometry {
3888 let q = spectrum.len();
3889 let mut penalty = Array2::<f64>::zeros((q, q));
3890 for (index, &value) in spectrum.iter().enumerate() {
3891 penalty[[index, index]] = value;
3892 }
3893 SelectionGeometry::whiten(&Array2::eye(q), std::slice::from_ref(&penalty), &[0.0])
3894 .expect("diagonal geometry")
3895 }
3896
3897 fn replay_from(spectrum: &[f64], window: (f64, f64), draws: usize) -> SmoothLrSelectionReplay {
3898 match SmoothLrSelectionReplay::from_geometry(&diagonal(spectrum), &[window], draws, draws) {
3899 SmoothLrSelection::Replayed(replay) => replay,
3900 SmoothLrSelection::Declined(reason) => {
3901 panic!("expected a replay, declined: {}", reason.label())
3902 }
3903 }
3904 }
3905
3906 /// The replay is a p-value input, so it must not depend on a thread, a
3907 /// machine or a run (#1017). It is a counter-based stratified stream, and
3908 /// this pins that: two independent generations are bit-identical.
3909 #[test]
3910 fn the_replay_is_bit_identical_across_generations() {
3911 let first = replay_from(&spectrum(), (-8.0, 8.0), SMOOTH_LR_SELECTION_DRAWS);
3912 let second = replay_from(&spectrum(), (-8.0, 8.0), SMOOTH_LR_SELECTION_DRAWS);
3913 assert_eq!(first, second);
3914 for statistic in [0.05_f64, 0.5, 1.5, 4.0] {
3915 assert_eq!(first.tail_shift(statistic), second.tail_shift(statistic));
3916 }
3917 }
3918
3919 /// Every coordinate draws from the SAME stratum midpoints, in a different
3920 /// order — a Latin hypercube. Sorting one coordinate's draws must reproduce
3921 /// the strata exactly, or the stratification is not what the doc claims and
3922 /// the variance argument behind the draw budget does not hold.
3923 #[test]
3924 fn every_coordinate_is_a_permutation_of_the_same_strata() {
3925 let replay = replay_from(&[1.0], (-6.0, 6.0), SMOOTH_LR_SELECTION_DRAWS);
3926 let mut conditional = replay.conditional_sample.clone();
3927 conditional.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
3928 // The conditional weight at `t = 1` for `ν = 1` is `2·½ − ¼ = 0.75`.
3929 let mut expected: Vec<f64> = (0..SMOOTH_LR_SELECTION_DRAWS)
3930 .map(|bin| {
3931 let uniform = (bin as f64 + 0.5) / SMOOTH_LR_SELECTION_DRAWS as f64;
3932 let normal =
3933 gam_math::probability::standard_normal_quantile(uniform).expect("interior");
3934 0.75 * normal * normal
3935 })
3936 .collect();
3937 expected.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
3938 for (got, want) in conditional.iter().zip(expected.iter()) {
3939 assert!(
3940 (got - want).abs() <= 1e-12 * want.abs().max(1.0),
3941 "the conditional sample is not the strata: {got} vs {want}"
3942 );
3943 }
3944 }
3945
3946 /// The replay is not inert, and what it does is DISPERSE the statistic
3947 /// rather than shift it.
3948 ///
3949 /// I assumed twice over that selecting `λ` could only inflate `W`, and the
3950 /// measurement says otherwise both times: on this spectrum `E[W(λ̂)] = 1.13`
3951 /// against `E[W(1)] = 2.17`, and at `W = E[W(1)]` the replay *shrinks* the
3952 /// upper tail by `0.19`. Under a fresh null draw the criterion usually
3953 /// prefers MORE shrinkage than the fitted point, so the mean falls — while
3954 /// a draw that happens to look wiggly buys itself a smaller `λ` and a much
3955 /// larger `W`, so the spread rises. Dispersion is the invariant; the sign of
3956 /// the tail shift is a property of where the fitted `λ̂` sits relative to the
3957 /// null's typical choice, i.e. of the fit, not of the construction. It is
3958 /// asserted on real fits in the integration suite, not here.
3959 #[test]
3960 fn selection_disperses_the_statistic_and_is_not_inert() {
3961 let replay = replay_from(&spectrum(), (-10.0, 10.0), SMOOTH_LR_SELECTION_DRAWS);
3962 let draws = replay.selection_sample.len() as f64;
3963 let mean = |sample: &[f64]| sample.iter().sum::<f64>() / draws;
3964 let variance = |sample: &[f64]| {
3965 let m = mean(sample);
3966 sample.iter().map(|v| (v - m) * (v - m)).sum::<f64>() / draws
3967 };
3968 assert!(
3969 variance(&replay.selection_sample) > variance(&replay.conditional_sample),
3970 "selection did not disperse the statistic: {} vs {}",
3971 variance(&replay.selection_sample),
3972 variance(&replay.conditional_sample)
3973 );
3974 let conditional_mean = mean(&replay.conditional_sample);
3975 let mut any_move = false;
3976 for multiple in [0.25_f64, 1.0, 4.0, 16.0] {
3977 let statistic = multiple * conditional_mean;
3978 let (shift, standard_error) = replay.tail_shift(statistic);
3979 assert!(
3980 shift.is_finite() && (-1.0..=1.0).contains(&shift) && standard_error >= 0.0,
3981 "at W={statistic} the shift {shift} is not a probability difference"
3982 );
3983 if shift.abs() > 4.0 * standard_error {
3984 any_move = true;
3985 }
3986 }
3987 assert!(
3988 any_move,
3989 "the replay never moved the tail by more than its own noise — it is inert"
3990 );
3991 // Dispersion at the far end, stated where `N` draws can still resolve
3992 // it: the most extreme value a selected scale reaches has to exceed the
3993 // most extreme the fitted scale reaches, on the SAME draws.
3994 let extreme = |sample: &[f64]| sample.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3995 assert!(
3996 extreme(&replay.selection_sample) > extreme(&replay.conditional_sample),
3997 "the selected law never reached past the conditional one's most extreme \
3998 draw ({} vs {}), so it is not dispersing the upper tail at all",
3999 extreme(&replay.selection_sample),
4000 extreme(&replay.conditional_sample)
4001 );
4002 }
4003
4004 /// The published standard error has to be HONEST, because it is what the
4005 /// report's accuracy bound is built from. Quadrupling the draws must move
4006 /// the shift by no more than the two reported standard errors allow — a
4007 /// self-consistency check that fails if the error is understated, which is
4008 /// the failure mode that matters (an overstated one is merely pessimistic).
4009 #[test]
4010 fn the_published_standard_error_covers_a_four_fold_draw_increase() {
4011 let coarse = replay_from(&spectrum(), (-10.0, 10.0), 4096);
4012 let fine = replay_from(&spectrum(), (-10.0, 10.0), 16384);
4013 let conditional_mean = coarse.conditional_sample.iter().sum::<f64>()
4014 / coarse.conditional_sample.len() as f64;
4015 for multiple in [0.5_f64, 1.0, 2.0, 4.0, 8.0] {
4016 let statistic = multiple * conditional_mean;
4017 let (coarse_shift, coarse_error) = coarse.tail_shift(statistic);
4018 let (fine_shift, fine_error) = fine.tail_shift(statistic);
4019 let allowance = 3.0 * (coarse_error + fine_error) + 1e-12;
4020 assert!(
4021 (coarse_shift - fine_shift).abs() <= allowance,
4022 "at W={statistic} the shift moved {coarse_shift} -> {fine_shift} under a \
4023 four-fold draw increase, outside the {allowance} the two published \
4024 standard errors ({coarse_error:.3e}, {fine_error:.3e}) allow"
4025 );
4026 }
4027 // And the finer run's own error must actually be smaller — a standard
4028 // error that does not fall with the budget is not a standard error.
4029 let statistic = 2.0 * conditional_mean;
4030 assert!(
4031 fine.tail_shift(statistic).1 < coarse.tail_shift(statistic).1,
4032 "the reported standard error did not fall when the draws quadrupled"
4033 );
4034 }
4035
4036 /// The multi-scale replay must agree with the one-dimensional one when
4037 /// there is only one scale to select — that is the seam between the two
4038 /// paths, and a seam nobody checks is a seam that drifts.
4039 ///
4040 /// `generate_multiscale` refuses a single penalty by construction (there is
4041 /// nothing it can do that the diagonal path cannot do faster), so the
4042 /// agreement is checked by handing it the SAME penalty split in two halves:
4043 /// `S = ½S + ½S` selects two scales whose sum is the one scale, so the
4044 /// two-dimensional grid contains the one-dimensional family along its
4045 /// diagonal and the two references must land on the same law.
4046 #[test]
4047 fn a_split_penalty_reproduces_the_single_scale_law() {
4048 let q = 4;
4049 let information = Array2::<f64>::eye(q);
4050 let mut penalty = Array2::<f64>::zeros((q, q));
4051 for index in 0..q {
4052 penalty[[index, index]] = 0.5 * (index as f64 + 1.0);
4053 }
4054 let half = penalty.clone() * 0.5;
4055 let split_geometry =
4056 SelectionGeometry::whiten(&information, &[half.clone(), half], &[0.0, 0.0])
4057 .expect("split geometry");
4058 let split = SmoothLrSelectionReplay::generate_multiscale(
4059 &split_geometry,
4060 &[(-6.0, 6.0), (-6.0, 6.0)],
4061 2048,
4062 MultiscaleBudget::SHIPPED,
4063 )
4064 .expect("multiscale replay");
4065 // With `information = I` the generalized eigenvalues ARE the penalty's
4066 // diagonal, so the one-dimensional replay is directly constructible.
4067 let single = replay_from(
4068 &(0..q)
4069 .map(|index| 0.5 * (index as f64 + 1.0))
4070 .collect::<Vec<f64>>(),
4071 (-6.0, 6.0),
4072 2048,
4073 );
4074 let mean = |sample: &[f64]| sample.iter().sum::<f64>() / sample.len() as f64;
4075 let split_mean = mean(&split.conditional_sample);
4076 let single_mean = mean(&single.conditional_sample);
4077 assert!(
4078 (split_mean - single_mean).abs() <= 0.05 * single_mean.abs().max(1.0),
4079 "the two paths disagree on the CONDITIONAL law: {split_mean} vs {single_mean}"
4080 );
4081 let split_selected = mean(&split.selection_sample);
4082 let single_selected = mean(&single.selection_sample);
4083 eprintln!(
4084 "[2672 seam] conditional {split_mean:.6} vs {single_mean:.6}; \
4085 selected {split_selected:.6} vs {single_selected:.6}"
4086 );
4087 assert!(
4088 (split_selected - single_selected).abs() <= 0.25 * single_selected.abs().max(1.0),
4089 "the two paths disagree on the SELECTED law by more than the coarser \
4090 grid can explain: {split_selected} vs {single_selected}"
4091 );
4092 // Both lanes publish the same generalized spectrum, because it is a
4093 // property of the term and not of which grid was affordable.
4094 for (from_split, from_single) in split
4095 .generalized
4096 .iter()
4097 .zip(single.generalized.iter())
4098 {
4099 assert!(
4100 (from_split - from_single).abs() <= 1e-9 * from_single.abs().max(1.0),
4101 "the two lanes publish different generalized spectra: {:?} vs {:?}",
4102 split.generalized,
4103 single.generalized
4104 );
4105 }
4106 }
4107
4108 /// The multi-scale path is for terms that actually select several scales,
4109 /// and it says so rather than pretending on the ones it cannot serve.
4110 #[test]
4111 fn the_multiscale_path_declines_what_it_cannot_serve() {
4112 let information = Array2::<f64>::eye(3);
4113 let penalty = Array2::<f64>::eye(3);
4114 // One scale: the diagonal path is strictly better, so this declines.
4115 let single = SelectionGeometry::whiten(
4116 &information,
4117 std::slice::from_ref(&penalty),
4118 &[0.0],
4119 )
4120 .expect("single geometry");
4121 assert!(
4122 SmoothLrSelectionReplay::generate_multiscale(
4123 &single,
4124 &[(-6.0, 6.0)],
4125 256,
4126 MultiscaleBudget::SHIPPED,
4127 )
4128 .is_err()
4129 );
4130 // More scales than the grid budget can resolve: declines rather than
4131 // gridding five axes at four points each — and the dispatcher then hands
4132 // the term the common-scale slice rather than nothing.
4133 let many = vec![penalty.clone(); SMOOTH_LR_SELECTION_MAX_SCALES + 1];
4134 let windows = vec![(-6.0, 6.0); SMOOTH_LR_SELECTION_MAX_SCALES + 1];
4135 let crowded = SelectionGeometry::whiten(
4136 &information,
4137 &many,
4138 &vec![0.0; SMOOTH_LR_SELECTION_MAX_SCALES + 1],
4139 )
4140 .expect("crowded geometry");
4141 assert!(
4142 SmoothLrSelectionReplay::generate_multiscale(
4143 &crowded,
4144 &windows,
4145 256,
4146 MultiscaleBudget::SHIPPED,
4147 )
4148 .is_err()
4149 );
4150 assert!(
4151 SmoothLrSelectionReplay::from_geometry(&crowded, &windows, 256, 256)
4152 .replay()
4153 .is_some(),
4154 "a term with more scales than the grid budget still gets the common-scale slice"
4155 );
4156 // Every window closed: nothing to select on any axis.
4157 let pair = SelectionGeometry::whiten(
4158 &information,
4159 &[penalty.clone(), penalty.clone()],
4160 &[0.0, 0.0],
4161 )
4162 .expect("pair geometry");
4163 assert!(
4164 SmoothLrSelectionReplay::generate_multiscale(
4165 &pair,
4166 &[(1.0, 1.0), (2.0, 2.0)],
4167 256,
4168 MultiscaleBudget::SHIPPED,
4169 ) == Err(SmoothLrSelectionDecline::WindowClosed)
4170 );
4171 // But ONE open axis is still a selection, and used to be discarded with
4172 // the closed one — the intersection of the two windows is empty.
4173 assert!(
4174 SmoothLrSelectionReplay::generate_multiscale(
4175 &pair,
4176 &[(1.0, 1.0), (-6.0, 6.0)],
4177 256,
4178 MultiscaleBudget::SHIPPED,
4179 )
4180 .is_ok(),
4181 "a scale whose own window is open must still be replayed when a \
4182 SIBLING scale's window is closed"
4183 );
4184 // Information with no identified direction cannot be whitened at all.
4185 assert!(
4186 SelectionGeometry::whiten(
4187 &Array2::<f64>::zeros((3, 3)),
4188 &[penalty.clone(), penalty],
4189 &[0.0, 0.0],
4190 )
4191 .is_none()
4192 );
4193 }
4194
4195 /// A term with nothing to select — no penalized direction, or a window the
4196 /// solver's box has closed — has no replay, and the conditional law is the
4197 /// selection law. This is the branch that keeps an unpenalized block exactly
4198 /// the textbook chi-square.
4199 #[test]
4200 fn nothing_to_select_means_no_replay() {
4201 assert!(SelectionGeometry::whiten(&Array2::eye(3), &[], &[]).is_none());
4202 assert!(
4203 SelectionGeometry::whiten(&Array2::eye(2), &[Array2::zeros((2, 2))], &[0.0]).is_none()
4204 );
4205 let geometry = diagonal(&spectrum());
4206 for window in [(4.0_f64, -4.0_f64), (f64::NAN, 1.0)] {
4207 assert_eq!(
4208 SmoothLrSelectionReplay::from_geometry(&geometry, &[window], 256, 256).decline(),
4209 Some(SmoothLrSelectionDecline::WindowClosed),
4210 "a closed window must decline with a NAMED reason"
4211 );
4212 }
4213 }
4214
4215 /// The MULTI-SCALE replay is a p-value input too, and its per-draw descent
4216 /// is the part of it that could most easily stop being a pure function of
4217 /// the geometry (#1017).
4218 ///
4219 /// `the_replay_is_bit_identical_across_generations` pins the diagonal lane,
4220 /// where the whole computation is a fixed grid. This pins the lane that
4221 /// searches: two generations must agree bit for bit on both samples and on
4222 /// every tail shift read off them.
4223 #[test]
4224 fn the_multiscale_replay_is_bit_identical_across_generations() {
4225 let q = 5;
4226 let mut bending = Array2::<f64>::zeros((q, q));
4227 let mut ridge = Array2::<f64>::zeros((q, q));
4228 for index in 0..q {
4229 if index < 3 {
4230 bending[[index, index]] = 1.0 + index as f64;
4231 } else {
4232 ridge[[index, index]] = 0.5 + index as f64;
4233 }
4234 }
4235 let generate = || {
4236 let geometry = SelectionGeometry::whiten(
4237 &Array2::eye(q),
4238 &[bending.clone(), ridge.clone()],
4239 &[6.0, -9.0],
4240 )
4241 .expect("geometry");
4242 SmoothLrSelectionReplay::generate_multiscale(
4243 &geometry,
4244 &[(-36.0, 24.0), (-21.0, 39.0)],
4245 512,
4246 MultiscaleBudget::SHIPPED,
4247 )
4248 .expect("multiscale replay")
4249 };
4250 let first = generate();
4251 let second = generate();
4252 assert_eq!(first, second);
4253 for statistic in [0.05_f64, 0.5, 1.5, 4.0] {
4254 assert_eq!(first.tail_shift(statistic), second.tail_shift(statistic));
4255 }
4256 }
4257
4258 /// #2672: the refinement's evaluator and the grid's are the SAME function.
4259 ///
4260 /// The bracket prices a point through the eigensystem of `T(t)` and the
4261 /// refinement prices it through two triangular factorizations, because one
4262 /// amortizes over draws and the other cannot. They are two routes to one
4263 /// number, and the refinement compares its trials against a baseline the
4264 /// bracket produced — so a discrepancy between them is not a rounding
4265 /// difference, it is a search descending one function while reporting
4266 /// another's value.
4267 ///
4268 /// Checked on a DENSE information with two dense components at separations
4269 /// up to the box's own width, which is where the two routes' conditioning
4270 /// differs most, and at `t` off the fitted point in both directions.
4271 #[test]
4272 fn the_two_evaluators_price_a_point_identically_2672() {
4273 let q = 7;
4274 let mixing = Array2::from_shape_fn((q, q), |(row, column)| {
4275 let a = row as f64 + 1.0;
4276 let b = column as f64 + 1.0;
4277 ((a * 0.7 + b * 1.3).sin() + 0.25 * (a * b).cos()) / (1.0 + 0.1 * a * b)
4278 });
4279 let information = mixing.dot(&mixing.t()) + Array2::<f64>::eye(q) * 0.5;
4280 let mut bending = Array2::<f64>::zeros((q, q));
4281 for index in 0..q - 2 {
4282 bending[[index, index]] = 1.0;
4283 bending[[index, index + 1]] = -0.5;
4284 bending[[index + 1, index]] = -0.5;
4285 }
4286 let bending = mixing.dot(&bending.dot(&mixing.t()));
4287 let bending = bending.dot(&bending.t());
4288 let ridge = mixing.dot(&mixing.t());
4289 let draw: Vec<f64> = (0..q)
4290 .map(|index| ((index as f64 + 1.0) * 0.9).sin() + 0.3)
4291 .collect();
4292 let norm_squared: f64 = draw.iter().map(|value| value * value).sum();
4293 for separation in [0.0_f64, 18.0, 40.0] {
4294 let geometry = SelectionGeometry::whiten(
4295 &information,
4296 &[bending.clone(), ridge.clone()],
4297 &[0.5 * separation, -0.5 * separation],
4298 )
4299 .expect("geometry");
4300 let mut factor = SelectionFactor::new(&geometry);
4301 let mut coordinates = vec![0.0_f64; geometry.rank];
4302 for log_t in [[0.0_f64, 0.0], [-2.5, 1.75], [3.0, -4.0], [-8.0, -8.0]] {
4303 let evaluated = geometry.at(&log_t).expect("eigen route");
4304 let mut criterion = evaluated.offset;
4305 let mut statistic = 0.0_f64;
4306 for column in 0..geometry.dimension {
4307 let mut coordinate = 0.0_f64;
4308 for row in 0..geometry.dimension {
4309 coordinate += draw[row] * evaluated.basis[[row, column]];
4310 }
4311 let square = coordinate * coordinate;
4312 criterion += square * evaluated.shares[column];
4313 statistic += square * evaluated.weights[column];
4314 }
4315 assert!(
4316 factor.refactor(&geometry, &log_t),
4317 "separation {separation}, ln t = {log_t:?}: the factor route refused a \
4318 point the eigen route priced"
4319 );
4320 for column in 0..geometry.rank {
4321 coordinates[column] = (0..geometry.dimension)
4322 .map(|row| draw[row] * geometry.range_basis[[row, column]])
4323 .sum();
4324 }
4325 let (fast_criterion, fast_statistic) =
4326 factor.score(&coordinates, norm_squared);
4327 assert!(
4328 (fast_criterion - criterion).abs() <= 1e-8 * criterion.abs().max(1.0),
4329 "separation {separation}, ln t = {log_t:?}: criterion {fast_criterion} \
4330 (factor) vs {criterion} (eigen)"
4331 );
4332 assert!(
4333 (fast_statistic - statistic).abs() <= 1e-8 * statistic.abs().max(1.0),
4334 "separation {separation}, ln t = {log_t:?}: statistic {fast_statistic} \
4335 (factor) vs {statistic} (eigen)"
4336 );
4337 }
4338 }
4339 }
4340
4341 /// #2672: the shipped multi-scale replay reaches the law a grid it cannot
4342 /// afford reaches — and the bracket alone does not.
4343 ///
4344 /// This is the contract the descent exists for, and it is stated as a
4345 /// CONTRAST so it cannot pass by both arms drifting together: the reference
4346 /// is a `161 × 161` grid (spacing `0.375`, `5.8 s` per term — sixty times
4347 /// the shipped budget's cost and still coarser than the descent's floor),
4348 /// and the two arms scored against it are the shipped one and the bracket
4349 /// with the descent switched off, which is what shipped before.
4350 ///
4351 /// Both halves have to hold. Without the second, a descent that did nothing
4352 /// would pass as soon as the reference grid stopped moving; without the
4353 /// first, the test would only be saying that a fine grid differs from a
4354 /// coarse one, which nobody disputes.
4355 #[test]
4356 fn the_descent_reaches_a_grid_it_cannot_afford_2672() {
4357 let q = 6;
4358 let mut bending = Array2::<f64>::zeros((q, q));
4359 let mut ridge = Array2::<f64>::zeros((q, q));
4360 for index in 0..q {
4361 if index < 4 {
4362 bending[[index, index]] = 1.0 + index as f64;
4363 } else {
4364 ridge[[index, index]] = 1.0;
4365 }
4366 }
4367 let geometry = SelectionGeometry::whiten(
4368 &Array2::eye(q),
4369 &[bending, ridge],
4370 &[12.0, -12.0],
4371 )
4372 .expect("geometry");
4373 let windows = [(-42.0, 18.0), (-18.0, 42.0)];
4374 let law = |budget: MultiscaleBudget| {
4375 let replay =
4376 SmoothLrSelectionReplay::generate_multiscale(&geometry, &windows, 2048, budget)
4377 .expect("multiscale replay");
4378 let draws = replay.selection_sample.len() as f64;
4379 let mean = replay.selection_sample.iter().sum::<f64>() / draws;
4380 let mut sorted = replay.selection_sample.clone();
4381 sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
4382 let upper = sorted[((0.95 * draws) as usize).min(sorted.len() - 1)];
4383 (mean, upper)
4384 };
4385 // The reference: a grid nobody can afford per term, with no descent.
4386 let (reference_mean, reference_upper) = law(MultiscaleBudget {
4387 grid: 25_921,
4388 refine_floor: f64::INFINITY,
4389 refine_evaluations: 0,
4390 });
4391 let (shipped_mean, shipped_upper) = law(MultiscaleBudget::SHIPPED);
4392 let (bracket_mean, bracket_upper) = law(MultiscaleBudget {
4393 grid: SMOOTH_LR_SELECTION_GRID_BUDGET,
4394 refine_floor: f64::INFINITY,
4395 refine_evaluations: 0,
4396 });
4397 eprintln!(
4398 "[2672 descent] reference (161²) mean={reference_mean:.4} q95={reference_upper:.4} \
4399 shipped mean={shipped_mean:.4} q95={shipped_upper:.4} \
4400 bracket-only mean={bracket_mean:.4} q95={bracket_upper:.4}"
4401 );
4402 assert!(
4403 (shipped_mean - reference_mean).abs() <= 0.03 * reference_mean.abs(),
4404 "the shipped replay's selected law has mean {shipped_mean} against the \
4405 unaffordable grid's {reference_mean}"
4406 );
4407 assert!(
4408 (shipped_upper - reference_upper).abs() <= 0.03 * reference_upper.abs(),
4409 "the shipped replay's selected law has q95 {shipped_upper} against the \
4410 unaffordable grid's {reference_upper} — and q95 is where α = 0.05 is read"
4411 );
4412 // And the arm the descent replaced misses, in the direction that
4413 // over-rejects: a less-selected law is a thinner upper tail.
4414 assert!(
4415 bracket_upper < 0.9 * reference_upper,
4416 "the bracket alone reached q95 {bracket_upper} against {reference_upper}; if \
4417 the grid on its own is now accurate, this test is no longer measuring the \
4418 defect it was written for and the descent's cost needs re-arguing"
4419 );
4420 assert!(
4421 bracket_mean < reference_mean,
4422 "a coarser selection cannot select MORE: bracket-only mean {bracket_mean} \
4423 against {reference_mean}"
4424 );
4425 }
4426
4427 /// PROBE (#2672, not a contract): what the multi-scale grid's BUDGET costs
4428 /// the law it generates.
4429 ///
4430 /// The one-dimensional lane grids `ln t` at a fixed `0.05`; the
4431 /// multi-scale one spends a fixed TOTAL of `441` points, which at `m = 2`
4432 /// over the box the solver leaves a railed `λ̂` is a spacing of about `3` in
4433 /// `ln λ` — sixty times coarser. The replay's whole job is to reproduce a
4434 /// selection the fit made with a continuum available, so a grid that cannot
4435 /// resolve the criterion's minimum generates a law that is selected LESS
4436 /// than the statistic it is the reference for. This prints the selected
4437 /// law's mean, spread and upper tail against budget so the size of that
4438 /// gap is a measurement rather than an assumption.
4439 #[test]
4440 fn zz_probe_multiscale_grid_budget_moves_the_selected_law_2672() {
4441 let q = 6;
4442 // The default `s(z)` shape: a bending penalty over the wiggly
4443 // directions and a null-space ridge over the rest, fitted at the
4444 // separation a null-true smooth actually reaches (`λ₁` up, `λ₂` down).
4445 let mut bending = Array2::<f64>::zeros((q, q));
4446 let mut ridge = Array2::<f64>::zeros((q, q));
4447 for index in 0..q {
4448 if index < 4 {
4449 bending[[index, index]] = 1.0 + index as f64;
4450 } else {
4451 ridge[[index, index]] = 1.0;
4452 }
4453 }
4454 for separation in [0.0_f64, 24.0, 42.0] {
4455 let (rho_one, rho_two) = (0.5 * separation, -0.5 * separation);
4456 let geometry = SelectionGeometry::whiten(
4457 &Array2::eye(q),
4458 &[bending.clone(), ridge.clone()],
4459 &[rho_one, rho_two],
4460 )
4461 .expect("geometry");
4462 let windows = [
4463 (-30.0 - rho_one, 30.0 - rho_one),
4464 (-30.0 - rho_two, 30.0 - rho_two),
4465 ];
4466 eprintln!(
4467 "[zz2672-grid] separation={separation} window0={:?} window1={:?}",
4468 windows[0], windows[1]
4469 );
4470 // Two axes, and they trade against each other: `grid` is the
4471 // BRACKET the draws start from and `refine_floor` is how far each
4472 // draw then descends. `INFINITY` is the shipped-before arm — the
4473 // bracket alone, with no descent.
4474 let arms: [(usize, f64, &str); 6] = [
4475 (441, f64::INFINITY, "grid only"),
4476 (1681, f64::INFINITY, "grid only"),
4477 (6561, f64::INFINITY, "grid only"),
4478 (25921, f64::INFINITY, "grid only"),
4479 (441, SMOOTH_LR_SELECTION_REFINE_FLOOR, "grid + refine"),
4480 (121, SMOOTH_LR_SELECTION_REFINE_FLOOR, "grid + refine"),
4481 ];
4482 for (grid, refine_floor, label) in arms {
4483 let budget = MultiscaleBudget {
4484 grid,
4485 refine_floor,
4486 refine_evaluations: SMOOTH_LR_SELECTION_REFINE_MAX_EVALUATIONS,
4487 };
4488 let started = std::time::Instant::now();
4489 let replay = SmoothLrSelectionReplay::generate_multiscale(
4490 &geometry, &windows, 2048, budget,
4491 )
4492 .expect("multiscale replay");
4493 let elapsed = started.elapsed().as_secs_f64();
4494 let per_axis = (grid as f64).powf(0.5).floor() as usize;
4495 let draws = replay.selection_sample.len() as f64;
4496 let mean = replay.selection_sample.iter().sum::<f64>() / draws;
4497 let variance = replay
4498 .selection_sample
4499 .iter()
4500 .map(|value| (value - mean) * (value - mean))
4501 .sum::<f64>()
4502 / draws;
4503 let conditional_mean = replay.conditional_sample.iter().sum::<f64>() / draws;
4504 let upper = |quantile: f64| {
4505 let mut sorted = replay.selection_sample.clone();
4506 sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
4507 sorted[((quantile * draws) as usize).min(sorted.len() - 1)]
4508 };
4509 eprintln!(
4510 "[zz2672-grid] {label:<13} grid={grid:>6} per_axis={per_axis:>3} \
4511 spacing={:>6.3} E[W(t-hat)]={mean:.4} sd={:.4} \
4512 q95={:.4} q99={:.4} (E[W|t-hat]={conditional_mean:.4}) {elapsed:.2}s",
4513 (windows[0].1 - windows[0].0) / (per_axis as f64 - 1.0),
4514 variance.sqrt(),
4515 upper(0.95),
4516 upper(0.99),
4517 );
4518 }
4519 }
4520 eprintln!(
4521 "[zz2672-grid] read: the selected law is generated by MINIMISING the \
4522 criterion over the grid, so a finer grid can only lower each draw's \
4523 criterion. A mean/tail that keeps moving as the budget rises is the \
4524 shipped budget failing to reproduce the selection the fit made."
4525 );
4526 }
4527
4528 /// #2672: the criterion's log-determinant is priced from the stacked scaled
4529 /// ROOTS, so a term whose scales separate keeps the coercivity that decides
4530 /// the selection.
4531 ///
4532 /// The replay used to read `Σ_{e > 0} log(1 + 1/e)` off the eigenvalues of
4533 /// the ASSEMBLED whitened sum. That is the route `penalty_logdet.rs`'s
4534 /// `SpectrumScale` documents as `O(ε·κ(S_λ))` — and `κ` here is
4535 /// `exp(ρ̂₁ − ρ̂₂)`, which the box allows to reach `e⁶⁰`. Past `κ ≈ 1e16` the
4536 /// smaller scale's genuine modes are below the eigendecomposition's own
4537 /// noise floor, so their `log(1 + 1/e)` is dropped when the noise lands
4538 /// negative and invented when it lands positive.
4539 ///
4540 /// The identity that makes this checkable without a second implementation:
4541 /// under a COMMON shift the criterion's offset is exactly
4542 /// `Σ_j log(1 + t·ν_j) − rank·ln t − Σ_{j<rank} ln ν_j`, so scaling every
4543 /// scale by `t` must move the offset by exactly `−rank·ln t` once the
4544 /// `log(1 + tν)` part is subtracted. That is a statement about the ANSWER,
4545 /// not about the arithmetic, and the assembled route violates it by tens of
4546 /// nats at the separations this fixture uses.
4547 #[test]
4548 fn the_criterion_keeps_its_coercivity_when_the_scales_separate() {
4549 let q = 6;
4550 // A bending-style penalty on the first four directions and a
4551 // null-space ridge on the last two: the default double penalty's shape.
4552 let mut bending = Array2::<f64>::zeros((q, q));
4553 let mut ridge = Array2::<f64>::zeros((q, q));
4554 for index in 0..q {
4555 if index < 4 {
4556 bending[[index, index]] = 1.0 + index as f64;
4557 } else {
4558 ridge[[index, index]] = 1.0;
4559 }
4560 }
4561 for separation in [0.0_f64, 12.0, 24.0, 42.0, 58.0] {
4562 let geometry = SelectionGeometry::whiten(
4563 &Array2::eye(q),
4564 &[bending.clone(), ridge.clone()],
4565 &[0.5 * separation, -0.5 * separation],
4566 )
4567 .expect("geometry");
4568 assert_eq!(
4569 geometry.rank, q,
4570 "the two components span the block, so the structural rank is q \
4571 whatever the separation"
4572 );
4573 let base = geometry.at(&[0.0, 0.0]).expect("fitted point");
4574 for shift in [-3.0_f64, 1.5] {
4575 let moved = geometry.at(&[shift, shift]).expect("shifted point");
4576 let predicted: f64 = base
4577 .eigenvalues
4578 .iter()
4579 .map(|&nu| (nu * shift.exp()).ln_1p() - nu.ln_1p())
4580 .sum::<f64>()
4581 - geometry.rank as f64 * shift;
4582 assert!(
4583 (moved.offset - base.offset - predicted).abs() <= 1e-8 * (1.0 + predicted.abs()),
4584 "at separation {separation} a common shift of {shift} moved the \
4585 criterion's offset by {} where the closed form says {predicted} \
4586 — the log-determinant has lost the scales it cannot see",
4587 moved.offset - base.offset
4588 );
4589 }
4590 }
4591 }
4592
4593 /// The geometry has to survive a DENSE information and a DENSE penalty,
4594 /// which is the only shape a real fit ever presents.
4595 ///
4596 /// Every other fixture in this module hands `SelectionGeometry::whiten` an
4597 /// identity information and a diagonal penalty, so the congruence
4598 /// `Wᵀ S W` comes out EXACTLY symmetric and the self-adjoint entry point's
4599 /// input validation never fires. On a real fit it is a product of three
4600 /// dense matrices, its two triangles differ by summation order, and
4601 /// `strict_symmetric_eigh` — correctly — refuses rather than symmetrizing
4602 /// for the caller. That refusal is silent: it becomes
4603 /// `SmoothLrSelectionDecline::GeometryRefused`, i.e. no replay at all, on
4604 /// EVERY fit. Measured that way before the symmetrization was restored:
4605 /// `y ~ s(z) [poisson]` declined with `geometry_refused` on the first cell
4606 /// of the integration sweep.
4607 #[test]
4608 fn the_geometry_survives_a_dense_information_and_a_dense_penalty() {
4609 let q = 7;
4610 // A deterministic dense SPD information and two dense PSD penalties
4611 // with complementary-ish ranges, all built by congruence so nothing is
4612 // diagonal and nothing is exactly symmetric in floating point.
4613 let mixing = Array2::from_shape_fn((q, q), |(row, column)| {
4614 let a = row as f64 + 1.0;
4615 let b = column as f64 + 1.0;
4616 ((a * 0.7 + b * 1.3).sin() + 0.25 * (a * b).cos()) / (1.0 + 0.1 * a * b)
4617 });
4618 let information = mixing.dot(&mixing.t()) + Array2::<f64>::eye(q) * 0.5;
4619 let mut bending = Array2::<f64>::zeros((q, q));
4620 for index in 0..q - 2 {
4621 bending[[index, index]] = 1.0;
4622 bending[[index, index + 1]] = -0.5;
4623 bending[[index + 1, index]] = -0.5;
4624 }
4625 let bending = mixing.dot(&bending.dot(&mixing.t()));
4626 let bending = bending.dot(&bending.t());
4627 let ridge = mixing.dot(&mixing.t());
4628 for separation in [0.0_f64, 30.0, 55.0] {
4629 let geometry = SelectionGeometry::whiten(
4630 &information,
4631 &[bending.clone(), ridge.clone()],
4632 &[0.5 * separation, -0.5 * separation],
4633 )
4634 .unwrap_or_else(|| {
4635 panic!(
4636 "the geometry refused a dense fit at separation {separation} — that is a silent `no replay` on every real model"
4637 )
4638 });
4639 let replay = SmoothLrSelectionReplay::from_geometry(
4640 &geometry,
4641 &[(-6.0, 6.0), (-6.0, 6.0)],
4642 512,
4643 512,
4644 );
4645 let replay = replay.replay().unwrap_or_else(|| {
4646 panic!(
4647 "declined at separation {separation}: {:?}",
4648 SmoothLrSelectionReplay::from_geometry(
4649 &geometry,
4650 &[(-6.0, 6.0), (-6.0, 6.0)],
4651 512,
4652 512,
4653 )
4654 .decline()
4655 )
4656 });
4657 assert_eq!(
4658 replay.generalized.len(),
4659 geometry.dimension,
4660 "the published spectrum must cover the block"
4661 );
4662 assert!(
4663 replay
4664 .generalized
4665 .iter()
4666 .all(|value| value.is_finite() && *value >= 0.0)
4667 );
4668 }
4669 }
4670
4671 /// The rank is STRUCTURAL, so an unpenalized direction contributes no
4672 /// log-determinant term at any scale — and a `1e-17` of roundoff on it
4673 /// cannot invent one.
4674 #[test]
4675 fn an_unpenalized_direction_carries_no_log_determinant_term() {
4676 let q = 4;
4677 let mut penalty = Array2::<f64>::zeros((q, q));
4678 for index in 0..q - 1 {
4679 penalty[[index, index]] = 1.0 + index as f64;
4680 }
4681 let geometry =
4682 SelectionGeometry::whiten(&Array2::eye(q), std::slice::from_ref(&penalty), &[0.0])
4683 .expect("geometry");
4684 assert_eq!(geometry.rank, q - 1);
4685 let base = geometry.at(&[0.0]).expect("fitted point");
4686 assert_eq!(base.eigenvalues.len(), q);
4687 // The last direction is unpenalized: weight one, share zero.
4688 let last = base.eigenvalues[q - 1];
4689 assert!(last.abs() < 1e-12, "expected a structural zero, got {last}");
4690 assert!((base.weights[q - 1] - 1.0).abs() < 1e-12);
4691 // And the offset moves by exactly `−rank·ln t` under a common shift, so
4692 // the unpenalized direction is not being counted.
4693 for shift in [-5.0_f64, 4.0] {
4694 let moved = geometry.at(&[shift]).expect("shifted point");
4695 let predicted: f64 = base
4696 .eigenvalues
4697 .iter()
4698 .map(|&nu| (nu * shift.exp()).ln_1p() - nu.ln_1p())
4699 .sum::<f64>()
4700 - geometry.rank as f64 * shift;
4701 assert!(
4702 (moved.offset - base.offset - predicted).abs() <= 1e-9 * (1.0 + predicted.abs()),
4703 "the unpenalized direction leaked a log-determinant term: {} vs {predicted}",
4704 moved.offset - base.offset
4705 );
4706 }
4707 }
4708}
4709
4710#[cfg(test)]
4711mod lr_null_spectrum_moment_tests {
4712 use super::*;
4713
4714 /// No penalty components and therefore no selection window: these tests are
4715 /// about the CONDITIONAL law, so the replay is inert.
4716 const WINDOW: &[(f64, f64)] = &[];
4717
4718 // The whole-term LR reference (#1766, #1872, #2672). The first spectral
4719 // moment `tr(2F − F²)` IS Wood's `edf1`; what changed under #2672 is that it
4720 // is now the MEAN of a reference whose SHAPE comes from the second moment,
4721 // instead of being handed to a chi-square as a degrees of freedom.
4722
4723 #[test]
4724 fn the_first_moment_is_wood_edf1() {
4725 // A symmetric smoother block with eigenvalues {0.9, 0.4}: a partially
4726 // shrunk penalized term. edf = tr = 1.3; tr(F²) = 0.81 + 0.16 = 0.97;
4727 // edf1 = 2·1.3 − 0.97 = 1.63. (Diagonal ⇒ block F² trace = Σ λ².)
4728 let f = ndarray::array![[0.9_f64, 0.0], [0.0, 0.4]];
4729 let [mean, second] = lr_null_spectral_moments(Some(&f), &(0..2)).unwrap();
4730 assert!(
4731 (mean - 1.63).abs() < 1e-12,
4732 "the first spectral moment is Wood's edf1 = 2*tr - tr(F^2) = 1.63, got {mean}"
4733 );
4734 // And it dominates the raw edf, analytically: w_j = 2f_j − f_j² ≥ f_j on
4735 // [0, 1], so no `.max(edf)` guard is needed to make it hold.
4736 assert!(mean >= 1.3 - 1e-12, "edf1 {mean} must be >= edf 1.3");
4737 // Second moment: w = {0.99, 0.64} ⇒ Σw² = 0.9801 + 0.4096.
4738 assert!((second - 1.3897).abs() < 1e-12, "second moment {second}");
4739 }
4740
4741 #[test]
4742 fn a_corrupted_block_degrades_to_the_fallback_rather_than_being_floored() {
4743 // A real influence block has eigenvalues in [0, 1], so `tr(F²)` cannot
4744 // run away. Numerical corruption can still produce one that does, and
4745 // the pre-#2672 code floored `edf1` back at `tr` — silently returning a
4746 // reference derived from a block it had just decided was unusable. The
4747 // spectral reference does not paper over it: the first moment goes
4748 // negative and the assembly degrades to the unit-weight lane, VISIBLY.
4749 let f = ndarray::array![[0.5_f64, 40.0], [40.0, 0.5]];
4750 let [mean, _] = lr_null_spectral_moments(Some(&f), &(0..2)).unwrap();
4751 assert!(mean < 0.0, "the corrupted block's first moment is {mean}");
4752 let reference = lr_null_reference(Some(&f), None, None, &(0..2), 1.0, 1, 0.0, WINDOW, &[], &[]);
4753 assert_eq!(reference.source, SmoothLrReferenceSource::UnitWeightFallback);
4754 assert_eq!(reference.chi_square_df, 1.0);
4755 assert_eq!(reference.scale, 1.0);
4756 }
4757
4758 #[test]
4759 fn returns_none_on_a_missing_or_out_of_range_block() {
4760 // No influence matrix at all → None (caller falls back to the
4761 // unit-weight `max(edf, null_dim, 1)` shape).
4762 assert!(lr_null_spectral_moments(None, &(0..2)).is_none());
4763 // An out-of-bounds range → None, never a panic.
4764 let f = ndarray::array![[0.5_f64, 0.0], [0.0, 0.5]];
4765 assert!(lr_null_spectral_moments(Some(&f), &(0..5)).is_none());
4766 // A fully-shrunk block has ZERO moments, which is not a usable
4767 // reference either — the caller must see the fallback, not a divide by
4768 // zero.
4769 let zero = ndarray::array![[0.0_f64, 0.0], [0.0, 0.0]];
4770 assert_eq!(
4771 lr_null_spectral_moments(Some(&zero), &(0..2)).unwrap(),
4772 [0.0, 0.0]
4773 );
4774 assert_eq!(
4775 lr_null_reference(Some(&zero), None, None, &(0..2), 0.0, 0, 0.0, WINDOW, &[], &[]).source,
4776 SmoothLrReferenceSource::UnitWeightFallback
4777 );
4778 }
4779}