Skip to main content

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}