Skip to main content

gam_models/bms/
conditional_score_covariance.rs

1//! The CONDITIONAL score covariance `Σ(a) = Var(z | a)` (gam#2766).
2//!
3//! # What this fixes
4//!
5//! The marginal-slope probit identity, transcribed from
6//! `crate::bms::gradient_paths`, is
7//!
8//! ```text
9//!   z | a ~ N(0, Σ(a)),   η = c(a)·q(t, a) + r(a)ᵀ z
10//!   E_z[Φ(−η) | a] = Φ(−q(t, a))    ⟺    c(a) = √(1 + r(a)ᵀ Σ(a) r(a))
11//! ```
12//!
13//! `Σ(a)`, conditional on the marginal-index span. Until this module existed the
14//! families supplied it from `marginal_slope_covariance_from_scores` — ONE
15//! weighted empirical covariance pooled over every row. Substituting a constant
16//! `c̄ = √(1 + rᵀΣ̄r)` into the exact integral leaves
17//!
18//! ```text
19//!   E_z[Φ(−η) | a] = Φ(−q · c̄ / √(1 + rᵀΣ(a)r))
20//! ```
21//!
22//! so the realised marginal index is `q · c̄/c(a)`: a multiplicative,
23//! covariate-dependent distortion of the one estimand this family exists to
24//! deliver. It is the same failure the `homoskedastic_var` field doc records for
25//! the K=1 diagonal (`Φ(q√(1+b²)/√(1+b²v))`), one dimension up.
26//!
27//! gam#2768 removed the per-coordinate half of it: after that gate every
28//! coordinate satisfies `E[ζ_j|a] = 0` and `Var(ζ_j|a) = 1`. What no
29//! per-coordinate location-scale map can reach is the OFF-DIAGONAL —
30//! `Cov(ζ_j, ζ_k | a)` — and that is what this module models.
31//!
32//! # The parameterisation, and why this one
33//!
34//! A covariance-valued regression has to return a positive-definite matrix at
35//! every `a`, including rows the fit never saw. Modelling the entries of `Σ(a)`
36//! directly does not: nothing keeps a fitted `[[1, ρ(a)], [ρ(a), 1]]` inside
37//! `|ρ| < 1`, and one row over the edge makes `c(a)` the square root of a
38//! negative number.
39//!
40//! This module uses Pourahmadi's **modified Cholesky decomposition** (MCD),
41//! whose parameters are *unconstrained* — every real value of every parameter
42//! yields a positive-definite `Σ(a)`, so extrapolation cannot manufacture an
43//! inadmissible covariance:
44//!
45//! ```text
46//!   T(a) Σ(a) T(a)ᵀ = D(a),
47//!   T(a) unit lower triangular with  T[j][k] = −φ_{jk}(a)  (k < j),
48//!   D(a) = diag(d_0(a), …, d_{K−1}(a)),   log d_j(a) = γ_jᵀ A(a).
49//! ```
50//!
51//! Read forwards this is a triangular system of ordinary regressions, which is
52//! exactly why it is the right object here — it is the SAME shape as the
53//! machinery gam#2768 already ships, applied one coordinate at a time:
54//!
55//! ```text
56//!   ζ_j = Σ_{k<j} φ_{jk}(a)·ζ_k + ε_j,     Var(ε_j | a) = d_j(a)
57//! ```
58//!
59//! the `φ` stage being a weighted ridge (like the conditional MEAN stage) and
60//! the `d` stage a log-linear variance fit (the conditional VARIANCE stage). The
61//! reconstruction `Σ(a) = T(a)⁻¹ D(a) T(a)⁻ᵀ` is one forward substitution, and
62//! `L(a) = T(a)⁻¹ D(a)^{1/2}` is its exact Cholesky factor — which is precisely
63//! the `Σ = L Lᵀ` shape [`MarginalSlopeCovariance::low_rank`] already admits, so
64//! the row program's quadratic forms stay exact sums of squares and no runtime
65//! eigendecomposition or PSD tolerance appears anywhere on this path.
66//!
67//! `log` d rather than a linear `d` with a floor (the shape gam#2768 used for
68//! the K=1 variance) because a floor is a non-differentiable clamp that can and
69//! does bind, whereas `exp` is positive by construction on the whole real line.
70//!
71//! # What triggers it
72//!
73//! One robust Rao score test per score PAIR, on the same centred marginal-index
74//! span the gam#2768 gate uses and at the same level: `u_i = ζ_ij·ζ_ik − mean`
75//! against `ã(C)`. That statistic tests exactly the sentence this issue is
76//! titled with — "the covariance between two scores varies" — and nothing else.
77//! If no pair fires, this module returns `None` and the caller keeps the pooled
78//! `Σ̄` object it already built, byte for byte.
79//!
80//! Once a pair HAS fired, every stage of the MCD is fitted honestly with its own
81//! gate: a `φ_{jk}` becomes `a`-varying only if its own interaction test fires,
82//! a `log d_j` becomes `a`-varying only if its own Breusch-Pagan test fires.
83//! That ordering — escalate on the thing the issue names, then fit the escalated
84//! model without further hedging — is the one gam#2768 already uses (a
85//! pure-variance trigger there still fits the conditional mean).
86//!
87//! # Extrapolation
88//!
89//! Each fitted linear predictor (`φ_{jk}(a)` and `log d_j(a)`) is clamped at
90//! evaluation to the range it took over the TRAINING rows. The bound is the
91//! data's, not a constant: a linear predictor is identified only on the range
92//! the sample explored, and holding the boundary value beyond it is the same
93//! monotone-extrapolation contract [`crate::bms::LatentZRankIntCalibration`]
94//! already states for out-of-support scores. Without it a predict row far
95//! outside the training hull could return an arbitrarily large `Σ(a)` from a
96//! model that had no evidence there.
97//!
98//! # What `Σ(a)` is a moment OF, and why the ordering with gam#2768 matters
99//!
100//! The estimated object is the conditional SECOND CENTRAL MOMENT about the
101//! weighted GLOBAL score mean, `E[(z − z̄)(z − z̄)ᵀ | a]` — which is what makes
102//! the no-escalation limit of this model exactly the pooled
103//! `marginal_slope_covariance_from_scores` object it refines, rather than
104//! something merely close to it.
105//!
106//! That equals `Var(z | a)` precisely when `E[z | a]` is constant. It is, by the
107//! time this runs: the gam#2768 per-coordinate gate is sequenced FIRST, and it
108//! either removes a detected conditional mean (`ζ = (z − m(a))/√v(a)`) or
109//! certifies at the same `α` that there is none to remove. Running the two in
110//! the other order would be wrong in both directions — this model would absorb
111//! mean structure into a "covariance", and the mean gate would then be
112//! correcting an axis whose scale had already moved.
113//!
114//! # Numerical edge cases
115//!
116//! * **Degenerate (collinear) scores.** A rank-deficient score geometry is a
117//!   real, expected input — [`MarginalSlopeCovariance::full`] says so and admits
118//!   the exactly-singular matrix it produces. Here it lands as a zero innovation
119//!   variance, which `log d` cannot represent, so the innovation is floored at
120//!   the SAME band that admission clamps a numerically-zero eigenvalue inside
121//!   (`128·K·ε·max weighted second moment`). The result is positive definite
122//!   rather than singular, which is strictly better for the `√(1 + rᵀΣr)` that
123//!   consumes it.
124//! * **Extreme magnitudes.** Every stage checks finiteness of what it produces
125//!   and refuses with the offending value rather than propagating a `NaN`: a
126//!   score scale that overflows `ε²` overflows the pooled estimator too, and
127//!   this path says so at the point it happens.
128//! * **An ill-conditioned conditioning span.** A penalised-spline marginal index
129//!   is routinely rank-deficient. Every regression here — the couplings and the
130//!   Fisher-scoring step for the innovation alike — goes through the same
131//!   relative, column-scaled Tikhonov primitive the gam#2768 stages use, so the
132//!   two degrade identically instead of one of them having its own normal
133//!   equations.
134//! * **Parallel and GPU row lanes.** `Σ(a_i)` is a per-row CONSTANT, not a
135//!   function of `β`, so every existing derivative formula holds verbatim and
136//!   nothing about the chain rule changes. Each rayon chunk binds its own row
137//!   workspace, and the survival GPU row kernel already carried `cov_ones` per
138//!   row, so the conditional lane needed no new device state.
139//!
140//! # `K = 1` is deliberately out of scope
141//!
142//! At `K = 1` there is no off-diagonal, and `Var(z|a)` is gam#2768's
143//! per-coordinate branch. Running a second, differently parameterised
144//! conditional-variance model on top of the score that branch has already
145//! standardised would double-correct it. [`ConditionalScoreCovariance::fit`]
146//! therefore returns `None` at `K < 2`, and the K=1 path is bit-for-bit
147//! unchanged.
148
149use super::{
150    AUTO_Z_CONDITIONAL_RAO_ALPHA, AUTO_Z_CONDITIONAL_RIDGE_REL, MarginalSlopeCovariance,
151    build_intercept_basis, robust_conditional_score_pvalue,
152};
153use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
154use serde::{Deserialize, Serialize};
155
156/// Damped Fisher-scoring iterations allowed for one log-linear innovation
157/// variance. The objective is strictly concave and bounded above and every
158/// accepted step strictly ascends it, so the loop terminates on its own; this
159/// cap exists to bound it, not to select an answer, and a run that reaches it is
160/// a refusal rather than a truncation. Measured on the module's own fixtures the
161/// hardest fit converges in 22 accepted steps, with the gain shrinking by about
162/// 4.5x per step — from which 64 is roughly 40 orders of magnitude of headroom.
163const LOG_INNOVATION_MAX_ITERATIONS: usize = 64;
164
165/// One coordinate of the modified Cholesky decomposition.
166///
167/// `autoregression[k]` (for `k < j`) holds the coefficients of `φ_{jk}(a)` and
168/// `log_innovation` the coefficients of `log d_j(a)`, both over the
169/// intercept-augmented basis `A = [1 | a]`. A coefficient vector of length `1`
170/// is a CONSTANT — the stage's own gate did not fire — and one of length
171/// `1 + basis_ncols` is `a`-varying. The two lengths are the only encoding of
172/// "this stage varies"; there is no separate flag that could disagree with the
173/// coefficients.
174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
175pub struct ConditionalScoreCoordinate {
176    /// `φ_{jk}(a)` for `k = 0 … j−1`, in `k` order. Empty for `j = 0`.
177    pub autoregression: Vec<Vec<f64>>,
178    /// `log d_j(a)`.
179    pub log_innovation: Vec<f64>,
180    /// Training range `[min, max]` of each `φ_{jk}(a)` linear predictor, in the
181    /// same order as `autoregression`. Length-1 (constant) stages still carry
182    /// their degenerate range so evaluation has one code path.
183    pub autoregression_range: Vec<[f64; 2]>,
184    /// Training range `[min, max]` of the `log d_j(a)` linear predictor.
185    pub log_innovation_range: [f64; 2],
186}
187
188/// The fitted conditional score covariance `Σ(a) = Var(z | a)`.
189///
190/// Evaluation is [`Self::factor_into`], which writes the exact lower-triangular
191/// Cholesky factor `L(a)` with `Σ(a) = L(a)·L(a)ᵀ`.
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
193pub struct ConditionalScoreCovariance {
194    /// Number of latent-score coordinates `K`. Always `≥ 2`.
195    pub score_dim: usize,
196    /// Number of marginal-design columns in `a` (EXCLUDING the leading
197    /// intercept). A predict-time conditioning block must present exactly this
198    /// many columns.
199    pub basis_ncols: usize,
200    /// Per-coordinate MCD blocks, in coordinate order. Length `score_dim`.
201    pub coordinates: Vec<ConditionalScoreCoordinate>,
202    /// Weighted mean of each raw score column at fit time. `Σ(a)` is the second
203    /// CENTRAL moment about this vector, which is what makes the no-fire limit
204    /// of this model the pooled `marginal_slope_covariance_from_scores` object
205    /// it replaces.
206    pub score_mean: Vec<f64>,
207    /// The pair-wise Rao p-values that decided the escalation, `(j, k, p)` with
208    /// `j < k`. Diagnostic; carried so a fit can say WHY it escalated.
209    pub pair_pvalues: Vec<(usize, usize, f64)>,
210}
211
212/// The score-covariance geometry a marginal-slope fit consumes, ROW BY ROW.
213///
214/// One object with two states, because every consumer wants the same thing —
215/// "the covariance at this row" — and only the fit's own gate decides which
216/// state it is in:
217///
218/// * `Pooled` — the single weighted empirical `Σ̄` from
219///   `marginal_slope_covariance_from_scores`. Every row returns the same
220///   object, so a fit that does not escalate is bit-for-bit what it was before
221///   gam#2766.
222/// * conditional — a materialised `Σ(a_i)` per row, produced by
223///   [`ConditionalScoreCovariance::row_covariances`]. The pooled object is
224///   RETAINED alongside it: it is still the fit's summary statistic (it is what
225///   the on-disk contract carries and what a diagnostic reports), and keeping it
226///   means no caller has to decide between "the covariance" and "the covariance
227///   here".
228///
229/// The row lane is an index, not a branch on a model: `at_row` is one match on
230/// an `Option` and one slice index, so the conditional path costs the hot loop
231/// nothing beyond the indirection.
232#[derive(Clone)]
233pub struct ScoreCovarianceField {
234    pooled: MarginalSlopeCovariance,
235    per_row: Option<std::sync::Arc<Vec<MarginalSlopeCovariance>>>,
236    model: Option<std::sync::Arc<ConditionalScoreCovariance>>,
237}
238
239impl std::fmt::Debug for ScoreCovarianceField {
240    /// Summarises rather than dumps. A derived `Debug` on a conditional field
241    /// prints one `K × K` matrix PER ROW, which on a biobank-scale fit is a
242    /// multi-gigabyte line the first time anything formats a family.
243    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        formatter
245            .debug_struct("ScoreCovarianceField")
246            .field("pooled", &self.pooled)
247            .field("materialised_rows", &self.materialised_rows())
248            .field("conditional_model", &self.model)
249            .finish()
250    }
251}
252
253impl PartialEq for ScoreCovarianceField {
254    /// Compares the geometry, not the materialised stack: two fields built from
255    /// the same pooled covariance and the same conditional model ARE the same
256    /// field, and the per-row stack is a pure function of the two.
257    fn eq(&self, other: &Self) -> bool {
258        self.pooled == other.pooled && self.model == other.model
259    }
260}
261
262impl From<MarginalSlopeCovariance> for ScoreCovarianceField {
263    fn from(pooled: MarginalSlopeCovariance) -> Self {
264        Self {
265            pooled,
266            per_row: None,
267            model: None,
268        }
269    }
270}
271
272impl ScoreCovarianceField {
273    /// The pooled, row-invariant field. This is the pre-gam#2766 object.
274    pub fn pooled(pooled: MarginalSlopeCovariance) -> Self {
275        Self::from(pooled)
276    }
277
278    /// Materialise `Σ(a_i)` for every row of `a_block`, keeping `pooled` as the
279    /// fit's summary. Refuses a model whose score dimension disagrees with the
280    /// pooled covariance's, because the two are the same `K` by construction and
281    /// a mismatch means the caller paired a field with the wrong fit.
282    pub fn conditional(
283        pooled: MarginalSlopeCovariance,
284        model: ConditionalScoreCovariance,
285        a_block: ArrayView2<'_, f64>,
286    ) -> Result<Self, String> {
287        if model.score_dim != pooled.dim() {
288            return Err(format!(
289                "conditional score covariance is K={} but the pooled covariance is K={}",
290                model.score_dim,
291                pooled.dim()
292            ));
293        }
294        let per_row = model.row_covariances(a_block)?;
295        Ok(Self {
296            pooled,
297            per_row: Some(std::sync::Arc::new(per_row)),
298            model: Some(std::sync::Arc::new(model)),
299        })
300    }
301
302    /// The covariance at `row`.
303    #[inline(always)]
304    pub fn at_row(&self, row: usize) -> &MarginalSlopeCovariance {
305        match &self.per_row {
306            None => &self.pooled,
307            Some(stack) => &stack[row],
308        }
309    }
310
311    /// The fit's pooled summary covariance, whatever the field's state.
312    #[inline]
313    pub fn pooled_covariance(&self) -> &MarginalSlopeCovariance {
314        &self.pooled
315    }
316
317    /// `K`.
318    #[inline]
319    pub fn dim(&self) -> usize {
320        self.pooled.dim()
321    }
322
323    /// Whether the covariance varies by row.
324    #[inline]
325    pub fn is_conditional(&self) -> bool {
326        self.per_row.is_some()
327    }
328
329    /// The fitted conditional model, when the field carries one. This is the
330    /// object persistence and prediction need; the materialised stack is a
331    /// training-row cache and is never saved.
332    #[inline]
333    pub fn model(&self) -> Option<&ConditionalScoreCovariance> {
334        self.model.as_deref()
335    }
336
337    /// Rows the field was materialised for, when it is conditional. A caller
338    /// that indexes past this has mixed two samples.
339    #[inline]
340    pub fn materialised_rows(&self) -> Option<usize> {
341        self.per_row.as_ref().map(|stack| stack.len())
342    }
343}
344
345/// Affine evaluation `coeffs·[1 | a]`, clamped to the training range of that
346/// linear predictor. A length-1 coefficient vector is the constant stage.
347#[inline]
348fn clamped_affine(coeffs: &[f64], range: &[f64; 2], a_row: ArrayView1<'_, f64>) -> f64 {
349    let mut acc = coeffs[0];
350    for (coefficient, &value) in coeffs[1..].iter().zip(a_row.iter()) {
351        acc += coefficient * value;
352    }
353    acc.clamp(range[0], range[1])
354}
355
356impl ConditionalScoreCovariance {
357    /// The lower-triangular Cholesky factor `L(a)` of `Σ(a)`, written into
358    /// `factor` (shape `K × K`, fully overwritten including the strict upper
359    /// triangle).
360    ///
361    /// `T(a)` is unit lower triangular with `T[j][k] = −φ_{jk}(a)`, so its
362    /// inverse `U = T⁻¹` is unit lower triangular and obtained by one forward
363    /// substitution:
364    ///
365    /// ```text
366    ///   U[j][j] = 1,   U[j][k] = φ_{jk}(a) + Σ_{m=k+1}^{j−1} φ_{jm}(a)·U[m][k]
367    /// ```
368    ///
369    /// and `L = U·D^{1/2}`, i.e. `L[j][k] = U[j][k]·√d_k(a)`. Positive
370    /// definiteness needs only `d_k(a) > 0`, which `exp` guarantees.
371    pub fn factor_into(
372        &self,
373        a_row: ArrayView1<'_, f64>,
374        factor: &mut Array2<f64>,
375    ) -> Result<(), String> {
376        let k = self.score_dim;
377        if a_row.len() != self.basis_ncols {
378            return Err(format!(
379                "conditional score covariance expects {} basis columns, got {}",
380                self.basis_ncols,
381                a_row.len()
382            ));
383        }
384        if factor.dim() != (k, k) {
385            return Err(format!(
386                "conditional score covariance factor must be {k}x{k}, got {}x{}",
387                factor.nrows(),
388                factor.ncols()
389            ));
390        }
391        factor.fill(0.0);
392        for j in 0..k {
393            let block = &self.coordinates[j];
394            // U[j][*] by forward substitution, written in place into row j.
395            factor[[j, j]] = 1.0;
396            for k_index in 0..j {
397                let phi = clamped_affine(
398                    &block.autoregression[k_index],
399                    &block.autoregression_range[k_index],
400                    a_row,
401                );
402                if !phi.is_finite() {
403                    return Err(format!(
404                        "conditional score covariance autoregression ({j},{k_index}) is not finite"
405                    ));
406                }
407                let mut value = phi;
408                for m in (k_index + 1)..j {
409                    let phi_jm = clamped_affine(
410                        &block.autoregression[m],
411                        &block.autoregression_range[m],
412                        a_row,
413                    );
414                    value += phi_jm * factor[[m, k_index]];
415                }
416                factor[[j, k_index]] = value;
417            }
418        }
419        // Scale column `k` by √d_k(a).
420        for column in 0..k {
421            let block = &self.coordinates[column];
422            let log_d = clamped_affine(
423                &block.log_innovation,
424                &block.log_innovation_range,
425                a_row,
426            );
427            let scale = (0.5 * log_d).exp();
428            if !(scale.is_finite() && scale > 0.0) {
429                return Err(format!(
430                    "conditional score covariance innovation {column} evaluated to log d = {log_d}"
431                ));
432            }
433            for row in column..k {
434                factor[[row, column]] *= scale;
435            }
436        }
437        Ok(())
438    }
439
440    /// `Σ(a)` as a dense symmetric matrix. Diagnostics and tests; the row
441    /// program consumes [`Self::row_covariances`].
442    pub fn dense_at(&self, a_row: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
443        let mut factor = Array2::<f64>::zeros((self.score_dim, self.score_dim));
444        self.factor_into(a_row, &mut factor)?;
445        Ok(factor.dot(&factor.t()))
446    }
447
448    /// One admitted [`MarginalSlopeCovariance`] per row of `a_block`, in the
449    /// `Σ = L Lᵀ` low-rank representation whose quadratic forms are exact sums
450    /// of squares.
451    ///
452    /// Materialising the whole stack once is deliberate. The row program
453    /// evaluates `rᵀΣ(a_i)r` inside the inner Newton loop, many times per row
454    /// per outer step; re-running the forward substitution there would put an
455    /// `O(K³)` triangular solve in the hot path to save `K²` doubles of storage.
456    /// The stack costs `K` times the score matrix `z` the family already holds.
457    pub fn row_covariances(
458        &self,
459        a_block: ArrayView2<'_, f64>,
460    ) -> Result<Vec<MarginalSlopeCovariance>, String> {
461        if a_block.ncols() != self.basis_ncols {
462            return Err(format!(
463                "conditional score covariance expects {} basis columns, got {}",
464                self.basis_ncols,
465                a_block.ncols()
466            ));
467        }
468        let mut factor = Array2::<f64>::zeros((self.score_dim, self.score_dim));
469        let mut out = Vec::with_capacity(a_block.nrows());
470        for row in 0..a_block.nrows() {
471            self.factor_into(a_block.row(row), &mut factor)?;
472            out.push(MarginalSlopeCovariance::low_rank(factor.clone())?);
473        }
474        Ok(out)
475    }
476
477    /// Fit the conditional covariance, or decide there is nothing to fit.
478    ///
479    /// `scores` are the latent scores the row kernel will actually see — i.e.
480    /// AFTER the gam#2768 per-coordinate calibration, because `Σ` is the
481    /// covariance of the axis the likelihood integrates over. Returns `None`
482    /// when `K < 2`, when the basis is degenerate, or when no score pair's
483    /// covariance is significantly non-constant on `a_block`; in every one of
484    /// those the caller must keep the pooled object unchanged.
485    pub fn fit(
486        scores: ArrayView2<'_, f64>,
487        weights: ArrayView1<'_, f64>,
488        a_block: ArrayView2<'_, f64>,
489    ) -> Result<Option<Self>, String> {
490        let (n, k) = scores.dim();
491        let p = a_block.ncols();
492        if k < 2 || p == 0 || n == 0 {
493            return Ok(None);
494        }
495        if weights.len() != n || a_block.nrows() != n {
496            return Err(format!(
497                "conditional score covariance length mismatch: rows={n}, weights={}, basis rows={}",
498                weights.len(),
499                a_block.nrows()
500            ));
501        }
502        let total_weight = weights.iter().copied().sum::<f64>();
503        if !(total_weight.is_finite() && total_weight > 0.0) {
504            return Ok(None);
505        }
506        if scores.iter().chain(a_block.iter()).any(|v| !v.is_finite()) {
507            return Ok(None);
508        }
509
510        // Centre on the weighted score mean, so the no-escalation limit of this
511        // model is exactly `marginal_slope_covariance_from_scores`.
512        let mut score_mean = vec![0.0_f64; k];
513        for coordinate in 0..k {
514            score_mean[coordinate] = scores
515                .column(coordinate)
516                .iter()
517                .zip(weights.iter())
518                .map(|(&value, &weight)| weight * value)
519                .sum::<f64>()
520                / total_weight;
521        }
522        let mut centered = Array2::<f64>::zeros((n, k));
523        for row in 0..n {
524            for coordinate in 0..k {
525                centered[[row, coordinate]] = scores[[row, coordinate]] - score_mean[coordinate];
526            }
527        }
528
529        // Centre the basis columns: the score tests are about conditional
530        // structure BEYOND the global level, and a constant column collapses to
531        // ~0 and is dropped by the statistic's own pseudo-inverse rank.
532        let mut a_centered = a_block.to_owned();
533        for column in 0..p {
534            let column_mean = a_block
535                .column(column)
536                .iter()
537                .zip(weights.iter())
538                .map(|(&value, &weight)| weight * value)
539                .sum::<f64>()
540                / total_weight;
541            a_centered
542                .column_mut(column)
543                .mapv_inplace(|value| value - column_mean);
544        }
545
546        // The escalation gate: one robust Rao score test per PAIR, on the
547        // cross-product residual. This is the statistic for the sentence the
548        // issue is titled with and nothing wider.
549        let mut pair_pvalues = Vec::new();
550        let mut any_pair_fires = false;
551        for left in 0..k {
552            for right in (left + 1)..k {
553                let products: Vec<f64> = (0..n)
554                    .map(|row| centered[[row, left]] * centered[[row, right]])
555                    .collect();
556                let mean = products
557                    .iter()
558                    .zip(weights.iter())
559                    .map(|(&value, &weight)| weight * value)
560                    .sum::<f64>()
561                    / total_weight;
562                let residual: Vec<f64> = products.iter().map(|&value| value - mean).collect();
563                let p_value =
564                    robust_conditional_score_pvalue(a_centered.view(), &residual, weights)?;
565                if let Some(p_value) = p_value {
566                    pair_pvalues.push((left, right, p_value));
567                    any_pair_fires |= p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA;
568                }
569            }
570        }
571        if !any_pair_fires {
572            return Ok(None);
573        }
574
575        let basis = build_intercept_basis(a_block);
576        // The band inside which an innovation variance is indistinguishable
577        // from zero. Same form and same coefficient as the PSD band
578        // `MarginalSlopeCovariance::full` admits an eigenvalue inside
579        // (`128·k·ε·max|λ̂|`), against the largest weighted second moment of the
580        // centred scores -- the scale a variance of this sample can have. It is
581        // derived from the sample and the floating-point type, and it exists
582        // because `log 0` is not a number, not because a number had to be
583        // picked.
584        let score_scale = (0..k)
585            .map(|coordinate| {
586                (0..n)
587                    .map(|row| {
588                        weights[row] * centered[[row, coordinate]] * centered[[row, coordinate]]
589                    })
590                    .sum::<f64>()
591                    / total_weight
592            })
593            .fold(0.0_f64, f64::max);
594        let innovation_floor =
595            (128.0 * k as f64 * f64::EPSILON * score_scale).max(f64::MIN_POSITIVE);
596        // One MCD block per coordinate, in order: the triangular structure means
597        // coordinate `j` regresses on the CENTRED scores below it, not on their
598        // innovations, so no state carries between iterations.
599        let mut coordinates = Vec::with_capacity(k);
600        for j in 0..k {
601            let (autoregression, autoregression_range, residual) =
602                fit_autoregression(&centered, j, basis.view(), a_centered.view(), weights)?;
603            let (log_innovation, log_innovation_range) = fit_log_innovation(
604                &residual,
605                basis.view(),
606                a_centered.view(),
607                weights,
608                total_weight,
609                innovation_floor,
610            )?;
611            coordinates.push(ConditionalScoreCoordinate {
612                autoregression,
613                log_innovation,
614                autoregression_range,
615                log_innovation_range,
616            });
617        }
618
619        Ok(Some(Self {
620            score_dim: k,
621            basis_ncols: p,
622            coordinates,
623            score_mean,
624            pair_pvalues,
625        }))
626    }
627}
628
629/// Fit `ζ_j = Σ_{k<j} φ_{jk}(a)·ζ_k + ε_j`, returning the coefficient blocks,
630/// their realised training ranges, and the residual `ε_j`.
631///
632/// Each `φ_{jk}` starts constant. A robust Rao score test of the constant-fit
633/// residual against `ã ⊙ ζ_k` — the exact LM statistic for "the coefficient of
634/// `ζ_k` depends on `a`" — decides whether that one coefficient is promoted to
635/// the full basis. Promotion is per `(j, k)`, so a model with one varying
636/// coupling does not spend `p` parameters on the couplings that are constant.
637fn fit_autoregression(
638    centered: &Array2<f64>,
639    j: usize,
640    basis: ArrayView2<'_, f64>,
641    a_centered: ArrayView2<'_, f64>,
642    weights: ArrayView1<'_, f64>,
643) -> Result<(Vec<Vec<f64>>, Vec<[f64; 2]>, Vec<f64>), String> {
644    let n = centered.nrows();
645    let response: Vec<f64> = (0..n).map(|row| centered[[row, j]]).collect();
646    if j == 0 {
647        return Ok((Vec::new(), Vec::new(), response));
648    }
649
650    // Stage 1 — constant couplings.
651    let mut constant_design = Array2::<f64>::zeros((n, j));
652    for row in 0..n {
653        for k_index in 0..j {
654            constant_design[[row, k_index]] = centered[[row, k_index]];
655        }
656    }
657    let (constant_coeffs, constant_fitted) =
658        weighted_ridge_columns(constant_design.view(), &response, weights)?;
659    let constant_residual: Vec<f64> = (0..n)
660        .map(|row| response[row] - constant_fitted[row])
661        .collect();
662
663    // Stage 2 — per-coupling interaction tests on that residual.
664    let mut varying = vec![false; j];
665    for k_index in 0..j {
666        let interaction: Vec<f64> = (0..n)
667            .map(|row| constant_residual[row] * centered[[row, k_index]])
668            .collect();
669        if let Some(p_value) =
670            robust_conditional_score_pvalue(a_centered, &interaction, weights)?
671        {
672            varying[k_index] = p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA;
673        }
674    }
675    if varying.iter().all(|fires| !fires) {
676        let coeffs: Vec<Vec<f64>> = (0..j).map(|k| vec![constant_coeffs[k]]).collect();
677        let ranges: Vec<[f64; 2]> = (0..j)
678            .map(|k| [constant_coeffs[k], constant_coeffs[k]])
679            .collect();
680        return Ok((coeffs, ranges, constant_residual));
681    }
682
683    // Stage 3 — refit with the promoted couplings expanded over `[1 | a]`.
684    let basis_width = basis.ncols();
685    let mut widths = Vec::with_capacity(j);
686    let mut total_columns = 0usize;
687    for &fires in varying.iter() {
688        let width = if fires { basis_width } else { 1 };
689        widths.push(width);
690        total_columns += width;
691    }
692    let mut design = Array2::<f64>::zeros((n, total_columns));
693    let mut offset = 0usize;
694    for (k_index, &width) in widths.iter().enumerate() {
695        for row in 0..n {
696            let score = centered[[row, k_index]];
697            if width == 1 {
698                design[[row, offset]] = score;
699            } else {
700                for column in 0..basis_width {
701                    design[[row, offset + column]] = score * basis[[row, column]];
702                }
703            }
704        }
705        offset += width;
706    }
707    let (coeffs, fitted) = weighted_ridge_columns(design.view(), &response, weights)?;
708    let residual: Vec<f64> = (0..n).map(|row| response[row] - fitted[row]).collect();
709
710    // Unpack into per-coupling blocks and record each one's realised range.
711    let mut blocks = Vec::with_capacity(j);
712    let mut ranges = Vec::with_capacity(j);
713    let mut offset = 0usize;
714    for &width in widths.iter() {
715        let block: Vec<f64> = coeffs[offset..offset + width].to_vec();
716        let range = linear_predictor_range(&block, basis, weights);
717        blocks.push(block);
718        ranges.push(range);
719        offset += width;
720    }
721    Ok((blocks, ranges, residual))
722}
723
724/// Fit `log d(a) = γᵀ[1 | a]` for the innovation `ε` by exact Fisher scoring of
725/// the Gaussian log-linear variance model, gated by a Breusch-Pagan score test.
726///
727/// With `ε_i ~ N(0, d_i)`, `d_i = exp(A_iᵀγ)`, the weighted log-likelihood score
728/// and Fisher information are
729///
730/// ```text
731///   s(γ) = ½ Σ_i w_i A_i (ε_i²/d_i − 1),     I(γ) = ½ Σ_i w_i A_i A_iᵀ
732/// ```
733///
734/// so the scoring step is `Δγ = (Σ w A Aᵀ)⁻¹ Σ w A (ε²/d − 1)`: the halves
735/// cancel and the information does not depend on the response. That is why this
736/// is a short exact loop rather than the log-of-squares linear regression
737/// (Harvey's two-step), whose intercept carries the `E[log χ²₁] = −1.2704` bias
738/// and whose slopes are inefficient.
739///
740/// `Σ w A Aᵀ` is the EXPECTED information, not the observed one — the observed
741/// Hessian carries an `ε²/d` weight — so the undamped step overshoots and the
742/// iteration is monotone in the log-likelihood but not in any norm of the step.
743/// The loop is therefore a line-searched ascent, not a bare Newton iteration;
744/// see the body for the measurement that forced it.
745fn fit_log_innovation(
746    residual: &[f64],
747    basis: ArrayView2<'_, f64>,
748    a_centered: ArrayView2<'_, f64>,
749    weights: ArrayView1<'_, f64>,
750    total_weight: f64,
751    innovation_floor: f64,
752) -> Result<(Vec<f64>, [f64; 2]), String> {
753    let n = residual.len();
754    let raw = residual
755        .iter()
756        .zip(weights.iter())
757        .map(|(&value, &weight)| weight * value * value)
758        .sum::<f64>()
759        / total_weight;
760    if !raw.is_finite() {
761        return Err(format!(
762            "conditional score covariance innovation variance is {raw}, which no log-linear \
763             variance model can represent"
764        ));
765    }
766    let homoskedastic = raw.max(innovation_floor);
767    let constant = vec![homoskedastic.ln()];
768    let constant_range = [constant[0], constant[0]];
769
770    // A direction the sample does not distinguish from a point. Collinear
771    // scores are a REAL and expected input -- `MarginalSlopeCovariance::full`
772    // says so, and admits an eigenvalue anywhere inside its own solver band as
773    // an exact zero -- so this cannot be a refusal. It is instead the one place
774    // the log parameterisation needs a floor, because `log 0` is not a number:
775    // the innovation is held at the same band `full` clamps inside, which
776    // contributes no spread at the working precision and keeps `Σ(a)` positive
777    // definite rather than singular. Below it there is nothing to model, so the
778    // Breusch-Pagan stage is skipped as well.
779    if raw <= innovation_floor {
780        return Ok((constant, constant_range));
781    }
782
783    // Breusch-Pagan: does the innovation variance depend on `a` at all?
784    let bp_residual: Vec<f64> = residual
785        .iter()
786        .map(|&value| value * value - homoskedastic)
787        .collect();
788    let fires = robust_conditional_score_pvalue(a_centered, &bp_residual, weights)?
789        .is_some_and(|p_value| p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA);
790    if !fires {
791        return Ok((constant, constant_range));
792    }
793
794    let width = basis.ncols();
795    let mut gamma = vec![0.0_f64; width];
796    gamma[0] = homoskedastic.ln();
797
798    // The Gaussian log-likelihood of the log-linear variance model, up to a
799    // constant: `ℓ(γ) = −½ Σ w (A_iᵀγ + ε_i²·exp(−A_iᵀγ))`. Non-finite is a
800    // legitimate answer — an iterate that drives `exp(−A_iᵀγ)` past the
801    // representable range is simply not an ascent step, and the line search
802    // below halves past it rather than the whole fit refusing.
803    let log_likelihood = |coefficients: &[f64]| -> Option<f64> {
804        let mut total = 0.0_f64;
805        for row in 0..n {
806            let weight = weights[row];
807            if !(weight > 0.0) {
808                continue;
809            }
810            let mut linear = 0.0;
811            for column in 0..width {
812                linear += coefficients[column] * basis[[row, column]];
813            }
814            total -= 0.5 * weight * (linear + residual[row] * residual[row] * (-linear).exp());
815        }
816        total.is_finite().then_some(total)
817    };
818
819    // Fisher scoring with a step-halving line search.
820    //
821    // The scoring step `Δγ = (Σ w A Aᵀ)⁻¹ Σ w A (ε²/d − 1)` is literally a
822    // weighted least-squares fit of `u_i = ε_i²/d_i − 1` on `A`, so it is taken
823    // with the same regularised primitive the coupling stage uses; the two then
824    // degrade identically on a rank-deficient span instead of one of them having
825    // its own hand-rolled normal equations.
826    //
827    // The line search is not optional. `Σ w A Aᵀ` is the EXPECTED information;
828    // the observed one carries an `ε²/d` weight, so the undamped step
829    // systematically overshoots and the iteration is not monotone in any norm of
830    // the step. Measured on a cubic span: the raw step's `max|A·Δγ|` went
831    // 3.21 → 1.04 → 1.66, and an earlier "stop when the step stops shrinking"
832    // rule took that third reading as convergence and returned a `log d` short of
833    // the optimum by 4.5 nats. Damping restores monotone ASCENT, which is the
834    // property a strictly concave objective actually supports, and the same run
835    // then converges in 22 accepted steps.
836    let mut current = log_likelihood(&gamma).ok_or_else(|| {
837        format!(
838            "conditional score covariance log-variance seed log d = {} is not evaluable",
839            gamma[0]
840        )
841    })?;
842    let mut converged = false;
843    for _ in 0..LOG_INNOVATION_MAX_ITERATIONS {
844        let mut deviation = Array1::<f64>::zeros(n);
845        for row in 0..n {
846            let mut linear = 0.0;
847            for column in 0..width {
848                linear += gamma[column] * basis[[row, column]];
849            }
850            deviation[row] = residual[row] * residual[row] * (-linear).exp() - 1.0;
851        }
852        if !deviation.iter().all(|value| value.is_finite()) {
853            return Err(
854                "conditional score covariance log-variance iterate left the representable range"
855                    .to_string(),
856            );
857        }
858        let (step, _) = weighted_ridge_columns(
859            basis,
860            deviation.as_slice().expect("deviation is standard layout"),
861            weights,
862        )?;
863        // Halve until the step ascends. Two derived exits and no chosen
864        // tolerance: a trial that no longer moves `γ` at all in floating point
865        // cannot ascend, and a gain below the log-likelihood's own last place is
866        // not a gain.
867        let mut trial = 1.0_f64;
868        let mut accepted: Option<(Vec<f64>, f64)> = None;
869        while trial > 0.0 {
870            let candidate: Vec<f64> = gamma
871                .iter()
872                .zip(step.iter())
873                .map(|(value, direction)| value + trial * direction)
874                .collect();
875            if candidate
876                .iter()
877                .zip(gamma.iter())
878                .all(|(new, old)| new.to_bits() == old.to_bits())
879            {
880                break;
881            }
882            if let Some(value) = log_likelihood(&candidate)
883                && value > current
884            {
885                accepted = Some((candidate, value));
886                break;
887            }
888            trial *= 0.5;
889        }
890        let Some((candidate, value)) = accepted else {
891            // No representable step ascends: this IS the optimum to round-off.
892            converged = true;
893            break;
894        };
895        let gain = value - current;
896        gamma = candidate;
897        current = value;
898        if gain <= f64::EPSILON * (1.0 + current.abs()) {
899            converged = true;
900            break;
901        }
902    }
903    if !converged {
904        return Err(format!(
905            "conditional score covariance log-variance scoring did not converge in \
906             {LOG_INNOVATION_MAX_ITERATIONS} damped Fisher steps"
907        ));
908    }
909    let range = linear_predictor_range(&gamma, basis, weights);
910    Ok((gamma, range))
911}
912
913/// `[min, max]` of `coeffs·[1 | a]` over the training rows that carry weight.
914/// A constant stage returns its own degenerate range, so evaluation has exactly
915/// one code path.
916fn linear_predictor_range(
917    coeffs: &[f64],
918    basis: ArrayView2<'_, f64>,
919    weights: ArrayView1<'_, f64>,
920) -> [f64; 2] {
921    if coeffs.len() == 1 {
922        return [coeffs[0], coeffs[0]];
923    }
924    let mut low = f64::INFINITY;
925    let mut high = f64::NEG_INFINITY;
926    for row in 0..basis.nrows() {
927        if !(weights[row] > 0.0) {
928            continue;
929        }
930        let mut linear = 0.0;
931        for column in 0..coeffs.len() {
932            linear += coeffs[column] * basis[[row, column]];
933        }
934        low = low.min(linear);
935        high = high.max(linear);
936    }
937    if !(low.is_finite() && high.is_finite() && low <= high) {
938        return [coeffs[0], coeffs[0]];
939    }
940    [low, high]
941}
942
943/// Weighted ridge of `response` on `design` with the same relative,
944/// column-scaled Tikhonov penalty the conditional location-scale stages use.
945/// Returns the coefficients and the fitted values.
946fn weighted_ridge_columns(
947    design: ArrayView2<'_, f64>,
948    response: &[f64],
949    weights: ArrayView1<'_, f64>,
950) -> Result<(Vec<f64>, Vec<f64>), String> {
951    let width = design.ncols();
952    let mut penalty = Array2::<f64>::zeros((width, width));
953    for column in 0..width {
954        let diagonal = design
955            .column(column)
956            .iter()
957            .zip(weights.iter())
958            .map(|(&value, &weight)| weight * value * value)
959            .sum::<f64>()
960            .max(f64::MIN_POSITIVE);
961        penalty[[column, column]] = diagonal;
962    }
963    let response_array = Array1::from_vec(response.to_vec());
964    let response_column = response_array.view().insert_axis(ndarray::Axis(1));
965    let (coeffs, fitted) = gam_linalg::utils::gaussian_weighted_ridge(
966        design,
967        response_column,
968        penalty.view(),
969        weights,
970        AUTO_Z_CONDITIONAL_RIDGE_REL,
971    )?;
972    Ok((
973        coeffs.column(0).to_vec(),
974        fitted.column(0).to_vec(),
975    ))
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    /// Deterministic standard normals (Box–Muller over splitmix64).
983    fn gaussians(n: usize, seed: u64) -> Vec<f64> {
984        let mut state = seed;
985        let mut unit = || {
986            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
987            let mut z = state;
988            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
989            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
990            z ^= z >> 31;
991            ((z >> 11) as f64 + 0.5) / (1u64 << 53) as f64
992        };
993        let mut out = Vec::with_capacity(n + 1);
994        while out.len() < n {
995            let u1 = unit().max(1e-12);
996            let u2 = unit();
997            let r = (-2.0 * u1.ln()).sqrt();
998            out.push(r * (std::f64::consts::TAU * u2).cos());
999            out.push(r * (std::f64::consts::TAU * u2).sin());
1000        }
1001        out.truncate(n);
1002        out
1003    }
1004
1005    fn standardized(mut v: Vec<f64>) -> Vec<f64> {
1006        let n = v.len() as f64;
1007        let mean = v.iter().sum::<f64>() / n;
1008        let sd = (v.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n)
1009            .sqrt()
1010            .max(1e-12);
1011        for value in v.iter_mut() {
1012            *value = (*value - mean) / sd;
1013        }
1014        v
1015    }
1016
1017    /// A `K = 2` sample drawn from EXACTLY the model this module fits:
1018    /// `ζ₀ = √d₀·e₀`, `ζ₁ = φ(x)·ζ₀ + √d₁(x)·e₁` with `φ(x) = φ₀ + φ₁x` and
1019    /// `log d₁(x) = γ₀ + γ₁x`. Recovery is then a statement about the estimator
1020    /// and not about approximation error.
1021    fn in_class_fixture(
1022        n: usize,
1023        phi: [f64; 2],
1024        gamma: [f64; 2],
1025        log_d0: f64,
1026    ) -> (Array2<f64>, Array1<f64>, Array2<f64>) {
1027        let x = standardized(gaussians(n, 0x2766_D4));
1028        let e0 = standardized(gaussians(n, 0x2766_E5));
1029        let e1 = standardized(gaussians(n, 0x2766_F6));
1030        let mut scores = Array2::<f64>::zeros((n, 2));
1031        let mut a_block = Array2::<f64>::zeros((n, 1));
1032        let sd0 = (0.5 * log_d0).exp();
1033        for row in 0..n {
1034            a_block[[row, 0]] = x[row];
1035            let z0 = sd0 * e0[row];
1036            let sd1 = (0.5 * (gamma[0] + gamma[1] * x[row])).exp();
1037            scores[[row, 0]] = z0;
1038            scores[[row, 1]] = (phi[0] + phi[1] * x[row]) * z0 + sd1 * e1[row];
1039        }
1040        (scores, Array1::<f64>::ones(n), a_block)
1041    }
1042
1043    /// The estimator must recover a truth drawn from its own model class:
1044    /// the coupling slope, the innovation-variance slope, and hence `Σ(a)`
1045    /// itself across the covariate range.
1046    #[test]
1047    fn recovers_an_in_class_conditional_covariance() {
1048        let n = 40_000;
1049        let phi = [0.3_f64, 0.5];
1050        let gamma = [(0.75_f64).ln(), -0.4];
1051        let log_d0 = 0.0;
1052        let (scores, weights, a_block) = in_class_fixture(n, phi, gamma, log_d0);
1053        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1054            .expect("fit")
1055            .expect("a varying cross-score covariance must escalate");
1056
1057        assert_eq!(fitted.score_dim, 2);
1058        assert_eq!(fitted.basis_ncols, 1);
1059        // Coordinate 1's coupling must be the planted affine function.
1060        let coupling = &fitted.coordinates[1].autoregression[0];
1061        assert_eq!(
1062            coupling.len(),
1063            2,
1064            "the coupling depends on a, so its own interaction gate must promote it"
1065        );
1066        assert!(
1067            (coupling[0] - phi[0]).abs() < 0.02 && (coupling[1] - phi[1]).abs() < 0.02,
1068            "coupling {coupling:?} against planted {phi:?}"
1069        );
1070        let innovation = &fitted.coordinates[1].log_innovation;
1071        assert_eq!(innovation.len(), 2, "the innovation variance depends on a");
1072        assert!(
1073            (innovation[0] - gamma[0]).abs() < 0.03 && (innovation[1] - gamma[1]).abs() < 0.03,
1074            "log innovation {innovation:?} against planted {gamma:?}"
1075        );
1076
1077        // And the assembled Σ(a) itself, against the closed-form truth.
1078        for &probe in &[-1.5_f64, -0.5, 0.0, 0.5, 1.5] {
1079            let a_row = Array1::from_vec(vec![probe]);
1080            let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1081            let phi_true = phi[0] + phi[1] * probe;
1082            let d0_true = log_d0.exp();
1083            let d1_true = (gamma[0] + gamma[1] * probe).exp();
1084            let truth = [
1085                d0_true,
1086                phi_true * d0_true,
1087                phi_true * phi_true * d0_true + d1_true,
1088            ];
1089            let got = [sigma[[0, 0]], sigma[[0, 1]], sigma[[1, 1]]];
1090            for (index, (&want, &have)) in truth.iter().zip(got.iter()).enumerate() {
1091                assert!(
1092                    (want - have).abs() < 0.05 * (1.0 + want.abs()),
1093                    "Σ(a={probe}) entry {index}: got {have}, want {want}"
1094                );
1095            }
1096            assert_eq!(sigma[[0, 1]], sigma[[1, 0]], "Σ(a) must be symmetric");
1097        }
1098        // The gate's own evidence must name the pair it fired on.
1099        assert!(
1100            fitted
1101                .pair_pvalues
1102                .iter()
1103                .any(|&(left, right, p)| left == 0 && right == 1 && p < 1.0e-3),
1104            "the (0,1) pair must be the recorded trigger: {:?}",
1105            fitted.pair_pvalues
1106        );
1107    }
1108
1109    /// The escalation gate must hold its SIZE. A trigger-happy gate would
1110    /// install a fitted covariance field on every multi-score fit, replacing an
1111    /// exactly-correct pooled object with an estimated one — a worse trade than
1112    /// the defect it exists to fix.
1113    ///
1114    /// Size, not one sample. The gate is a level-`AUTO_Z_CONDITIONAL_RAO_ALPHA`
1115    /// hypothesis test, so "it did not fire on this null sample" is an assertion
1116    /// about a `1 − α` event and a single unlucky draw would make it red for a
1117    /// reason that is not a defect. (It does happen: at `n = 40000` the first
1118    /// seed tried here drew `|Z| = 3.31`, `p = 9.2e-4`, just inside `α = 1e-3`.)
1119    /// What is actually claimed is the escalation RATE over a bank of null
1120    /// replicates, and the bound is derived rather than chosen: with
1121    /// `R = REPLICATES` draws at level `α`, the escalation count is
1122    /// `Binomial(R, α)`, and `MAX_NULL_ESCALATIONS` is the smallest `k` for
1123    /// which `P(Binomial(R, α) > k) < α` — i.e. the smallest bound that makes
1124    /// THIS test's own false-alarm rate no worse than the gate's. At `R = 32`,
1125    /// `α = 1e-3`: `P(X ≥ 1) = 3.2e-2` (too loose), `P(X ≥ 2) = 4.9e-4 < α`, so
1126    /// `k = 1`.
1127    #[test]
1128    fn the_escalation_gate_holds_its_size_on_null_replicates() {
1129        const REPLICATES: usize = 32;
1130        const MAX_NULL_ESCALATIONS: usize = 1;
1131        let n = 4_000;
1132        let mut escalations = Vec::new();
1133        for replicate in 0..REPLICATES {
1134            let (scores, weights, a_block) = null_fixture(n, replicate as u64);
1135            if ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1136                .expect("fit")
1137                .is_some()
1138            {
1139                escalations.push(replicate);
1140            }
1141        }
1142        assert!(
1143            escalations.len() <= MAX_NULL_ESCALATIONS,
1144            "the pair gate escalated on {} of {REPLICATES} null replicates (bound \
1145             {MAX_NULL_ESCALATIONS}): {escalations:?}",
1146            escalations.len()
1147        );
1148    }
1149
1150    /// The same fixture shape as [`in_class_fixture`] with every `a`-varying
1151    /// coefficient set to zero, so `Cov(z₀, z₁ | a)` is constant by
1152    /// construction, seeded per replicate.
1153    fn null_fixture(n: usize, replicate: u64) -> (Array2<f64>, Array1<f64>, Array2<f64>) {
1154        let base = 0x2766_0000_u64 + replicate * 3;
1155        let x = standardized(gaussians(n, base));
1156        let e0 = standardized(gaussians(n, base + 1));
1157        let e1 = standardized(gaussians(n, base + 2));
1158        let mut scores = Array2::<f64>::zeros((n, 2));
1159        let mut a_block = Array2::<f64>::zeros((n, 1));
1160        for row in 0..n {
1161            a_block[[row, 0]] = x[row];
1162            scores[[row, 0]] = e0[row];
1163            scores[[row, 1]] = 0.4 * e0[row] + (0.75_f64).sqrt() * e1[row];
1164        }
1165        (scores, Array1::<f64>::ones(n), a_block)
1166    }
1167
1168    /// `K = 1` has no off-diagonal, and its conditional variance is gam#2768's
1169    /// per-coordinate branch. Escalating here would double-correct it.
1170    #[test]
1171    fn a_single_score_is_out_of_scope() {
1172        let n = 4_000;
1173        let x = standardized(gaussians(n, 0x2766_D4));
1174        let e = standardized(gaussians(n, 0x2766_E5));
1175        let mut scores = Array2::<f64>::zeros((n, 1));
1176        let mut a_block = Array2::<f64>::zeros((n, 1));
1177        for row in 0..n {
1178            a_block[[row, 0]] = x[row];
1179            scores[[row, 0]] = (0.5 * x[row]).exp() * e[row];
1180        }
1181        let weights = Array1::<f64>::ones(n);
1182        assert!(
1183            ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1184                .expect("fit")
1185                .is_none(),
1186            "K = 1 is gam#2768's branch, not this one"
1187        );
1188    }
1189
1190    /// Positive definiteness is a property of the PARAMETERISATION, so it must
1191    /// survive rows the fit never saw — including rows far outside the training
1192    /// hull, where a model on the entries of `Σ` would return `|ρ| > 1`.
1193    #[test]
1194    fn the_factor_is_positive_definite_off_the_training_hull() {
1195        let n = 20_000;
1196        let (scores, weights, a_block) =
1197            in_class_fixture(n, [0.3, 0.9], [(0.5_f64).ln(), -0.8], 0.0);
1198        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1199            .expect("fit")
1200            .expect("escalates");
1201        for &probe in &[-1.0e6_f64, -50.0, -5.0, 0.0, 5.0, 50.0, 1.0e6] {
1202            let a_row = Array1::from_vec(vec![probe]);
1203            let mut factor = Array2::<f64>::zeros((2, 2));
1204            fitted.factor_into(a_row.view(), &mut factor).expect("L(a)");
1205            assert!(
1206                factor[[0, 1]] == 0.0,
1207                "L(a) must be lower triangular; got {factor:?}"
1208            );
1209            assert!(
1210                factor[[0, 0]] > 0.0 && factor[[1, 1]] > 0.0,
1211                "L(a) must have a strictly positive diagonal at a={probe}: {factor:?}"
1212            );
1213            let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1214            let determinant = sigma[[0, 0]] * sigma[[1, 1]] - sigma[[0, 1]] * sigma[[1, 0]];
1215            assert!(
1216                sigma[[0, 0]] > 0.0 && determinant > 0.0,
1217                "Σ(a={probe}) must be positive definite: {sigma:?} (det {determinant})"
1218            );
1219            let correlation = sigma[[0, 1]] / (sigma[[0, 0]] * sigma[[1, 1]]).sqrt();
1220            assert!(
1221                correlation.abs() < 1.0,
1222                "an admissible Σ cannot have |corr| >= 1; got {correlation} at a={probe}"
1223            );
1224        }
1225    }
1226
1227    /// The materialised per-row stack must be the same object `dense_at`
1228    /// describes, in the `Σ = L Lᵀ` representation whose quadratic forms the row
1229    /// program evaluates as exact sums of squares.
1230    #[test]
1231    fn the_row_stack_agrees_with_the_dense_evaluation() {
1232        let n = 4_000;
1233        let (scores, weights, a_block) =
1234            in_class_fixture(n, [0.2, 0.6], [(0.9_f64).ln(), 0.3], 0.1);
1235        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1236            .expect("fit")
1237            .expect("escalates");
1238        let stack = fitted.row_covariances(a_block.view()).expect("row stack");
1239        assert_eq!(stack.len(), n);
1240        for &row in &[0usize, 1, 17, n / 2, n - 1] {
1241            let dense = fitted.dense_at(a_block.row(row)).expect("Σ(a)");
1242            let from_stack = stack[row].to_dense();
1243            for left in 0..2 {
1244                for right in 0..2 {
1245                    assert!(
1246                        (dense[[left, right]] - from_stack[[left, right]]).abs() < 1.0e-12,
1247                        "row {row} entry ({left},{right}): {} vs {}",
1248                        dense[[left, right]],
1249                        from_stack[[left, right]]
1250                    );
1251                }
1252            }
1253            // `1ᵀΣ1` is the cached scalar the SHARED log-slope lane consumes; it
1254            // must be the same number the dense matrix implies.
1255            let ones_form: f64 = dense.iter().sum();
1256            assert!(
1257                (stack[row].ones_quadratic_form() - ones_form).abs()
1258                    < 1.0e-10 * (1.0 + ones_form.abs()),
1259                "row {row}: cached 1'Σ1 {} vs dense {ones_form}",
1260                stack[row].ones_quadratic_form()
1261            );
1262        }
1263    }
1264
1265    /// Collinear scores are a real and expected input — `MarginalSlopeCovariance`
1266    /// says so in its own admission doc, and admits the exactly-singular pooled
1267    /// matrix they produce. The log parameterisation cannot represent a zero
1268    /// innovation variance, so this is the one place it needs a floor, and the
1269    /// floor must not turn a valid input into a refusal.
1270    ///
1271    /// The fixture makes the pair gate fire on a perfectly collinear pair:
1272    /// `z₁ = z₀` with `Var(z₀|a)` moving, so `Cov(z₀, z₁|a) = Var(z₀|a)` moves
1273    /// too. Before the floor this reached `fit_log_innovation` with an exactly
1274    /// zero residual variance and errored the whole fit.
1275    #[test]
1276    fn collinear_scores_are_admitted_rather_than_refused() {
1277        let n = 20_000;
1278        let x = standardized(gaussians(n, 0x2766_5E));
1279        let e0 = standardized(gaussians(n, 0x2766_6F));
1280        let mut scores = Array2::<f64>::zeros((n, 2));
1281        let mut a_block = Array2::<f64>::zeros((n, 1));
1282        for row in 0..n {
1283            a_block[[row, 0]] = x[row];
1284            let z0 = (0.5 * (0.6 * x[row])).exp() * e0[row];
1285            scores[[row, 0]] = z0;
1286            scores[[row, 1]] = z0;
1287        }
1288        let weights = Array1::<f64>::ones(n);
1289        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1290            .expect("a collinear score pair must be admitted, not refused")
1291            .expect("a moving Var(z₀|a) makes Cov(z₀,z₁|a) move, so the pair gate fires");
1292        for &probe in &[-1.5_f64, 0.0, 1.5] {
1293            let a_row = Array1::from_vec(vec![probe]);
1294            let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1295            let determinant = sigma[[0, 0]] * sigma[[1, 1]] - sigma[[0, 1]] * sigma[[1, 0]];
1296            assert!(
1297                sigma[[0, 0]] > 0.0 && determinant > 0.0,
1298                "Σ(a={probe}) must stay positive definite on a collinear pair: {sigma:?}"
1299            );
1300            let correlation = sigma[[0, 1]] / (sigma[[0, 0]] * sigma[[1, 1]]).sqrt();
1301            assert!(
1302                (correlation - 1.0).abs() < 1.0e-6,
1303                "a collinear pair must read as correlation 1; got {correlation} at a={probe}"
1304            );
1305            // And the variance itself must track the planted `exp(0.6·x)`.
1306            let planted = (0.6 * probe).exp();
1307            assert!(
1308                (sigma[[0, 0]] - planted).abs() < 0.1 * (1.0 + planted),
1309                "Σ₀₀(a={probe}) = {} against planted {planted}",
1310                sigma[[0, 0]]
1311            );
1312        }
1313    }
1314
1315    /// Three scores, so the triangular reconstruction is exercised past the
1316    /// `K = 2` special case where `T⁻¹` has no accumulated term.
1317    #[test]
1318    fn three_scores_reconstruct_through_the_forward_substitution() {
1319        let n = 30_000;
1320        let x = standardized(gaussians(n, 0x2766_1A));
1321        let e0 = standardized(gaussians(n, 0x2766_2B));
1322        let e1 = standardized(gaussians(n, 0x2766_3C));
1323        let e2 = standardized(gaussians(n, 0x2766_4D));
1324        let mut scores = Array2::<f64>::zeros((n, 3));
1325        let mut a_block = Array2::<f64>::zeros((n, 1));
1326        // φ₁₀(x) = 0.4 + 0.3x,  φ₂₀ = 0.2 (constant),  φ₂₁(x) = −0.1 + 0.45x.
1327        for row in 0..n {
1328            let xi = x[row];
1329            a_block[[row, 0]] = xi;
1330            let z0 = e0[row];
1331            let z1 = (0.4 + 0.3 * xi) * z0 + 0.8 * e1[row];
1332            let z2 = 0.2 * z0 + (-0.1 + 0.45 * xi) * z1 + 0.7 * e2[row];
1333            scores[[row, 0]] = z0;
1334            scores[[row, 1]] = z1;
1335            scores[[row, 2]] = z2;
1336        }
1337        let weights = Array1::<f64>::ones(n);
1338        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1339            .expect("fit")
1340            .expect("escalates");
1341        assert_eq!(fitted.coordinates.len(), 3);
1342        assert_eq!(fitted.coordinates[2].autoregression.len(), 2);
1343        for &probe in &[-1.0_f64, 0.0, 1.0] {
1344            let a_row = Array1::from_vec(vec![probe]);
1345            let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1346            // Closed-form truth by the same forward recursion.
1347            let phi10 = 0.4 + 0.3 * probe;
1348            let phi20 = 0.2_f64;
1349            let phi21 = -0.1 + 0.45 * probe;
1350            let (d0, d1, d2) = (1.0_f64, 0.64_f64, 0.49_f64);
1351            let s00 = d0;
1352            let s01 = phi10 * d0;
1353            let s11 = phi10 * phi10 * d0 + d1;
1354            let s02 = phi20 * d0 + phi21 * s01;
1355            let s12 = phi20 * s01 + phi21 * s11;
1356            let s22 = phi20 * s02 + phi21 * s12 + d2;
1357            let truth = [s00, s01, s02, s11, s12, s22];
1358            let got = [
1359                sigma[[0, 0]],
1360                sigma[[0, 1]],
1361                sigma[[0, 2]],
1362                sigma[[1, 1]],
1363                sigma[[1, 2]],
1364                sigma[[2, 2]],
1365            ];
1366            for (index, (&want, &have)) in truth.iter().zip(got.iter()).enumerate() {
1367                assert!(
1368                    (want - have).abs() < 0.06 * (1.0 + want.abs()),
1369                    "K=3 Σ(a={probe}) entry {index}: got {have}, want {want}"
1370                );
1371            }
1372        }
1373    }
1374
1375    /// An INDEPENDENT oracle: the fitted `Σ(a)` against a nonparametric local
1376    /// estimate.
1377    ///
1378    /// Every other recovery test here compares the fit to the coefficients it
1379    /// was generated from, which is a comparison inside the model's own
1380    /// parameterisation. This one is not: it bins the rows by the conditioning
1381    /// covariate, takes the ordinary empirical second moments inside each bin,
1382    /// and asks the fitted surface to reproduce them. It would catch a
1383    /// parameterisation that recovers its own planted coefficients and still
1384    /// assembles the wrong matrix — a forward-substitution transposition, a
1385    /// column scaled by `d` instead of `√d`, a coordinate order swapped.
1386    ///
1387    /// The truth is IN the model class here, deliberately: the bar is each bin's
1388    /// own sampling band, so any approximation error would be read as a defect.
1389    /// How the model behaves when the truth is out of class is a different
1390    /// question and is measured where it belongs — on the end-to-end identity,
1391    /// in `survival_multi_z_conditional_covariance_2766`, against a planted
1392    /// sigmoid.
1393    #[test]
1394    fn the_fitted_surface_matches_a_nonparametric_local_covariance() {
1395        const BINS: usize = 8;
1396        let n = 40_000;
1397        let phi = [0.15_f64, 0.42];
1398        let gamma = [(0.7_f64).ln(), -0.3];
1399        let (scores, weights, a_block) = in_class_fixture(n, phi, gamma, 0.0);
1400        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1401            .expect("fit")
1402            .expect("escalates");
1403
1404        // Bin on the conditioning covariate's own rank, so every bin holds the
1405        // same number of rows and the band below is the same in each.
1406        let mut order: Vec<usize> = (0..n).collect();
1407        order.sort_by(|&left, &right| {
1408            a_block[[left, 0]]
1409                .partial_cmp(&a_block[[right, 0]])
1410                .expect("the fixture covariate is finite, so the ordering is total")
1411        });
1412        let mut bin = vec![0usize; n];
1413        for (rank, &row) in order.iter().enumerate() {
1414            bin[row] = (rank * BINS / n).min(BINS - 1);
1415        }
1416
1417        let mut counts = vec![0usize; BINS];
1418        let mut empirical = vec![[0.0_f64; 3]; BINS];
1419        let mut modelled = vec![[0.0_f64; 3]; BINS];
1420        for row in 0..n {
1421            let index = bin[row];
1422            counts[index] += 1;
1423            let (left, right) = (scores[[row, 0]], scores[[row, 1]]);
1424            empirical[index][0] += left * left;
1425            empirical[index][1] += left * right;
1426            empirical[index][2] += right * right;
1427            let sigma = fitted.dense_at(a_block.row(row)).expect("Σ(a)");
1428            modelled[index][0] += sigma[[0, 0]];
1429            modelled[index][1] += sigma[[0, 1]];
1430            modelled[index][2] += sigma[[1, 1]];
1431        }
1432        let mut worst = 0.0_f64;
1433        let mut worst_label = String::new();
1434        for index in 0..BINS {
1435            let scale = counts[index] as f64;
1436            // A second moment of Gaussians has sampling standard error
1437            // `moment·√(2/n_bin)`; three of those is the band a correct surface
1438            // sits inside, and it is derived from the bin rather than chosen.
1439            for entry in 0..3 {
1440                let want = empirical[index][entry] / scale;
1441                let have = modelled[index][entry] / scale;
1442                let reference = (empirical[index][0] / scale).max(empirical[index][2] / scale);
1443                let band = 3.0 * reference * (2.0 / scale).sqrt();
1444                let miss = (want - have).abs() / band;
1445                if miss > worst {
1446                    worst = miss;
1447                    worst_label = format!(
1448                        "bin {index} (n={}) entry {entry}: empirical {want:.4} against fitted \
1449                         {have:.4}, band {band:.4}",
1450                        counts[index]
1451                    );
1452                }
1453            }
1454        }
1455        assert!(
1456            worst <= 1.0,
1457            "the fitted surface must sit inside each bin's own sampling band; worst {worst:.2}x \
1458             at {worst_label}"
1459        );
1460    }
1461
1462    /// A fitted field has to survive the on-disk round trip unchanged: the
1463    /// predict path rebuilds `Σ(a)` from exactly these coefficients, so a
1464    /// serialisation that dropped a stage would silently evaluate a different
1465    /// model.
1466    #[test]
1467    fn the_fitted_field_round_trips_through_serde() {
1468        let n = 8_000;
1469        let (scores, weights, a_block) =
1470            in_class_fixture(n, [0.25, 0.55], [(0.8_f64).ln(), -0.35], 0.0);
1471        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1472            .expect("fit")
1473            .expect("escalates");
1474        let encoded = serde_json::to_string(&fitted).expect("encode");
1475        let decoded: ConditionalScoreCovariance = serde_json::from_str(&encoded).expect("decode");
1476        assert_eq!(decoded, fitted);
1477        let a_row = Array1::from_vec(vec![0.37]);
1478        let before = fitted.dense_at(a_row.view()).expect("Σ before");
1479        let after = decoded.dense_at(a_row.view()).expect("Σ after");
1480        assert_eq!(before, after);
1481    }
1482
1483    /// Against the object it replaces: averaged over the training rows, the
1484    /// conditional field must reproduce the pooled covariance. `Σ̄ = E[Σ(a)] +
1485    /// Var(E[z|a])`, and the conditional mean is zero here, so the two agree —
1486    /// which is the sense in which this is a refinement of the pooled estimator
1487    /// and not a different quantity.
1488    #[test]
1489    fn the_row_average_reproduces_the_pooled_covariance() {
1490        let n = 40_000;
1491        let (scores, weights, a_block) =
1492            in_class_fixture(n, [0.3, 0.5], [(0.75_f64).ln(), -0.4], 0.0);
1493        let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1494            .expect("fit")
1495            .expect("escalates");
1496        let pooled = super::super::marginal_slope_covariance_from_scores(scores.view(), &weights)
1497            .expect("pooled Σ")
1498            .to_dense();
1499        let stack = fitted.row_covariances(a_block.view()).expect("stack");
1500        let mut average = Array2::<f64>::zeros((2, 2));
1501        for covariance in &stack {
1502            average += &covariance.to_dense();
1503        }
1504        average /= n as f64;
1505        for left in 0..2 {
1506            for right in 0..2 {
1507                let want = pooled[[left, right]];
1508                let have = average[[left, right]];
1509                assert!(
1510                    (want - have).abs() < 0.05 * (1.0 + want.abs()),
1511                    "row-averaged Σ({left},{right}) = {have} against pooled {want}"
1512                );
1513            }
1514        }
1515    }
1516}