Skip to main content

gam_solve/
gaussian_reml.rs

1use crate::estimate::EstimationError;
2use faer::Side;
3use gam_linalg::faer_ndarray::{
4    FaerCholesky, FaerEigh, fast_ab, fast_atb, fast_xt_diag_x, fast_xt_diag_y,
5};
6use ndarray::{
7    Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, ArrayViewMut1, ArrayViewMut2, Axis,
8    s,
9};
10use opt::{RidgeSchedule, escalate_ridge};
11use rayon::prelude::*;
12use std::sync::Once;
13
14/// One-time warning latch for backward-pass graceful degradation on a
15/// near-singular penalized Hessian `K = XᵀWX + λS`. When `λ_k` saturates
16/// (e.g. 1e10+), `K` becomes effectively rank-deficient and the analytic VJP
17/// cannot be evaluated. Rather than raising, the backward returns zero
18/// gradients of the correct shape: this is the statistically correct
19/// "shrink-out" gradient — when `λ` has saturated, the atom is unused, so
20/// every input's contribution to the loss is zero in the limit.
21static ILL_CONDITIONED_BACKWARD_WARNED: Once = Once::new();
22
23fn warn_ill_conditioned_backward_once(p: usize, d: usize, condition_number: f64) {
24    ILL_CONDITIONED_BACKWARD_WARNED.call_once(|| {
25        log::warn!(
26            "gaussian_reml_fit_backward: K = XᵀWX + λS is near-singular \
27             (p={p}, d={d}, cond≈{condition_number:.2e}); returning zero gradients \
28             for this fit (λ has saturated, atom is effectively unused). \
29             Further occurrences are silent."
30        );
31    });
32}
33
34fn zero_backward_result(n: usize, p: usize, d: usize) -> GaussianRemlBackwardResult {
35    GaussianRemlBackwardResult {
36        grad_x: Array2::<f64>::zeros((n, p)),
37        grad_y: Array2::<f64>::zeros((n, d)),
38        grad_penalty: Array2::<f64>::zeros((p, p)),
39        grad_weights: Array1::<f64>::zeros(n),
40    }
41}
42
43const RHO_LOWER: f64 = -30.0;
44const RHO_UPPER: f64 = 30.0;
45const EIGEN_REL_TOL: f64 = 1.0e-10;
46const GRAD_TOL: f64 = 1.0e-12;
47const MIN_DEVIANCE: f64 = 1.0e-300;
48/// Relative first-order convergence certificate for the block-orthogonal
49/// alternation: the largest per-block |dV/drho|, normalized by the score's
50/// natural magnitude `d * max(1, rank)`, must fall below this and the analytic
51/// profiled Hessian must be PSD before a fit is minted. See
52/// `gaussian_reml_blocks_orthogonal_shared_scale`.
53const BLOCK_ORTHOGONAL_SCORE_TOL: f64 = 1.0e-7;
54/// Exhaustion-escalation bound on outer alternation passes. It never selects
55/// the estimator: reaching it without the score/curvature certificate is a typed
56/// `BlockOrthogonalRemlDidNotConverge` error carrying the rho checkpoint.
57const BLOCK_ORTHOGONAL_MAX_OUTER_PASSES: usize = 200;
58/// Work allocated to each one-dimensional block polish within an outer pass.
59/// This is not a convergence criterion: the joint analytic score below is the
60/// only condition that can mint a fit.
61const BLOCK_ORTHOGONAL_BLOCK_UPDATES_PER_PASS: usize = 32;
62
63#[derive(Clone, Copy)]
64struct BlockOrthogonalControls {
65    score_tol: f64,
66    max_outer_passes: usize,
67    block_updates_per_pass: usize,
68}
69
70impl Default for BlockOrthogonalControls {
71    fn default() -> Self {
72        Self {
73            score_tol: BLOCK_ORTHOGONAL_SCORE_TOL,
74            max_outer_passes: BLOCK_ORTHOGONAL_MAX_OUTER_PASSES,
75            block_updates_per_pass: BLOCK_ORTHOGONAL_BLOCK_UPDATES_PER_PASS,
76        }
77    }
78}
79
80/// Canonicalize a penalty matrix to its symmetric average.
81///
82/// Closed-form Gaussian REML treats `S` as symmetric throughout — the
83/// eigendecomposition, the pseudo-determinant `log|S|₊`, the rank detector,
84/// and every per-helper VJP all assume `S = Sᵀ`. To make that contract
85/// explicit (rather than implicit in `eigh(Side::Lower)` reading the lower
86/// triangle and silently ignoring the upper), every entry point that takes a
87/// penalty matrix replaces it with `0.5 (S + Sᵀ)` before any downstream use.
88/// For symmetric input this is a numerical no-op; for asymmetric input it
89/// defines the function as operating on the symmetric average.
90fn canonicalize_penalty(penalty: ArrayView2<'_, f64>) -> Array2<f64> {
91    let p = penalty.nrows();
92    let mut out = penalty.to_owned();
93    for i in 0..p {
94        for j in (i + 1)..p {
95            let avg = 0.5 * (out[[i, j]] + out[[j, i]]);
96            out[[i, j]] = avg;
97            out[[j, i]] = avg;
98        }
99    }
100    out
101}
102
103#[derive(Clone, Debug)]
104pub struct GaussianRemlEigenCache {
105    pub penalty_eigenvalues: Array1<f64>,
106    pub eigenvectors: Array2<f64>,
107    pub coefficient_basis: Array2<f64>,
108    pub xtwx_fingerprint: u64,
109    pub penalty_fingerprint: u64,
110    pub logdet_xtwx: f64,
111    pub logdet_penalty_positive: f64,
112    pub penalty_rank: usize,
113    pub nullity: usize,
114}
115
116#[derive(Clone, Debug, Default)]
117pub struct GaussianRemlWarmStart {
118    pub lambda: Option<f64>,
119    pub eigen_cache: Option<GaussianRemlEigenCache>,
120}
121
122impl GaussianRemlWarmStart {
123    pub fn from_multi_result(result: &GaussianRemlMultiResult) -> Self {
124        Self {
125            lambda: Some(result.lambda),
126            eigen_cache: Some(result.cache.clone()),
127        }
128    }
129}
130
131#[derive(Clone, Debug)]
132pub struct GaussianRemlResult {
133    pub lambda: f64,
134    pub rho: f64,
135    pub coefficients: Array1<f64>,
136    pub fitted: Array1<f64>,
137    pub reml_score: f64,
138    pub reml_grad_lambda: f64,
139    pub reml_hess_lambda: f64,
140    pub reml_grad_rho: f64,
141    pub reml_hess_rho: f64,
142    pub edf: f64,
143    pub sigma2: f64,
144    pub cache: GaussianRemlEigenCache,
145}
146
147#[derive(Clone, Debug)]
148pub struct GaussianRemlMultiResult {
149    pub lambda: f64,
150    pub rho: f64,
151    pub coefficients: Array2<f64>,
152    pub fitted: Array2<f64>,
153    pub reml_score: f64,
154    pub reml_grad_lambda: f64,
155    pub reml_hess_lambda: f64,
156    pub reml_grad_rho: f64,
157    pub reml_hess_rho: f64,
158    pub edf: f64,
159    pub sigma2: Array1<f64>,
160    pub cache: GaussianRemlEigenCache,
161}
162
163#[derive(Clone, Debug)]
164pub struct GaussianRemlFreeBScore {
165    pub reml_score: f64,
166    pub grad_coefficients: Array2<f64>,
167    pub grad_penalty: Array2<f64>,
168    pub grad_log_lambda: f64,
169    pub fitted: Array2<f64>,
170    pub sigma2: Array1<f64>,
171    pub edf: f64,
172}
173
174#[derive(Clone, Debug)]
175pub struct GaussianRemlBackwardResult {
176    pub grad_x: Array2<f64>,
177    pub grad_y: Array2<f64>,
178    pub grad_penalty: Array2<f64>,
179    pub grad_weights: Array1<f64>,
180}
181
182#[derive(Clone, Debug)]
183pub struct GaussianRemlMultiBackwardProblem<'a> {
184    pub x: ArrayView2<'a, f64>,
185    pub y: ArrayView2<'a, f64>,
186    pub weights: Option<ArrayView1<'a, f64>>,
187    pub fit: &'a GaussianRemlMultiResult,
188    pub grad_lambda: f64,
189    pub grad_coefficients: Option<ArrayView2<'a, f64>>,
190    pub grad_fitted: Option<ArrayView2<'a, f64>>,
191    pub grad_reml_score: f64,
192    pub grad_edf: f64,
193}
194
195#[derive(Clone, Debug)]
196pub struct GaussianRemlNoAllocWorkspace {
197    pub xtwy: Array2<f64>,
198    pub ywy: Array1<f64>,
199    pub projected_rhs: Array2<f64>,
200    pub projected_rhs_squared: Array2<f64>,
201    pub scaled_projected_rhs: Array2<f64>,
202}
203
204impl GaussianRemlNoAllocWorkspace {
205    pub fn new(n_coefficients: usize, n_outputs: usize) -> Self {
206        Self {
207            xtwy: Array2::zeros((n_coefficients, n_outputs)),
208            ywy: Array1::zeros(n_outputs),
209            projected_rhs: Array2::zeros((n_coefficients, n_outputs)),
210            projected_rhs_squared: Array2::zeros((n_coefficients, n_outputs)),
211            scaled_projected_rhs: Array2::zeros((n_coefficients, n_outputs)),
212        }
213    }
214
215    fn validate(&self, p: usize, d: usize) -> Result<(), EstimationError> {
216        if self.xtwy.dim() != (p, d)
217            || self.ywy.len() != d
218            || self.projected_rhs.dim() != (p, d)
219            || self.projected_rhs_squared.dim() != (p, d)
220            || self.scaled_projected_rhs.dim() != (p, d)
221        {
222            crate::bail_invalid_estim!(
223                "Gaussian REML no-alloc workspace shape mismatch: expected p={p}, d={d}"
224            );
225        }
226        Ok::<(), _>(())
227    }
228}
229
230#[derive(Clone, Copy, Debug)]
231pub struct GaussianRemlNoAllocFit {
232    pub lambda: f64,
233    pub rho: f64,
234    pub reml_score: f64,
235    pub reml_grad_lambda: f64,
236    pub reml_hess_lambda: f64,
237    pub reml_grad_rho: f64,
238    pub reml_hess_rho: f64,
239    pub edf: f64,
240}
241
242#[derive(Clone, Debug)]
243pub struct GaussianRemlMultiBatchProblem<'a> {
244    pub x: ArrayView2<'a, f64>,
245    pub y: ArrayView2<'a, f64>,
246    pub weights: Option<ArrayView1<'a, f64>>,
247    pub init_rho: Option<f64>,
248}
249
250#[derive(Clone, Debug)]
251pub struct GaussianRemlBlockOrthogonalResult {
252    pub coefficients: Vec<Array2<f64>>,
253    pub fitted: Array2<f64>,
254    pub lambdas: Array1<f64>,
255    pub log_lambdas: Array1<f64>,
256    pub reml_score: f64,
257    pub edf: Array1<f64>,
258}
259
260#[derive(Clone)]
261struct GaussianRemlPrepared {
262    cache: GaussianRemlEigenCache,
263    ywy: Array1<f64>,
264    projected_rhs_squared: Array2<f64>,
265    projected_rhs: Array2<f64>,
266    /// Number of rows with a strictly positive prior weight — the effective
267    /// sample size that enters the REML residual degrees of freedom `ν`. Rows
268    /// with weight `0` are excluded (see [`effective_observation_count`]).
269    n_effective: usize,
270    n_outputs: usize,
271}
272
273#[derive(Clone, Copy)]
274struct ObjectiveEval {
275    cost: f64,
276    grad: f64,
277    hess: f64,
278    edf: f64,
279}
280
281/// A single Gaussian closed-form REML objective term, carrying its analytic
282/// VALUE together with its analytic ρ-GRADIENT and ρ-HESSIAN.
283///
284/// Single source of truth: each term's value and its (already hand-derived,
285/// closed-form) ρ-derivatives are returned from ONE function body, so a future
286/// edit to the value formula cannot silently leave the derivatives stale.
287/// Mirrors the `PenaltyLogdetDerivs`-returning-tuple pattern used by the
288/// unified outer evaluator — the structural cure for the objective↔gradient
289/// desync class (#752/#748/#808). The three contributions are accumulated
290/// through [`ObjectiveEval`] at one site, so they cannot drift apart.
291#[derive(Clone, Copy)]
292struct TermDerivs {
293    value: f64,
294    grad: f64,
295    hess: f64,
296}
297
298/// Boundary-stable kernels for one nonnegative affine mode
299/// `t = exp(rho) * delta`.
300///
301/// Forming `t` first is numerically wrong at the finite rho boundaries: a
302/// large, finite `log(t) = rho + log(delta)` can overflow even though all four
303/// ratios below have finite limits.  Keeping the mode in log-space makes the
304/// objective and both derivatives regular at both smoothing boundaries.
305#[derive(Clone, Copy)]
306struct ModalKernels {
307    log_one_plus_t: f64,
308    /// `t / (1 + t)`.
309    u: f64,
310    /// `1 / (1 + t)`.
311    v: f64,
312    /// `t / (1 + t)^2 = u * v`.
313    w: f64,
314    /// `t(1 - t) / (1 + t)^3 = u * v * (v - u)`.
315    k: f64,
316}
317
318fn modal_kernels(rho: f64, delta: f64) -> ModalKernels {
319    if delta == 0.0 {
320        return ModalKernels {
321            log_one_plus_t: 0.0,
322            u: 0.0,
323            v: 1.0,
324            w: 0.0,
325            k: 0.0,
326        };
327    }
328    let log_t = rho + delta.ln();
329    let (log_one_plus_t, u, v) = if log_t >= 0.0 {
330        let reciprocal_t = (-log_t).exp();
331        let v = reciprocal_t / (1.0 + reciprocal_t);
332        (log_t + reciprocal_t.ln_1p(), 1.0 - v, v)
333    } else {
334        let t = log_t.exp();
335        let u = t / (1.0 + t);
336        (t.ln_1p(), u, 1.0 - u)
337    };
338    let w = u * v;
339    ModalKernels {
340        log_one_plus_t,
341        u,
342        v,
343        w,
344        k: w * (v - u),
345    }
346}
347
348impl std::ops::AddAssign<TermDerivs> for ObjectiveEval {
349    /// Fold a term's `(value, grad, hess)` triple into the running totals in
350    /// lock-step, so value and derivative can never be added at separate sites.
351    fn add_assign(&mut self, rhs: TermDerivs) {
352        self.cost += rhs.value;
353        self.grad += rhs.grad;
354        self.hess += rhs.hess;
355    }
356}
357
358/// `½d·(log|H| − log|S|_+)` value with its analytic ρ-gradient/Hessian.
359///
360/// The penalty-eigenvalue sum produces all three quantities from the SAME
361/// `t = λδ` intermediates in one pass, so the value (`log|1+t|`) and its
362/// derivatives (`t/(1+t)`, `t/(1+t)²`) are single-sourced.
363fn gaussian_reml_logdet_term(
364    cache: &GaussianRemlEigenCache,
365    rho: f64,
366    n_outputs: f64,
367) -> (TermDerivs, f64) {
368    let mut logdet_h = cache.logdet_xtwx;
369    let mut trace_h = 0.0;
370    let mut trace_h_deriv = 0.0;
371    let mut edf = 0.0;
372    for &delta in &cache.penalty_eigenvalues {
373        let mode = modal_kernels(rho, delta);
374        logdet_h += mode.log_one_plus_t;
375        if delta > 0.0 {
376            trace_h += mode.u;
377            trace_h_deriv += mode.w;
378        }
379        edf += mode.v;
380    }
381    let logdet_s = cache.logdet_penalty_positive + (cache.penalty_rank as f64) * rho;
382    let term = TermDerivs {
383        value: 0.5 * n_outputs * (logdet_h - logdet_s),
384        grad: 0.5 * n_outputs * (trace_h - cache.penalty_rank as f64),
385        hess: 0.5 * n_outputs * trace_h_deriv,
386    };
387    (term, edf)
388}
389
390/// Per-output dispersion-prior term `½ν·(1 + log(2π·dp/ν))` with its analytic
391/// ρ-gradient/Hessian.
392///
393/// `dp`, `dp_grad`, `dp_hess` are computed from the SAME eigenvalue sum, then
394/// the value `log(dp)` and its derivatives `dp_grad/dp`,
395/// `dp_hess/dp − (dp_grad/dp)²` are returned together so they cannot desync.
396fn gaussian_reml_dispersion_term(
397    cache: &GaussianRemlEigenCache,
398    ywy: ArrayView1<'_, f64>,
399    projected_rhs_squared: ArrayView2<'_, f64>,
400    output: usize,
401    nu: f64,
402    rho: f64,
403) -> TermDerivs {
404    let mut fitted_quadratic = 0.0;
405    let mut dp_grad = 0.0;
406    let mut dp_hess = 0.0;
407    for eig in 0..cache.penalty_eigenvalues.len() {
408        let c2 = projected_rhs_squared[[eig, output]];
409        let mode = modal_kernels(rho, cache.penalty_eigenvalues[eig]);
410        fitted_quadratic += c2 * mode.v;
411        dp_grad += c2 * mode.w;
412        dp_hess += c2 * mode.k;
413    }
414    let dp = ywy[output] - fitted_quadratic;
415    TermDerivs {
416        value: 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp / nu).ln()),
417        grad: 0.5 * nu * dp_grad / dp,
418        hess: 0.5 * nu * (dp_hess / dp - (dp_grad * dp_grad) / (dp * dp)),
419    }
420}
421
422pub fn gaussian_reml_closed_form(
423    x: ArrayView2<'_, f64>,
424    y: ArrayView1<'_, f64>,
425    penalty: ArrayView2<'_, f64>,
426    weights: Option<ArrayView1<'_, f64>>,
427    init_rho: Option<f64>,
428) -> Result<GaussianRemlResult, EstimationError> {
429    gaussian_reml_closed_form_with_nullspace_dim(x, y, penalty, None, weights, init_rho)
430}
431
432pub fn gaussian_reml_closed_form_with_nullspace_dim(
433    x: ArrayView2<'_, f64>,
434    y: ArrayView1<'_, f64>,
435    penalty: ArrayView2<'_, f64>,
436    nullspace_dim: Option<usize>,
437    weights: Option<ArrayView1<'_, f64>>,
438    init_rho: Option<f64>,
439) -> Result<GaussianRemlResult, EstimationError> {
440    let y2 = y.insert_axis(Axis(1));
441    let result = gaussian_reml_multi_closed_form_with_nullspace_dim(
442        x,
443        y2,
444        penalty,
445        nullspace_dim,
446        weights,
447        init_rho,
448    )?;
449    scalar_result_from_multi(result)
450}
451
452fn scalar_result_from_multi(
453    result: GaussianRemlMultiResult,
454) -> Result<GaussianRemlResult, EstimationError> {
455    Ok(GaussianRemlResult {
456        lambda: result.lambda,
457        rho: result.rho,
458        coefficients: result.coefficients.column(0).to_owned(),
459        fitted: result.fitted.column(0).to_owned(),
460        reml_score: result.reml_score,
461        reml_grad_lambda: result.reml_grad_lambda,
462        reml_hess_lambda: result.reml_hess_lambda,
463        reml_grad_rho: result.reml_grad_rho,
464        reml_hess_rho: result.reml_hess_rho,
465        edf: result.edf,
466        sigma2: result.sigma2[0],
467        cache: result.cache,
468    })
469}
470
471/// Point evaluation of the closed-form Gaussian REML objective at a FIXED
472/// log-smoothing parameter, with no optimization. Exposes the same REML score,
473/// effective df, σ², and posterior-mean coefficients the optimizer sees at that
474/// `rho`, so callers can trace the REML score surface as a function of `rho`
475/// (e.g. to audit λ-selection against a reference tool).
476#[derive(Clone, Debug)]
477pub struct GaussianRemlPointEval {
478    pub rho: f64,
479    pub lambda: f64,
480    pub reml_score: f64,
481    pub edf: f64,
482    pub sigma2: f64,
483    pub coefficients: Array1<f64>,
484}
485
486/// Evaluate the scalar closed-form Gaussian REML objective at a fixed `rho`
487/// (`= ln λ`). This is the analytic score/edf/σ²/β the profiled search sees at
488/// that point; it performs no search. Diagnostic surface for cross-tool
489/// λ-selection audits — the production optimizer is unchanged.
490pub fn gaussian_reml_point_eval_at_rho(
491    x: ArrayView2<'_, f64>,
492    y: ArrayView1<'_, f64>,
493    penalty: ArrayView2<'_, f64>,
494    nullspace_dim: Option<usize>,
495    weights: Option<ArrayView1<'_, f64>>,
496    rho: f64,
497) -> Result<GaussianRemlPointEval, EstimationError> {
498    let lambda = gam_problem::checked_exp_log_strength(rho)
499        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
500    let y2 = y.insert_axis(Axis(1));
501    let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
502    validate_reml_profile_residuals(
503        &prepared.cache,
504        prepared.ywy.view(),
505        prepared.projected_rhs_squared.view(),
506        rho,
507    )?;
508    let eval = prepared.evaluate(rho);
509    let coefficients = prepared.coefficients(lambda).column(0).to_owned();
510    let sigma2 = prepared.sigma2(lambda)[0];
511    Ok(GaussianRemlPointEval {
512        rho,
513        lambda,
514        reml_score: eval.cost,
515        edf: eval.edf,
516        sigma2,
517        coefficients,
518    })
519}
520
521/// Successful finite-window certificate for the profiled Gaussian REML
522/// ρ-objective.
523///
524/// `roots` contains one representative from every stationary bracket isolated
525/// on `rho_window`; `root_brackets` records those location certificates and
526/// `root_gradients` makes their numerical residuals directly auditable. The
527/// selected ρ is the lowest evaluated representative or boundary, and
528/// `selected_projected_gradient_residual` is the box-KKT residual at that
529/// selection. A search cell whose stationary structure remains ambiguous at
530/// `root_location_resolution` is not represented by a flag in a successful
531/// value: the search returns [`EstimationError::RemlDidNotConverge`] instead.
532#[derive(Clone, Debug)]
533pub struct GaussianRemlStationarySet {
534    pub roots: Vec<f64>,
535    pub root_brackets: Vec<[f64; 2]>,
536    pub root_gradients: Vec<f64>,
537    pub selected_rho: f64,
538    pub selected_projected_gradient_residual: f64,
539    pub endpoint_costs: [f64; 2],
540    pub rho_window: [f64; 2],
541    pub root_location_resolution: f64,
542}
543
544/// Enumerate the closed-form Gaussian REML stationary set at the given design,
545/// exposing the [`GaussianRemlStationarySet`] certificate. Thin wrapper over the
546/// shared enumeration used by the production optimizer — added beside
547/// [`gaussian_reml_point_eval_at_rho`] rather than changing any existing public
548/// signature.
549pub fn gaussian_reml_stationary_set(
550    x: ArrayView2<'_, f64>,
551    y: ArrayView1<'_, f64>,
552    penalty: ArrayView2<'_, f64>,
553    nullspace_dim: Option<usize>,
554    weights: Option<ArrayView1<'_, f64>>,
555    init_rho: Option<f64>,
556) -> Result<GaussianRemlStationarySet, EstimationError> {
557    if init_rho.is_some_and(|rho| !rho.is_finite()) {
558        crate::bail_invalid_estim!("Gaussian REML stationary search requires a finite rho hint");
559    }
560    let y2 = y.insert_axis(Axis(1));
561    let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
562    let endpoint_costs = [
563        prepared.evaluate(RHO_LOWER).cost,
564        prepared.evaluate(RHO_UPPER).cost,
565    ];
566    validate_reml_profile_residuals(
567        &prepared.cache,
568        prepared.ywy.view(),
569        prepared.projected_rhs_squared.view(),
570        RHO_LOWER,
571    )?;
572    if prepared.cache.penalty_rank == 0 {
573        return Ok(GaussianRemlStationarySet {
574            roots: Vec::new(),
575            root_brackets: Vec::new(),
576            root_gradients: Vec::new(),
577            selected_rho: init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER),
578            selected_projected_gradient_residual: 0.0,
579            endpoint_costs,
580            rho_window: [RHO_LOWER, RHO_UPPER],
581            root_location_resolution: RHO_BRACKET_RESOLUTION,
582        });
583    }
584    let eval = |rho: f64| prepared.evaluate(rho);
585    let enclose = |a: f64, b: f64| {
586        reml_deriv_enclosure(
587            &prepared.cache,
588            prepared.ywy.view(),
589            prepared.projected_rhs_squared.view(),
590            prepared.n_effective,
591            prepared.n_outputs,
592            a,
593            b,
594        )
595    };
596    let mut roots = Vec::new();
597    let mut root_brackets = Vec::new();
598    let mut root_gradients = Vec::new();
599    let selection = enumerate_and_select_rho(&eval, &enclose, init_rho, |root, e| {
600        roots.push(root.rho);
601        root_brackets.push(root.bracket);
602        root_gradients.push(e.grad);
603    })?;
604    Ok(GaussianRemlStationarySet {
605        roots,
606        root_brackets,
607        root_gradients,
608        selected_rho: selection.rho,
609        selected_projected_gradient_residual: selection.projected_gradient_residual,
610        endpoint_costs,
611        rho_window: [RHO_LOWER, RHO_UPPER],
612        root_location_resolution: RHO_BRACKET_RESOLUTION,
613    })
614}
615
616pub fn gaussian_reml_multi_closed_form(
617    x: ArrayView2<'_, f64>,
618    y: ArrayView2<'_, f64>,
619    penalty: ArrayView2<'_, f64>,
620    weights: Option<ArrayView1<'_, f64>>,
621    init_rho: Option<f64>,
622) -> Result<GaussianRemlMultiResult, EstimationError> {
623    gaussian_reml_multi_closed_form_with_nullspace_dim(x, y, penalty, None, weights, init_rho)
624}
625
626/// Closed-form multi-response Gaussian REML with one SHARED dispersion across
627/// all response columns.
628///
629/// This is the appropriate likelihood when the columns are coordinates of one
630/// vector-valued observation rather than unrelated responses with independently
631/// estimable noise scales.  The coefficient matrix and smoothing parameter are
632/// still shared exactly as in [`gaussian_reml_multi_closed_form`], but the
633/// profiled deviance is pooled before taking its logarithm:
634///
635/// `dp = sum_j dp_j`, `nu = d * (n_eff - nullity)`.
636///
637/// Pooling is essential for coordinate-chart races.  A chart made from a linear
638/// projection of the response reconstructs those projection axes tautologically;
639/// independently profiling each output dispersion lets one exact axis drive its
640/// variance to zero and dominate evidence even when another ambient direction is
641/// badly missed.  A shared ambient dispersion scores the reconstruction of the
642/// vector as one object and cannot be gamed by that coordinate leakage.
643pub fn gaussian_reml_multi_shared_dispersion_closed_form(
644    x: ArrayView2<'_, f64>,
645    y: ArrayView2<'_, f64>,
646    penalty: ArrayView2<'_, f64>,
647    weights: Option<ArrayView1<'_, f64>>,
648    init_rho: Option<f64>,
649) -> Result<GaussianRemlMultiResult, EstimationError> {
650    if y.ncols() == 0 {
651        crate::bail_invalid_estim!(
652            "shared-dispersion Gaussian REML requires at least one response column"
653        );
654    }
655    let prepared = prepare_gaussian_reml(x, y, penalty, None, weights, None)?;
656    let init_rho = init_rho
657        .map(f64::exp)
658        .map(validate_initial_lambda)
659        .transpose()?
660        .map(f64::ln);
661    let d = prepared.n_outputs;
662    let mut pooled_ywy = Array1::<f64>::zeros(1);
663    pooled_ywy[0] = prepared.ywy.iter().copied().sum();
664    let mut pooled_projected_rhs_squared =
665        Array2::<f64>::zeros((prepared.cache.penalty_eigenvalues.len(), 1));
666    for eig in 0..prepared.cache.penalty_eigenvalues.len() {
667        pooled_projected_rhs_squared[[eig, 0]] = prepared
668            .projected_rhs_squared
669            .row(eig)
670            .iter()
671            .copied()
672            .sum();
673    }
674    let per_output_nu = prepared.n_effective as f64 - prepared.cache.nullity as f64;
675    let shared_nu = (d as f64) * per_output_nu;
676    validate_reml_profile_residuals(
677        &prepared.cache,
678        pooled_ywy.view(),
679        pooled_projected_rhs_squared.view(),
680        RHO_LOWER,
681    )?;
682    let eval = |rho: f64| {
683        evaluate_reml_profile(
684            &prepared.cache,
685            pooled_ywy.view(),
686            pooled_projected_rhs_squared.view(),
687            d,
688            shared_nu,
689            rho,
690        )
691    };
692    let rho = if prepared.cache.penalty_rank == 0 {
693        init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER)
694    } else {
695        let enclose = |a: f64, b: f64| {
696            reml_deriv_enclosure_profile(
697                &prepared.cache,
698                pooled_ywy.view(),
699                pooled_projected_rhs_squared.view(),
700                d,
701                shared_nu,
702                a,
703                b,
704            )
705        };
706        enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho
707    };
708    let objective = eval(rho);
709    let lambda = gam_problem::checked_exp_log_strength(rho)
710        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
711    let coefficients = prepared.coefficients(lambda);
712    let fitted = dense_ab(x, coefficients.view());
713    let mut fitted_quadratic = 0.0_f64;
714    for eig in 0..prepared.cache.penalty_eigenvalues.len() {
715        let denom = 1.0 + lambda * prepared.cache.penalty_eigenvalues[eig];
716        fitted_quadratic += pooled_projected_rhs_squared[[eig, 0]] / denom;
717    }
718    let shared_sigma2 = (pooled_ywy[0] - fitted_quadratic) / shared_nu;
719    let (reml_grad_lambda, reml_hess_lambda) =
720        rho_derivatives_to_lambda(lambda, objective.grad, objective.hess);
721    Ok(GaussianRemlMultiResult {
722        lambda,
723        rho,
724        coefficients,
725        fitted,
726        reml_score: objective.cost,
727        reml_grad_lambda,
728        reml_hess_lambda,
729        reml_grad_rho: objective.grad,
730        reml_hess_rho: objective.hess,
731        edf: objective.edf,
732        sigma2: Array1::from_elem(d, shared_sigma2),
733        cache: prepared.cache,
734    })
735}
736
737pub fn gaussian_reml_multi_closed_form_with_nullspace_dim(
738    x: ArrayView2<'_, f64>,
739    y: ArrayView2<'_, f64>,
740    penalty: ArrayView2<'_, f64>,
741    nullspace_dim: Option<usize>,
742    weights: Option<ArrayView1<'_, f64>>,
743    init_rho: Option<f64>,
744) -> Result<GaussianRemlMultiResult, EstimationError> {
745    let init_lambda = init_rho.map(f64::exp);
746    gaussian_reml_multi_closed_form_from_parts(
747        x,
748        y,
749        penalty,
750        nullspace_dim,
751        weights,
752        init_lambda,
753        None,
754    )
755}
756
757pub fn gaussian_reml_multi_closed_form_warm_started(
758    x: ArrayView2<'_, f64>,
759    y: ArrayView2<'_, f64>,
760    penalty: ArrayView2<'_, f64>,
761    weights: Option<ArrayView1<'_, f64>>,
762    warm_start: Option<&GaussianRemlWarmStart>,
763) -> Result<GaussianRemlMultiResult, EstimationError> {
764    gaussian_reml_multi_closed_form_warm_started_with_nullspace_dim(
765        x, y, penalty, None, weights, warm_start,
766    )
767}
768
769pub fn gaussian_reml_multi_closed_form_warm_started_with_nullspace_dim(
770    x: ArrayView2<'_, f64>,
771    y: ArrayView2<'_, f64>,
772    penalty: ArrayView2<'_, f64>,
773    nullspace_dim: Option<usize>,
774    weights: Option<ArrayView1<'_, f64>>,
775    warm_start: Option<&GaussianRemlWarmStart>,
776) -> Result<GaussianRemlMultiResult, EstimationError> {
777    let init_lambda = warm_start.and_then(|start| start.lambda);
778    let eigen_cache = warm_start.and_then(|start| start.eigen_cache.as_ref());
779    gaussian_reml_multi_closed_form_from_parts(
780        x,
781        y,
782        penalty,
783        nullspace_dim,
784        weights,
785        init_lambda,
786        eigen_cache,
787    )
788}
789
790pub fn gaussian_reml_multi_closed_form_with_cache(
791    x: ArrayView2<'_, f64>,
792    y: ArrayView2<'_, f64>,
793    penalty: ArrayView2<'_, f64>,
794    weights: Option<ArrayView1<'_, f64>>,
795    init_lambda: Option<f64>,
796    eigen_cache: Option<&GaussianRemlEigenCache>,
797) -> Result<GaussianRemlMultiResult, EstimationError> {
798    gaussian_reml_multi_closed_form_from_parts(
799        x,
800        y,
801        penalty,
802        None,
803        weights,
804        init_lambda,
805        eigen_cache,
806    )
807}
808
809pub fn gaussian_reml_multi_closed_form_with_cache_no_alloc(
810    x: ArrayView2<'_, f64>,
811    y: ArrayView2<'_, f64>,
812    penalty: ArrayView2<'_, f64>,
813    weights: Option<ArrayView1<'_, f64>>,
814    init_lambda: Option<f64>,
815    eigen_cache: &GaussianRemlEigenCache,
816    workspace: &mut GaussianRemlNoAllocWorkspace,
817    mut coefficients: ArrayViewMut2<'_, f64>,
818    mut fitted: ArrayViewMut2<'_, f64>,
819    mut sigma2: ArrayViewMut1<'_, f64>,
820) -> Result<GaussianRemlNoAllocFit, EstimationError> {
821    // Match the symmetric-S contract used by the cache builder: the
822    // fingerprint check below compares against a fingerprint computed on the
823    // canonicalized penalty, so the input must be canonicalized first.
824    let penalty_owned = canonicalize_penalty(penalty);
825    let penalty = penalty_owned.view();
826    let n = x.nrows();
827    let p = x.ncols();
828    let d = y.ncols();
829    validate_gaussian_reml_design(x, penalty, weights)?;
830    validate_gaussian_reml_eigen_cache(eigen_cache, p)?;
831    if y.nrows() != n {
832        crate::bail_invalid_estim!(
833            "Gaussian REML row mismatch: X has {n} rows but Y has {}",
834            y.nrows()
835        );
836    }
837    if y.iter().any(|value| !value.is_finite()) {
838        crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
839    }
840    let n_effective = match weights {
841        Some(w) => effective_observation_count(w),
842        None => n,
843    };
844    if n_effective <= eigen_cache.nullity {
845        crate::bail_invalid_estim!(
846            "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
847            eigen_cache.nullity
848        );
849    }
850    let penalty_fingerprint = matrix_fingerprint(penalty);
851    if eigen_cache.penalty_fingerprint != penalty_fingerprint {
852        crate::bail_invalid_estim!("Gaussian REML eigen cache penalty mismatch");
853    }
854    workspace.validate(p, d)?;
855    if coefficients.dim() != (p, d) || fitted.dim() != (n, d) || sigma2.len() != d {
856        crate::bail_invalid_estim!(
857            "Gaussian REML no-alloc output shape mismatch: expected coefficients=({p},{d}), fitted=({n},{d}), sigma2={d}"
858        );
859    }
860    if let Some(lambda) = init_lambda {
861        validate_initial_lambda(lambda)?;
862    }
863
864    fill_weighted_rhs_no_alloc(x, y, weights, workspace)?;
865    project_rhs_no_alloc(eigen_cache, workspace);
866
867    let init_rho = init_lambda.map(f64::ln);
868    let rho = optimize_rho_no_alloc(
869        eigen_cache,
870        workspace.ywy.view(),
871        workspace.projected_rhs_squared.view(),
872        n_effective,
873        d,
874        init_rho,
875    )?;
876    let eval = evaluate_reml_parts(
877        eigen_cache,
878        workspace.ywy.view(),
879        workspace.projected_rhs_squared.view(),
880        n_effective,
881        d,
882        rho,
883    );
884    let lambda = gam_problem::checked_exp_log_strength(rho)
885        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
886    fill_coefficients_no_alloc(eigen_cache, workspace, lambda, coefficients.view_mut());
887    fill_fitted_no_alloc(x, coefficients.view(), fitted.view_mut());
888    fill_sigma2_no_alloc(
889        eigen_cache,
890        workspace.ywy.view(),
891        workspace.projected_rhs_squared.view(),
892        n_effective,
893        d,
894        lambda,
895        sigma2.view_mut(),
896    );
897    let (reml_grad_lambda, reml_hess_lambda) =
898        rho_derivatives_to_lambda(lambda, eval.grad, eval.hess);
899    Ok(GaussianRemlNoAllocFit {
900        lambda,
901        rho,
902        reml_score: eval.cost,
903        reml_grad_lambda,
904        reml_hess_lambda,
905        reml_grad_rho: eval.grad,
906        reml_hess_rho: eval.hess,
907        edf: eval.edf,
908    })
909}
910
911pub fn gaussian_reml_multi_closed_form_batch<'a>(
912    problems: &[GaussianRemlMultiBatchProblem<'a>],
913    penalty: ArrayView2<'a, f64>,
914    nullspace_dim: Option<usize>,
915) -> Result<Vec<GaussianRemlMultiResult>, EstimationError> {
916    if problems.is_empty() {
917        return Ok(Vec::new());
918    }
919    // Phase A: par_iter compute X'WX per problem (the only per-fit step that
920    // depends on `n_b`; remaining work is `O(p)` and can amortize through
921    // `_with_cache`).
922    let xtwx_per_problem: Vec<Array2<f64>> = problems
923        .par_iter()
924        .map(|problem| {
925            let weight = match problem.weights.as_ref() {
926                Some(w) => w.to_owned(),
927                None => Array1::ones(problem.x.nrows()),
928            };
929            dense_xt_diag_x(problem.x.view(), weight.view())
930        })
931        .collect();
932    // Phase B: one batched cuSOLVER Cholesky when policy approves uniform p
933    // and K aggregate FLOPs; otherwise the cache builder uses the normal
934    // per-fit non-GPU factorization path.
935    let caches =
936        build_gaussian_reml_eigen_cache_batched(xtwx_per_problem, penalty.view(), nullspace_dim);
937    // Phase C: par_iter finish each fit with its prebuilt cache. A cache-build
938    // error is a real per-problem error, not a signal to rebuild through a
939    // second path.
940    let fits: Vec<Result<GaussianRemlMultiResult, EstimationError>> = problems
941        .par_iter()
942        .zip(caches.into_par_iter())
943        .map(|(problem, cache_result)| {
944            let init_lambda = problem.init_rho.map(f64::exp);
945            let cache = cache_result?;
946            gaussian_reml_multi_closed_form_from_parts(
947                problem.x.view(),
948                problem.y.view(),
949                penalty.view(),
950                nullspace_dim,
951                problem.weights.as_ref().map(|weights| weights.view()),
952                init_lambda,
953                Some(&cache),
954            )
955        })
956        .collect();
957    fits.into_iter().collect()
958}
959
960struct BlockOrthogonalEval {
961    beta: Array2<f64>,
962    logdet: f64,
963    trace: f64,
964    trace_pair: f64,
965    fitted_energy: Array1<f64>,
966    penalty_energy: Array1<f64>,
967    curvature_energy: Array1<f64>,
968    edf: f64,
969}
970
971fn block_penalty_rank_logdet(
972    penalty: ArrayView2<'_, f64>,
973) -> Result<(usize, f64), EstimationError> {
974    let eigs = penalty
975        .to_owned()
976        .eigh(Side::Lower)
977        .map_err(|_| EstimationError::ModelIsIllConditioned {
978            condition_number: f64::INFINITY,
979        })?
980        .0;
981    let max_abs = eigs.iter().fold(0.0_f64, |m, &v| m.max(v.abs()));
982    let tol = (EIGEN_REL_TOL * max_abs).max(1.0e-14);
983    let mut rank = 0_usize;
984    let mut logdet = 0.0;
985    for eig in eigs.iter().copied() {
986        if eig > tol {
987            rank += 1;
988            logdet += eig.ln();
989        }
990    }
991    Ok((rank, logdet))
992}
993
994fn block_orthogonal_eval(
995    gram: &Array2<f64>,
996    rhs: &Array2<f64>,
997    penalty: &Array2<f64>,
998    rho: f64,
999) -> Result<BlockOrthogonalEval, EstimationError> {
1000    let lambda = gam_problem::checked_exp_log_strength(rho)
1001        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1002    validate_initial_lambda(lambda)?;
1003    let scaled_penalty = penalty * lambda;
1004    let hessian = canonicalize_penalty((gram + &scaled_penalty).view());
1005    let chol = gaussian_reml_cholesky_lower(hessian)?;
1006    let beta = solve_spd_from_lower_factor(&chol, rhs)?;
1007    let solved_penalty = solve_spd_from_lower_factor(&chol, &scaled_penalty)?;
1008    let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
1009    let trace = (0..solved_penalty.nrows())
1010        .map(|i| solved_penalty[[i, i]])
1011        .sum::<f64>();
1012    let trace_pair =
1013        gam_linalg::utils::trace_of_product(solved_penalty.view(), solved_penalty.view());
1014    let fitted_energy = (rhs * &beta).sum_axis(Axis(0));
1015    let p_beta = scaled_penalty.dot(&beta);
1016    let penalty_energy = (&beta * &p_beta).sum_axis(Axis(0));
1017    let solved_p_beta = solve_spd_from_lower_factor(&chol, &p_beta)?;
1018    let curvature_energy = (&p_beta * &solved_p_beta).sum_axis(Axis(0));
1019    Ok(BlockOrthogonalEval {
1020        beta,
1021        logdet,
1022        trace,
1023        trace_pair,
1024        fitted_energy,
1025        penalty_energy,
1026        curvature_energy,
1027        edf: penalty.nrows() as f64 - trace,
1028    })
1029}
1030
1031/// Block-orthogonal shared-scale REML objective VALUE together with its
1032/// analytic ρ-gradient and ρ-Hessian.
1033///
1034/// Single source of truth: the value `½d·logdet − ½·fit − ½d·rank·ρ` and its
1035/// ρ-derivatives are returned from ONE function body, so a future edit to the
1036/// objective cannot leave the Newton gradient/Hessian (previously written at a
1037/// physically separate site inside `solve_block_orthogonal_rho`) stale. This
1038/// closes a genuine `(value_here, gradient_there)` loose pair. Mirrors the
1039/// `PenaltyLogdetDerivs` single-source pattern; behavior is identical (the same
1040/// closed-form formulas, reorganized).
1041struct BlockOrthogonalScaleDerivs {
1042    value: f64,
1043    grad: f64,
1044    hess: f64,
1045}
1046
1047fn block_orthogonal_scale_objective(
1048    eval: &BlockOrthogonalEval,
1049    rho: f64,
1050    scale_precision: ArrayView1<'_, f64>,
1051    rank: usize,
1052) -> BlockOrthogonalScaleDerivs {
1053    let d = scale_precision.len() as f64;
1054    let fit_term = scale_precision
1055        .iter()
1056        .zip(eval.fitted_energy.iter())
1057        .map(|(scale, energy)| scale * energy)
1058        .sum::<f64>();
1059    // VALUE: ½d·log|H| − ½ Σ_o w_o ⟨y_o, fit_o⟩ − ½d·rank·ρ.
1060    let value = 0.5 * d * eval.logdet - 0.5 * fit_term - 0.5 * d * (rank as f64) * rho;
1061    // ρ-GRADIENT: d/dρ of the same scalar. The logdet term contributes
1062    // ½d·(tr(H⁻¹λS) − rank); the (data-independent-at-fixed-β envelope) fit term
1063    // contributes +½ Σ_o w_o βᵀ(λS)β. Both share `eval`'s cached energies.
1064    let grad = 0.5 * d * (eval.trace - rank as f64)
1065        + 0.5
1066            * scale_precision
1067                .iter()
1068                .zip(eval.penalty_energy.iter())
1069                .map(|(scale, energy)| scale * energy)
1070                .sum::<f64>();
1071    // ρ-HESSIAN: d²/dρ². Logdet term: ½d·(tr(H⁻¹λS) − tr((H⁻¹λS)²)); penalty
1072    // term: ½ Σ_o w_o (βᵀλSβ − 2 βᵀλS H⁻¹ λS β).
1073    let hess = 0.5 * d * (eval.trace - eval.trace_pair)
1074        + 0.5
1075            * scale_precision
1076                .iter()
1077                .zip(eval.penalty_energy.iter().zip(eval.curvature_energy.iter()))
1078                .map(|(scale, (energy, curvature))| scale * (energy - 2.0 * curvature))
1079                .sum::<f64>();
1080    BlockOrthogonalScaleDerivs { value, grad, hess }
1081}
1082
1083/// One warm-started 1-D Newton polish of a single block's rho at fixed scale
1084/// precisions. `max_iter` is a per-pass WORK bound, not a convergence
1085/// selector: the caller (`gaussian_reml_blocks_orthogonal_shared_scale`)
1086/// re-enters this solve every outer pass and certifies the joint fit by the
1087/// analytic score residual, erroring typed if the certificate is never met —
1088/// so an iterate returned at this cap never silently becomes the estimator.
1089fn solve_block_orthogonal_rho(
1090    gram: &Array2<f64>,
1091    rhs: &Array2<f64>,
1092    penalty: &Array2<f64>,
1093    rho0: f64,
1094    scale_precision: ArrayView1<'_, f64>,
1095    rank: usize,
1096    max_iter: usize,
1097) -> Result<(f64, BlockOrthogonalEval), EstimationError> {
1098    let mut rho = rho0;
1099    let mut current = block_orthogonal_eval(gram, rhs, penalty, rho)?;
1100    for _ in 0..max_iter {
1101        // Value, ρ-gradient, and ρ-Hessian all come from the SINGLE
1102        // single-source objective evaluation — they cannot desync.
1103        let derivs = block_orthogonal_scale_objective(&current, rho, scale_precision, rank);
1104        let grad = derivs.grad;
1105        let hess = derivs.hess;
1106        if !(grad.is_finite() && hess.is_finite()) {
1107            return Err(EstimationError::ModelIsIllConditioned {
1108                condition_number: f64::INFINITY,
1109            });
1110        }
1111        if grad == 0.0 {
1112            break;
1113        }
1114        // Positive curvature gives the Newton direction. Else use the exact
1115        // negative-gradient direction, which is descending regardless of the
1116        // local curvature. A representability-terminated backtracking search
1117        // globalizes either direction; it has no arbitrary finite trial list,
1118        // step clamp, or line-search iteration budget.
1119        let direction = if hess > 0.0 { -grad / hess } else { -grad };
1120        if !direction.is_finite() || grad * direction >= 0.0 {
1121            return Err(EstimationError::ModelIsIllConditioned {
1122                condition_number: f64::INFINITY,
1123            });
1124        }
1125        let current_value = derivs.value;
1126        let mut step_scale = 1.0_f64;
1127        let accepted = loop {
1128            let candidate_rho = rho + step_scale * direction;
1129            if candidate_rho == rho {
1130                break None;
1131            }
1132            if let Ok(candidate_eval) = block_orthogonal_eval(gram, rhs, penalty, candidate_rho) {
1133                let candidate_value = block_orthogonal_scale_objective(
1134                    &candidate_eval,
1135                    candidate_rho,
1136                    scale_precision,
1137                    rank,
1138                )
1139                .value;
1140                if candidate_value.is_finite() && candidate_value < current_value {
1141                    break Some((candidate_rho, candidate_eval));
1142                }
1143            }
1144            // Bisection is intrinsic to backtracking, not a tuned step-size
1145            // schedule. Floating-point representability above is the stopping
1146            // rule, so every feasible improving step remains reachable.
1147            step_scale *= 0.5;
1148        };
1149        let Some((next_rho, next_eval)) = accepted else {
1150            break;
1151        };
1152        rho = next_rho;
1153        current = next_eval;
1154    }
1155    Ok((rho, current))
1156}
1157
1158fn block_orthogonal_conditional_scale(
1159    evals: &[BlockOrthogonalEval],
1160    ywy: ArrayView1<'_, f64>,
1161    nu: f64,
1162) -> Result<Array1<f64>, EstimationError> {
1163    let mut explained = Array1::<f64>::zeros(ywy.len());
1164    for eval in evals {
1165        explained += &eval.fitted_energy;
1166    }
1167    let q = &ywy - &explained;
1168    if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1169        return Err(EstimationError::ModelIsIllConditioned {
1170            condition_number: f64::INFINITY,
1171        });
1172    }
1173    let scale = q.mapv(|value| nu / value);
1174    if scale
1175        .iter()
1176        .any(|value| !value.is_finite() || *value <= 0.0)
1177    {
1178        return Err(EstimationError::ModelIsIllConditioned {
1179            condition_number: f64::INFINITY,
1180        });
1181    }
1182    Ok(scale)
1183}
1184
1185/// Verify the defining contract of the decomposed block objective.  For every
1186/// pair of design columns this checks `x_a' W x_b = 0` against the standard
1187/// `gamma_m` forward-error bound for the two multiplications and two
1188/// accumulations performed per row.  The tolerance therefore scales with the
1189/// actual product magnitudes and row count; it is not a data-scale knob.
1190fn validate_weighted_block_orthogonality(
1191    designs: &[Array2<f64>],
1192    weight: ArrayView1<'_, f64>,
1193) -> Result<(), EstimationError> {
1194    let unit_roundoff = 0.5 * f64::EPSILON;
1195    let operation_count = weight.len().saturating_mul(4);
1196    let accumulated = operation_count as f64 * unit_roundoff;
1197    if accumulated >= 1.0 {
1198        crate::bail_invalid_estim!(
1199            "block-orthogonality verification has no finite floating-point error bound for {} rows",
1200            weight.len()
1201        );
1202    }
1203    let gamma = accumulated / (1.0 - accumulated);
1204    for left_block in 0..designs.len() {
1205        for right_block in (left_block + 1)..designs.len() {
1206            let left = &designs[left_block];
1207            let right = &designs[right_block];
1208            for left_col in 0..left.ncols() {
1209                for right_col in 0..right.ncols() {
1210                    let mut cross_product = 0.0_f64;
1211                    let mut magnitude_sum = 0.0_f64;
1212                    for row in 0..weight.len() {
1213                        let term = weight[row] * left[[row, left_col]] * right[[row, right_col]];
1214                        cross_product += term;
1215                        magnitude_sum += term.abs();
1216                    }
1217                    let roundoff = gamma * magnitude_sum;
1218                    if !cross_product.is_finite()
1219                        || !roundoff.is_finite()
1220                        || cross_product.abs() > roundoff
1221                    {
1222                        crate::bail_invalid_estim!(
1223                            "block-orthogonal Gaussian REML requires X[{left_block}]' W X[{right_block}] = 0, but columns ({left_col}, {right_col}) have weighted cross-product {cross_product:.6e} beyond the arithmetic bound {roundoff:.3e}"
1224                        );
1225                    }
1226                }
1227            }
1228        }
1229    }
1230    Ok(())
1231}
1232
1233#[derive(Clone, Copy, Debug)]
1234struct BlockOrthogonalProfileCurvature {
1235    min_eigenvalue: f64,
1236    roundoff: f64,
1237}
1238
1239/// Analytic rho Hessian after profiling out the exact conditional scale.
1240///
1241/// With `tau_o = nu / q_o` and `e_bo = beta_bo' lambda_b S_b beta_bo`,
1242/// eliminating the exact conditional scale block contributes the dense Schur
1243/// correction
1244///
1245/// `H_profile[b,c] = 1[b=c] H_fixed_scale[b,b]
1246///                    - (1/(2 nu)) sum_o tau_o^2 e_bo e_co`.
1247///
1248fn block_orthogonal_profile_hessian(
1249    evals: &[BlockOrthogonalEval],
1250    rhos: ArrayView1<'_, f64>,
1251    scale_precision: ArrayView1<'_, f64>,
1252    ranks: &[usize],
1253    nu: f64,
1254) -> Result<Array2<f64>, EstimationError> {
1255    let blocks = evals.len();
1256    let mut hessian = Array2::<f64>::zeros((blocks, blocks));
1257    for block in 0..blocks {
1258        hessian[[block, block]] = block_orthogonal_scale_objective(
1259            &evals[block],
1260            rhos[block],
1261            scale_precision.view(),
1262            ranks[block],
1263        )
1264        .hess;
1265    }
1266    for left in 0..blocks {
1267        for right in 0..=left {
1268            let correction = evals[left]
1269                .penalty_energy
1270                .iter()
1271                .zip(evals[right].penalty_energy.iter())
1272                .zip(scale_precision.iter())
1273                .map(|((&left_energy, &right_energy), &scale)| {
1274                    0.5 * scale * scale * left_energy * right_energy / nu
1275                })
1276                .sum::<f64>();
1277            hessian[[left, right]] -= correction;
1278            if left != right {
1279                hessian[[right, left]] -= correction;
1280            }
1281        }
1282    }
1283    if hessian.iter().any(|value| !value.is_finite()) {
1284        return Err(EstimationError::ModelIsIllConditioned {
1285            condition_number: f64::INFINITY,
1286        });
1287    }
1288    Ok(hessian)
1289}
1290
1291/// Smallest eigenvalue of the analytic profiled Hessian. A first-order score
1292/// can vanish at a REML maximum or saddle; requiring this curvature to be
1293/// nonnegative up to eigensolver roundoff prevents such a point from being
1294/// minted as a converged fit.
1295fn block_orthogonal_profile_curvature(
1296    evals: &[BlockOrthogonalEval],
1297    rhos: ArrayView1<'_, f64>,
1298    scale_precision: ArrayView1<'_, f64>,
1299    ranks: &[usize],
1300    nu: f64,
1301) -> Result<BlockOrthogonalProfileCurvature, EstimationError> {
1302    let hessian = block_orthogonal_profile_hessian(evals, rhos, scale_precision, ranks, nu)?;
1303    let blocks = hessian.nrows();
1304    let eigenvalues = hessian
1305        .eigh(Side::Lower)
1306        .map_err(|_| EstimationError::ModelIsIllConditioned {
1307            condition_number: f64::INFINITY,
1308        })?
1309        .0;
1310    let min_eigenvalue = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
1311    let spectral_scale = eigenvalues
1312        .iter()
1313        .copied()
1314        .map(f64::abs)
1315        .fold(0.0_f64, f64::max);
1316    let roundoff = f64::EPSILON * blocks.max(1) as f64 * spectral_scale.max(f64::MIN_POSITIVE);
1317    Ok(BlockOrthogonalProfileCurvature {
1318        min_eigenvalue,
1319        roundoff,
1320    })
1321}
1322
1323pub fn gaussian_reml_blocks_orthogonal_shared_scale(
1324    designs: &[Array2<f64>],
1325    penalties: &[Array2<f64>],
1326    y: ArrayView2<'_, f64>,
1327    weights: Option<ArrayView1<'_, f64>>,
1328    init_rhos: Option<&[f64]>,
1329) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1330    gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1331        designs,
1332        penalties,
1333        y,
1334        weights,
1335        init_rhos,
1336        BlockOrthogonalControls::default(),
1337    )
1338}
1339
1340fn gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
1341    designs: &[Array2<f64>],
1342    penalties: &[Array2<f64>],
1343    y: ArrayView2<'_, f64>,
1344    weights: Option<ArrayView1<'_, f64>>,
1345    init_rhos: Option<&[f64]>,
1346    controls: BlockOrthogonalControls,
1347) -> Result<GaussianRemlBlockOrthogonalResult, EstimationError> {
1348    if designs.is_empty() {
1349        crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one block");
1350    }
1351    if designs.len() != penalties.len() {
1352        crate::bail_invalid_estim!(
1353            "block-orthogonal Gaussian REML block mismatch: {} designs, {} penalties",
1354            designs.len(),
1355            penalties.len()
1356        );
1357    }
1358    let n = y.nrows();
1359    let d = y.ncols();
1360    if d == 0 {
1361        crate::bail_invalid_estim!("block-orthogonal Gaussian REML requires at least one output");
1362    }
1363    if y.iter().any(|value| !value.is_finite()) {
1364        crate::bail_invalid_estim!("block-orthogonal Gaussian REML response must be finite");
1365    }
1366    let weight = gaussian_reml_weights(n, weights)?;
1367    if let Some(rhos) = init_rhos {
1368        if rhos.len() != designs.len() {
1369            crate::bail_invalid_estim!(
1370                "block-orthogonal Gaussian REML init_rhos length mismatch: expected {}, got {}",
1371                designs.len(),
1372                rhos.len()
1373            );
1374        }
1375        if rhos.iter().any(|value| !value.is_finite()) {
1376            crate::bail_invalid_estim!("block-orthogonal Gaussian REML init_rhos must be finite");
1377        }
1378    }
1379
1380    let mut ywy = Array1::<f64>::zeros(d);
1381    for row in 0..n {
1382        for output in 0..d {
1383            ywy[output] += weight[row] * y[[row, output]] * y[[row, output]];
1384        }
1385    }
1386    let mut grams = Vec::with_capacity(designs.len());
1387    let mut rhs_blocks = Vec::with_capacity(designs.len());
1388    let mut penalties_owned = Vec::with_capacity(penalties.len());
1389    let mut ranks = Vec::with_capacity(penalties.len());
1390    let mut penalty_logdets = Vec::with_capacity(penalties.len());
1391    let mut nullity_total = 0_usize;
1392    for (block, (design, penalty)) in designs.iter().zip(penalties.iter()).enumerate() {
1393        let penalty_owned = canonicalize_penalty(penalty.view());
1394        validate_gaussian_reml_design(design.view(), penalty_owned.view(), Some(weight.view()))?;
1395        if design.nrows() != n {
1396            crate::bail_invalid_estim!(
1397                "block-orthogonal Gaussian REML designs[{block}] has {} rows, expected {n}",
1398                design.nrows()
1399            );
1400        }
1401        let gram = dense_xt_diag_x(design.view(), weight.view());
1402        let rhs = dense_xt_diag_y(design.view(), weight.view(), y);
1403        let (rank, logdet) = block_penalty_rank_logdet(penalty_owned.view())?;
1404        nullity_total += penalty_owned.nrows().saturating_sub(rank);
1405        grams.push(canonicalize_penalty(gram.view()));
1406        rhs_blocks.push(rhs);
1407        penalties_owned.push(penalty_owned);
1408        ranks.push(rank);
1409        penalty_logdets.push(logdet);
1410    }
1411    validate_weighted_block_orthogonality(designs, weight.view())?;
1412    let n_effective = effective_observation_count(weight.view());
1413    if n_effective <= nullity_total {
1414        crate::bail_invalid_estim!(
1415            "block-orthogonal Gaussian REML requires more positive-weight rows than the total penalty nullity; got n_effective={n_effective}, nullity={nullity_total}"
1416        );
1417    }
1418    let nu = (n_effective - nullity_total) as f64;
1419    let mut rhos = match init_rhos {
1420        Some(values) => Array1::from_vec(values.to_vec()),
1421        None => Array1::zeros(designs.len()),
1422    };
1423    // A rho checkpoint is sufficient to resume exactly because scale is a
1424    // closed-form conditional block. Reconstruct that block from the supplied
1425    // rhos before any new rho update instead of discarding it and restarting
1426    // from the response-only scale.
1427    let mut evals = (0..designs.len())
1428        .map(|block| {
1429            block_orthogonal_eval(
1430                &grams[block],
1431                &rhs_blocks[block],
1432                &penalties_owned[block],
1433                rhos[block],
1434            )
1435        })
1436        .collect::<Result<Vec<_>, _>>()?;
1437    let mut scale_precision = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1438    // Convergence is certified by the analytic score of the joint REML
1439    // objective, never by the iteration cap (SPEC rule 20). Each outer pass
1440    // (a) solves every block's 1-D rho Newton at the current scale precisions
1441    // and (b) applies the EXACT conditional-optimum scale update
1442    // `scale_o = nu / q_o`, so at the post-update point the scale block of the
1443    // joint score vanishes identically and — by the envelope theorem — the
1444    // profiled objective's total rho-derivative equals the partial
1445    // rho-gradient there. That gradient is available exactly from the cached
1446    // block evaluations because `block_orthogonal_eval` depends only on rho,
1447    // not on the scale precisions. First-order certification is therefore
1448    // `max_b |dV/drho_b| / (d * max(1, rank_b)) <= BLOCK_ORTHOGONAL_SCORE_TOL`
1449    // (the normalizer is the score's natural magnitude: every gradient term is
1450    // a sum of `d * rank`-order quantities, making the test relative). The
1451    // analytic Schur-profiled rho Hessian must additionally be PSD within its
1452    // dimension-scaled eigensolver roundoff; score-zero maxima and saddles are
1453    // not converged estimators.
1454    // Exhausting the pass budget without the certificate is a typed error
1455    // carrying the rho checkpoint, resumable through `init_rhos`.
1456    let mut converged = false;
1457    let mut cycle_detected = false;
1458    let mut outer_passes = 0usize;
1459    let mut last_score_residual = f64::INFINITY;
1460    let mut last_min_profile_curvature = f64::NEG_INFINITY;
1461    let mut last_profile_curvature_roundoff = 0.0_f64;
1462    let mut last_scale_step = f64::INFINITY;
1463    let mut recent_states: [Option<(Array1<f64>, Array1<f64>)>; 2] = [None, None];
1464    while outer_passes < controls.max_outer_passes {
1465        outer_passes += 1;
1466        evals.clear();
1467        for block in 0..designs.len() {
1468            let (rho, eval) = solve_block_orthogonal_rho(
1469                &grams[block],
1470                &rhs_blocks[block],
1471                &penalties_owned[block],
1472                rhos[block],
1473                scale_precision.view(),
1474                ranks[block],
1475                controls.block_updates_per_pass,
1476            )?;
1477            rhos[block] = rho;
1478            evals.push(eval);
1479        }
1480        let next_scale = block_orthogonal_conditional_scale(&evals, ywy.view(), nu)?;
1481        last_scale_step = next_scale
1482            .iter()
1483            .zip(scale_precision.iter())
1484            .map(|(next, old)| (next.ln() - old.ln()).abs())
1485            .fold(0.0_f64, f64::max);
1486        scale_precision = next_scale;
1487        last_score_residual = 0.0;
1488        for (block, eval) in evals.iter().enumerate() {
1489            let derivs = block_orthogonal_scale_objective(
1490                eval,
1491                rhos[block],
1492                scale_precision.view(),
1493                ranks[block],
1494            );
1495            let residual = derivs.grad.abs() / ((d as f64) * (ranks[block].max(1) as f64));
1496            if !residual.is_finite() {
1497                return Err(EstimationError::ModelIsIllConditioned {
1498                    condition_number: f64::INFINITY,
1499                });
1500            }
1501            last_score_residual = last_score_residual.max(residual);
1502        }
1503        let curvature = block_orthogonal_profile_curvature(
1504            &evals,
1505            rhos.view(),
1506            scale_precision.view(),
1507            &ranks,
1508            nu,
1509        )?;
1510        last_min_profile_curvature = curvature.min_eigenvalue;
1511        last_profile_curvature_roundoff = curvature.roundoff;
1512        if last_score_residual <= controls.score_tol
1513            && last_min_profile_curvature >= -last_profile_curvature_roundoff
1514        {
1515            converged = true;
1516            break;
1517        }
1518        // Cycle guard: one outer pass is a pure function of the state
1519        // `(rhos, scale_precision)`. Revisiting a state from one or two passes
1520        // ago (bitwise) means the alternation is in a floating-point limit
1521        // cycle that can never certify, so stop escalating immediately instead
1522        // of burning the remaining budget on the same orbit.
1523        let state = (rhos.clone(), scale_precision.clone());
1524        if recent_states
1525            .iter()
1526            .flatten()
1527            .any(|prev| prev.0 == state.0 && prev.1 == state.1)
1528        {
1529            cycle_detected = true;
1530            break;
1531        }
1532        recent_states[1] = recent_states[0].take();
1533        recent_states[0] = Some(state);
1534    }
1535    if !converged {
1536        return Err(EstimationError::BlockOrthogonalRemlDidNotConverge {
1537            iterations: outer_passes,
1538            max_score_residual: last_score_residual,
1539            score_tol: controls.score_tol,
1540            min_profile_curvature: last_min_profile_curvature,
1541            profile_curvature_roundoff: last_profile_curvature_roundoff,
1542            last_scale_step,
1543            cycle_detected,
1544            rho_checkpoint: rhos.to_vec(),
1545        });
1546    }
1547
1548    let coefficients = evals
1549        .iter()
1550        .map(|eval| eval.beta.clone())
1551        .collect::<Vec<_>>();
1552    let mut fitted = Array2::<f64>::zeros((n, d));
1553    for (design, coef) in designs.iter().zip(coefficients.iter()) {
1554        fitted += &fast_ab(&design.view(), &coef.view());
1555    }
1556    let mut explained = Array1::<f64>::zeros(d);
1557    for eval in evals.iter() {
1558        explained += &eval.fitted_energy;
1559    }
1560    let q = &ywy - &explained;
1561    if q.iter().any(|value| !value.is_finite() || *value <= 0.0) {
1562        return Err(EstimationError::ModelIsIllConditioned {
1563            condition_number: f64::INFINITY,
1564        });
1565    }
1566    let lambdas = Array1::from_vec(gam_problem::checked_exp_log_strengths(
1567        rhos.iter().copied(),
1568    )?);
1569    let edf = Array1::from_iter(evals.iter().map(|eval| eval.edf));
1570    let logdet_term = evals
1571        .iter()
1572        .enumerate()
1573        .map(|(block, eval)| {
1574            eval.logdet - penalty_logdets[block] - (ranks[block] as f64) * rhos[block]
1575        })
1576        .sum::<f64>();
1577    let scale_term = q
1578        .iter()
1579        .map(|value| nu * (1.0 + (2.0 * std::f64::consts::PI * value / nu).ln()))
1580        .sum::<f64>();
1581    Ok(GaussianRemlBlockOrthogonalResult {
1582        coefficients,
1583        fitted,
1584        lambdas,
1585        log_lambdas: rhos,
1586        reml_score: 0.5 * (d as f64) * logdet_term + 0.5 * scale_term,
1587        edf,
1588    })
1589}
1590
1591/// Exact envelope derivative of shared-dispersion Gaussian REML with respect
1592/// to its symmetric penalty matrix at a converged inner fit.
1593///
1594/// The coefficient matrix and log smoothing strength are stationary in
1595/// [`gaussian_reml_multi_shared_dispersion_closed_form`], so their implicit
1596/// derivatives vanish from the outer derivative. What remains is the explicit
1597/// penalty derivative of the restricted determinant and the single pooled
1598/// deviance. This is the authority for continuously optimized reference-metric
1599/// parameters: a metric supplies `dS/dtheta`, and the outer derivative is the
1600/// Frobenius contraction `<dV/dS, dS/dtheta>`.
1601pub fn gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
1602    x: ArrayView2<'_, f64>,
1603    y: ArrayView2<'_, f64>,
1604    penalty: ArrayView2<'_, f64>,
1605    weights: Option<ArrayView1<'_, f64>>,
1606    fit: &GaussianRemlMultiResult,
1607) -> Result<Array2<f64>, EstimationError> {
1608    validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
1609    let n = x.nrows();
1610    let p = x.ncols();
1611    let d = y.ncols();
1612    if d == 0 {
1613        crate::bail_invalid_estim!(
1614            "shared-dispersion REML penalty gradient requires at least one response column"
1615        );
1616    }
1617    let weight = gaussian_reml_weights(n, weights)?;
1618    let n_effective = effective_observation_count(weight.view());
1619    let per_output_nu = n_effective.checked_sub(fit.cache.nullity).ok_or_else(|| {
1620        EstimationError::InvalidInput(
1621            "shared-dispersion REML penalty gradient has non-positive residual degrees of freedom"
1622                .to_string(),
1623        )
1624    })?;
1625    if per_output_nu == 0 {
1626        crate::bail_invalid_estim!(
1627            "shared-dispersion REML penalty gradient requires positive residual degrees of freedom"
1628        );
1629    }
1630    let shared_nu = (d as f64) * (per_output_nu as f64);
1631    // Use the deviance represented by the forward fit itself.  Reconstructing
1632    // the mathematically equivalent quantity as RSS + lambda * beta' S beta
1633    // follows a different floating-point path from the modal subtraction used
1634    // by `gaussian_reml_multi_shared_dispersion_closed_form`.  On a nearly
1635    // interpolating chart the two paths lose different low bits, making this
1636    // gradient disagree with value probes even though both formulas are exact
1637    // over the reals.  A nested metric optimizer then follows the mismatched
1638    // derivative until every Armijo step is rejected.  The shared forward fit
1639    // stores its single profiled dispersion in every output slot, so recover
1640    // the authoritative pooled deviance from that state instead.
1641    let shared_sigma2 = fit.sigma2[0];
1642    if fit
1643        .sigma2
1644        .iter()
1645        .any(|sigma2| sigma2.to_bits() != shared_sigma2.to_bits())
1646    {
1647        crate::bail_invalid_estim!(
1648            "shared-dispersion REML penalty gradient requires one shared forward dispersion"
1649        );
1650    }
1651    let pooled_deviance = shared_sigma2 * shared_nu;
1652    if !(pooled_deviance.is_finite() && pooled_deviance > 0.0) {
1653        crate::bail_invalid_estim!(
1654            "shared-dispersion REML penalty gradient requires positive forward deviance"
1655        );
1656    }
1657
1658    let inverse_hessian = gaussian_reml_inverse_hessian_from_cache(&fit.cache, fit.lambda)?;
1659    let penalty_pseudoinverse = gaussian_reml_penalty_pseudoinverse_from_cache(&fit.cache);
1660    let mut gradient = Array2::<f64>::zeros((p, p));
1661    for row in 0..p {
1662        for col in 0..p {
1663            gradient[[row, col]] = 0.5
1664                * (d as f64)
1665                * (fit.lambda * inverse_hessian[[col, row]] - penalty_pseudoinverse[[col, row]]);
1666        }
1667    }
1668    let deviance_scale = 0.5 * shared_nu * fit.lambda / pooled_deviance;
1669    for output in 0..d {
1670        add_rank_one_penalty_vjp(
1671            deviance_scale,
1672            fit.coefficients.column(output),
1673            &mut gradient,
1674        );
1675    }
1676    for row in 0..p {
1677        for col in (row + 1)..p {
1678            let mean = 0.5 * (gradient[[row, col]] + gradient[[col, row]]);
1679            gradient[[row, col]] = mean;
1680            gradient[[col, row]] = mean;
1681        }
1682    }
1683    if gradient.iter().any(|value| !value.is_finite()) {
1684        crate::bail_invalid_estim!(
1685            "shared-dispersion REML penalty gradient produced a non-finite value"
1686        );
1687    }
1688    Ok(gradient)
1689}
1690
1691fn gaussian_reml_multi_closed_form_from_parts(
1692    x: ArrayView2<'_, f64>,
1693    y: ArrayView2<'_, f64>,
1694    penalty: ArrayView2<'_, f64>,
1695    nullspace_dim: Option<usize>,
1696    weights: Option<ArrayView1<'_, f64>>,
1697    init_lambda: Option<f64>,
1698    eigen_cache: Option<&GaussianRemlEigenCache>,
1699) -> Result<GaussianRemlMultiResult, EstimationError> {
1700    let prepared = prepare_gaussian_reml(x, y, penalty, nullspace_dim, weights, eigen_cache)?;
1701    let init_rho = init_lambda
1702        .map(validate_initial_lambda)
1703        .transpose()?
1704        .map(f64::ln);
1705    let rho = optimize_rho(&prepared, init_rho)?;
1706    let eval = prepared.evaluate(rho);
1707    let lambda = gam_problem::checked_exp_log_strength(rho)
1708        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1709    let coefficients = prepared.coefficients(lambda);
1710    let fitted = dense_ab(x, coefficients.view());
1711    let sigma2 = prepared.sigma2(lambda);
1712    let (reml_grad_lambda, reml_hess_lambda) =
1713        rho_derivatives_to_lambda(lambda, eval.grad, eval.hess);
1714    Ok(GaussianRemlMultiResult {
1715        lambda,
1716        rho,
1717        coefficients,
1718        fitted,
1719        reml_score: eval.cost,
1720        reml_grad_lambda,
1721        reml_hess_lambda,
1722        reml_grad_rho: eval.grad,
1723        reml_hess_rho: eval.hess,
1724        edf: eval.edf,
1725        sigma2,
1726        cache: prepared.cache,
1727    })
1728}
1729
1730pub fn gaussian_reml_free_b_score(
1731    x: ArrayView2<'_, f64>,
1732    y: ArrayView2<'_, f64>,
1733    coefficients: ArrayView2<'_, f64>,
1734    log_lambda: f64,
1735    penalty: ArrayView2<'_, f64>,
1736    weights: Option<ArrayView1<'_, f64>>,
1737) -> Result<GaussianRemlFreeBScore, EstimationError> {
1738    let lambda = gam_problem::checked_exp_log_strength(log_lambda)
1739        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1740    let penalty_owned = canonicalize_penalty(penalty);
1741    let penalty = penalty_owned.view();
1742    let n = x.nrows();
1743    let p = x.ncols();
1744    let d = y.ncols();
1745    validate_gaussian_reml_design(x, penalty, weights)?;
1746    if y.nrows() != n {
1747        crate::bail_invalid_estim!(
1748            "Gaussian REML row mismatch: X has {n} rows but Y has {}",
1749            y.nrows()
1750        );
1751    }
1752    if coefficients.dim() != (p, d) {
1753        crate::bail_invalid_estim!(
1754            "Gaussian REML coefficient shape mismatch: expected {p}x{d}, got {}x{}",
1755            coefficients.nrows(),
1756            coefficients.ncols()
1757        );
1758    }
1759    if y.iter().chain(coefficients.iter()).any(|v| !v.is_finite()) {
1760        crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
1761    }
1762
1763    let weight = gaussian_reml_weights(n, weights)?;
1764    let n_effective = effective_observation_count(weight.view());
1765    let cache =
1766        build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, Some(weight.view()))?;
1767    if n_effective <= cache.nullity {
1768        crate::bail_invalid_estim!(
1769            "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
1770            cache.nullity
1771        );
1772    }
1773    let nu = n_effective as f64 - cache.nullity as f64;
1774    let fitted = dense_ab(x, coefficients);
1775    let residual = y.to_owned() - &fitted;
1776    let xtw_residual = dense_xt_diag_y(x, weight.view(), residual.view());
1777    let s_beta = dense_ab(penalty, coefficients);
1778
1779    let mut logdet_h = cache.logdet_xtwx;
1780    let mut trace_h = 0.0;
1781    let mut edf = 0.0;
1782    for &delta in &cache.penalty_eigenvalues {
1783        let t = lambda * delta;
1784        logdet_h += (1.0 + t).ln();
1785        if delta > 0.0 {
1786            trace_h += t / (1.0 + t);
1787        }
1788        edf += 1.0 / (1.0 + t);
1789    }
1790    let logdet_s = cache.logdet_penalty_positive + (cache.penalty_rank as f64) * log_lambda;
1791    let mut reml_score = 0.5 * (d as f64) * (logdet_h - logdet_s);
1792    let mut grad_log_lambda = 0.5 * (d as f64) * (trace_h - cache.penalty_rank as f64);
1793    let mut grad_coefficients = Array2::<f64>::zeros((p, d));
1794    let inverse_hessian = {
1795        let xtwx = dense_xt_diag_x(x, weight.view());
1796        let mut hessian = xtwx;
1797        hessian += &(penalty.to_owned() * lambda);
1798        hessian
1799            .cholesky(Side::Lower)
1800            .map_err(EstimationError::LinearSystemSolveFailed)?
1801            .solve_mat(&Array2::<f64>::eye(p))
1802    };
1803    let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(&cache);
1804    let mut grad_penalty = Array2::<f64>::zeros((p, p));
1805    for row in 0..p {
1806        for col in 0..p {
1807            grad_penalty[[row, col]] += 0.5
1808                * (d as f64)
1809                * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
1810        }
1811    }
1812    let mut sigma2 = Array1::<f64>::zeros(d);
1813
1814    for output in 0..d {
1815        let mut weighted_rss = 0.0;
1816        for row in 0..n {
1817            let r = residual[[row, output]];
1818            weighted_rss += weight[row] * r * r;
1819        }
1820        let beta_col = coefficients.column(output);
1821        let s_beta_col = s_beta.column(output);
1822        let penalty_quadratic = beta_col.dot(&s_beta_col);
1823        let dp = (weighted_rss + lambda * penalty_quadratic).max(MIN_DEVIANCE);
1824        sigma2[output] = dp / nu;
1825        reml_score += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp / nu).ln());
1826        grad_log_lambda += 0.5 * nu * lambda * penalty_quadratic / dp;
1827        let scale = nu / dp;
1828        for coeff in 0..p {
1829            grad_coefficients[[coeff, output]] =
1830                scale * (-xtw_residual[[coeff, output]] + lambda * s_beta[[coeff, output]]);
1831        }
1832        add_rank_one_penalty_vjp(0.5 * scale * lambda, beta_col, &mut grad_penalty);
1833    }
1834    for i in 0..p {
1835        for j in (i + 1)..p {
1836            let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
1837            grad_penalty[[i, j]] = avg;
1838            grad_penalty[[j, i]] = avg;
1839        }
1840    }
1841
1842    Ok(GaussianRemlFreeBScore {
1843        reml_score,
1844        grad_coefficients,
1845        grad_penalty,
1846        grad_log_lambda,
1847        fitted,
1848        sigma2,
1849        edf,
1850    })
1851}
1852
1853pub fn gaussian_reml_multi_closed_form_backward(
1854    x: ArrayView2<'_, f64>,
1855    y: ArrayView2<'_, f64>,
1856    penalty: ArrayView2<'_, f64>,
1857    weights: Option<ArrayView1<'_, f64>>,
1858    init_lambda: Option<f64>,
1859    upstream_lambda: f64,
1860    upstream_coefficients: Option<ArrayView2<'_, f64>>,
1861    upstream_fitted: Option<ArrayView2<'_, f64>>,
1862    upstream_reml_score: f64,
1863    upstream_edf: f64,
1864) -> Result<GaussianRemlBackwardResult, EstimationError> {
1865    let fit =
1866        gaussian_reml_multi_closed_form_with_cache(x, y, penalty, weights, init_lambda, None)?;
1867    gaussian_reml_multi_closed_form_backward_from_fit(
1868        x,
1869        y,
1870        penalty,
1871        weights,
1872        &fit,
1873        upstream_lambda,
1874        upstream_coefficients,
1875        upstream_fitted,
1876        upstream_reml_score,
1877        upstream_edf,
1878    )
1879}
1880
1881pub fn gaussian_reml_multi_closed_form_backward_from_fit(
1882    x: ArrayView2<'_, f64>,
1883    y: ArrayView2<'_, f64>,
1884    penalty: ArrayView2<'_, f64>,
1885    weights: Option<ArrayView1<'_, f64>>,
1886    fit: &GaussianRemlMultiResult,
1887    upstream_lambda: f64,
1888    upstream_coefficients: Option<ArrayView2<'_, f64>>,
1889    upstream_fitted: Option<ArrayView2<'_, f64>>,
1890    upstream_reml_score: f64,
1891    upstream_edf: f64,
1892) -> Result<GaussianRemlBackwardResult, EstimationError> {
1893    validate_gaussian_reml_backward_upstreams(
1894        x,
1895        y,
1896        penalty,
1897        upstream_lambda,
1898        upstream_coefficients,
1899        upstream_fitted,
1900        upstream_reml_score,
1901        upstream_edf,
1902    )?;
1903    validate_gaussian_reml_forward_fit(x, y, penalty, weights, fit)?;
1904    let lambda = fit.lambda;
1905    let n = x.nrows();
1906    let p = x.ncols();
1907    let d = y.ncols();
1908    // The implicit-function channel dλ̂/d(inputs) = −V_ρθ/V_ρρ is the derivative
1909    // of an INTERIOR stationary root only. Two selections break its premise:
1910    //  * ρ̂ railed at a box endpoint (±RHO bound): the selection is locally the
1911    //    constant projection, so dλ̂/d(inputs) = 0 exactly — applying the
1912    //    interior formula there emits enormous wrong gradients;
1913    //  * unusable ρ-curvature (flat or rank-zero penalty): λ̂ is not identified.
1914    // Neither invalidates the FIXED-ρ explicit VJPs — coefficients/fitted still
1915    // depend on X, y, W at the selected λ — so only the λ̂-root channel is
1916    // suppressed below. (The old gate zeroed the WHOLE backward here, silently
1917    // dropping real coefficient gradients on unpenalized/flat-penalty fits.)
1918    let rho_hat = lambda.ln();
1919    let rho_at_bound =
1920        (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
1921    let implicit_rho_usable =
1922        fit.reml_hess_rho.is_finite() && fit.reml_hess_rho.abs() > 1.0e-14 && !rho_at_bound;
1923    let weight = gaussian_reml_weights(n, weights)?;
1924    let inverse_hessian = match gaussian_reml_inverse_hessian_from_cache(&fit.cache, lambda) {
1925        Ok(inv) => inv,
1926        Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
1927            warn_ill_conditioned_backward_once(p, d, condition_number);
1928            return Ok(zero_backward_result(n, p, d));
1929        }
1930        Err(err) => return Err(err),
1931    };
1932    gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
1933        x,
1934        y,
1935        penalty,
1936        weight,
1937        fit,
1938        inverse_hessian,
1939        upstream_lambda,
1940        upstream_coefficients,
1941        upstream_fitted,
1942        upstream_reml_score,
1943        upstream_edf,
1944        implicit_rho_usable,
1945        n,
1946        p,
1947        d,
1948    )
1949}
1950
1951fn gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
1952    x: ArrayView2<'_, f64>,
1953    y: ArrayView2<'_, f64>,
1954    penalty: ArrayView2<'_, f64>,
1955    weight: Array1<f64>,
1956    fit: &GaussianRemlMultiResult,
1957    inverse_hessian: Array2<f64>,
1958    upstream_lambda: f64,
1959    upstream_coefficients: Option<ArrayView2<'_, f64>>,
1960    upstream_fitted: Option<ArrayView2<'_, f64>>,
1961    upstream_reml_score: f64,
1962    upstream_edf: f64,
1963    implicit_rho_usable: bool,
1964    n: usize,
1965    p: usize,
1966    d: usize,
1967) -> Result<GaussianRemlBackwardResult, EstimationError> {
1968    // Backward sees the same symmetric S the forward used. Canonicalize on
1969    // entry so an asymmetric input (e.g. a single-entry gradcheck perturbation
1970    // around a symmetric base) cannot leak into the per-helper VJPs.
1971    let penalty_owned = canonicalize_penalty(penalty);
1972    let penalty = penalty_owned.view();
1973    let lambda = fit.lambda;
1974    let beta = &fit.coefficients;
1975    let residual = y.to_owned() - &fit.fitted;
1976    // Match the forward's REML residual DoF: zero prior-weight rows are excluded
1977    // from the effective sample size (see `effective_observation_count`), so the
1978    // adjoint of `ν` uses the same count the forward used.
1979    let nu = effective_observation_count(weight.view()) as f64 - fit.cache.nullity as f64;
1980
1981    let mut grad_x = Array2::<f64>::zeros((n, p));
1982    let mut grad_y = Array2::<f64>::zeros((n, d));
1983    let mut grad_penalty = Array2::<f64>::zeros((p, p));
1984    let mut grad_weights = Array1::<f64>::zeros(n);
1985
1986    let mut upstream_beta = Array2::<f64>::zeros((p, d));
1987    if let Some(upstream_coefficients) = upstream_coefficients {
1988        upstream_beta += &upstream_coefficients;
1989    }
1990    if let Some(upstream_fitted) = upstream_fitted {
1991        upstream_beta += &dense_atb(x, upstream_fitted);
1992        grad_x += &dense_ab(upstream_fitted, beta.t());
1993    }
1994
1995    let mut lambda_adjoint = upstream_lambda;
1996    if upstream_beta.iter().any(|value| *value != 0.0) {
1997        // A downstream loss that explicitly uses beta_hat or fitted = X beta_hat
1998        // cannot use the REML envelope shortcut.  Route those seeds through
1999        // the fixed-rho KKT adjoint M u = upstream_beta, then differentiate
2000        // X, y, weights, and S through the ridge solve.
2001        add_ridge_profile_vjp_with_lambda_grad(
2002            1.0,
2003            x,
2004            y,
2005            penalty,
2006            &weight,
2007            lambda,
2008            &inverse_hessian,
2009            beta,
2010            upstream_beta.view(),
2011            &mut grad_x,
2012            &mut grad_y,
2013            &mut grad_penalty,
2014            &mut grad_weights,
2015            &mut lambda_adjoint,
2016        );
2017    }
2018
2019    if upstream_reml_score != 0.0 {
2020        add_reml_score_vjp(
2021            upstream_reml_score,
2022            x,
2023            &weight,
2024            &inverse_hessian,
2025            beta,
2026            &residual,
2027            &fit.sigma2,
2028            nu,
2029            lambda,
2030            &fit.cache,
2031            &mut grad_x,
2032            &mut grad_y,
2033            &mut grad_penalty,
2034            &mut grad_weights,
2035        );
2036        lambda_adjoint += upstream_reml_score * fit.reml_grad_lambda;
2037    }
2038
2039    if upstream_edf != 0.0 {
2040        lambda_adjoint += add_edf_vjp(
2041            upstream_edf,
2042            x,
2043            penalty,
2044            &weight,
2045            lambda,
2046            &inverse_hessian,
2047            &mut grad_x,
2048            &mut grad_penalty,
2049            &mut grad_weights,
2050        );
2051    }
2052
2053    if lambda_adjoint != 0.0 && implicit_rho_usable {
2054        let root_scale = -lambda_adjoint * lambda / fit.reml_hess_rho;
2055        add_reml_rho_gradient_vjp(
2056            root_scale,
2057            x,
2058            y,
2059            penalty,
2060            &weight,
2061            lambda,
2062            &inverse_hessian,
2063            beta,
2064            &residual,
2065            &fit.sigma2,
2066            nu,
2067            &mut grad_x,
2068            &mut grad_y,
2069            &mut grad_penalty,
2070            &mut grad_weights,
2071        );
2072    }
2073
2074    // The forward consumes `S` only through the canonicalization
2075    // `S_canon = 0.5 (S + Sᵀ)`. By the chain rule, the gradient w.r.t. an
2076    // input `S_input` is `0.5 (G + Gᵀ)` where `G = ∂L/∂S_canon` is what the
2077    // per-helper VJPs accumulate. Symmetrize the full matrix here so a
2078    // single-entry perturbation `δS = ε E_{i,j}` (asymmetric, as
2079    // `torch.autograd.gradcheck` produces) sees the gradient component
2080    // `0.5 (G[i,j] + G[j,i])` it expects from FD — no caller-side
2081    // bookkeeping required.
2082    let p = grad_penalty.nrows();
2083    for i in 0..p {
2084        for j in (i + 1)..p {
2085            let avg = 0.5 * (grad_penalty[[i, j]] + grad_penalty[[j, i]]);
2086            grad_penalty[[i, j]] = avg;
2087            grad_penalty[[j, i]] = avg;
2088        }
2089    }
2090    Ok(GaussianRemlBackwardResult {
2091        grad_x,
2092        grad_y,
2093        grad_penalty,
2094        grad_weights,
2095    })
2096}
2097
2098pub fn gaussian_reml_multi_closed_form_backward_batch<'a>(
2099    problems: &[GaussianRemlMultiBackwardProblem<'a>],
2100    penalty: ArrayView2<'a, f64>,
2101) -> Vec<Result<GaussianRemlBackwardResult, EstimationError>> {
2102    let inverse_hessians = batched_inverse_hessians_from_caches(problems);
2103    let results: Vec<Result<GaussianRemlBackwardResult, EstimationError>> = problems
2104        .par_iter()
2105        .zip(inverse_hessians.into_par_iter())
2106        .map(|(problem, inverse_hessian_result)| {
2107            validate_gaussian_reml_backward_upstreams(
2108                problem.x.view(),
2109                problem.y.view(),
2110                penalty,
2111                problem.grad_lambda,
2112                problem.grad_coefficients.as_ref().map(|g| g.view()),
2113                problem.grad_fitted.as_ref().map(|g| g.view()),
2114                problem.grad_reml_score,
2115                problem.grad_edf,
2116            )?;
2117            validate_gaussian_reml_forward_fit(
2118                problem.x.view(),
2119                problem.y.view(),
2120                penalty,
2121                problem.weights.as_ref().map(|w| w.view()),
2122                problem.fit,
2123            )?;
2124            let n = problem.x.nrows();
2125            let p = problem.x.ncols();
2126            let d = problem.y.ncols();
2127            if !(problem.fit.reml_hess_rho.is_finite() && problem.fit.reml_hess_rho.abs() > 1.0e-14)
2128            {
2129                // Graceful degradation — see `gaussian_reml_multi_closed_form_backward_from_fit`.
2130                warn_ill_conditioned_backward_once(p, d, f64::INFINITY);
2131                return Ok(zero_backward_result(n, p, d));
2132            }
2133            let weight = gaussian_reml_weights(n, problem.weights.as_ref().map(|w| w.view()))?;
2134            let inverse_hessian = match inverse_hessian_result {
2135                Ok(inv) => inv,
2136                Err(EstimationError::ModelIsIllConditioned { condition_number }) => {
2137                    warn_ill_conditioned_backward_once(p, d, condition_number);
2138                    return Ok(zero_backward_result(n, p, d));
2139                }
2140                Err(err) => return Err(err),
2141            };
2142            // Same selection-validity rule as the single-problem entry above:
2143            // the implicit λ̂-root channel is usable only for an INTERIOR
2144            // stationary root with usable ρ-curvature (a ρ̂ railed at a box
2145            // endpoint is locally the constant projection — its channel is 0).
2146            let rho_hat = problem.fit.lambda.ln();
2147            let rho_at_bound =
2148                (rho_hat - RHO_UPPER).abs() <= 1.0e-9 || (rho_hat - RHO_LOWER).abs() <= 1.0e-9;
2149            let implicit_rho_usable = problem.fit.reml_hess_rho.is_finite()
2150                && problem.fit.reml_hess_rho.abs() > 1.0e-14
2151                && !rho_at_bound;
2152            gaussian_reml_multi_closed_form_backward_from_fit_with_inverse_hessian_impl(
2153                problem.x.view(),
2154                problem.y.view(),
2155                penalty,
2156                weight,
2157                problem.fit,
2158                inverse_hessian,
2159                problem.grad_lambda,
2160                problem.grad_coefficients.as_ref().map(|g| g.view()),
2161                problem.grad_fitted.as_ref().map(|g| g.view()),
2162                problem.grad_reml_score,
2163                problem.grad_edf,
2164                implicit_rho_usable,
2165                n,
2166                p,
2167                d,
2168            )
2169        })
2170        .collect();
2171    results
2172}
2173
2174fn rho_derivatives_to_lambda(lambda: f64, grad_rho: f64, hess_rho: f64) -> (f64, f64) {
2175    (grad_rho / lambda, (hess_rho - grad_rho) / (lambda * lambda))
2176}
2177
2178fn validate_gaussian_reml_backward_upstreams(
2179    x: ArrayView2<'_, f64>,
2180    y: ArrayView2<'_, f64>,
2181    penalty: ArrayView2<'_, f64>,
2182    upstream_lambda: f64,
2183    upstream_coefficients: Option<ArrayView2<'_, f64>>,
2184    upstream_fitted: Option<ArrayView2<'_, f64>>,
2185    upstream_reml_score: f64,
2186    upstream_edf: f64,
2187) -> Result<(), EstimationError> {
2188    if !(upstream_lambda.is_finite() && upstream_reml_score.is_finite() && upstream_edf.is_finite())
2189    {
2190        crate::bail_invalid_estim!("Gaussian REML backward upstream scalars must be finite");
2191    }
2192    if let Some(upstream_coefficients) = upstream_coefficients {
2193        if upstream_coefficients.dim() != (x.ncols(), y.ncols()) {
2194            crate::bail_invalid_estim!(
2195                "Gaussian REML backward coefficient upstream shape mismatch: expected {}x{}, got {}x{}",
2196                x.ncols(),
2197                y.ncols(),
2198                upstream_coefficients.nrows(),
2199                upstream_coefficients.ncols()
2200            );
2201        }
2202        if upstream_coefficients.iter().any(|value| !value.is_finite()) {
2203            crate::bail_invalid_estim!(
2204                "Gaussian REML backward coefficient upstream must be finite"
2205            );
2206        }
2207    }
2208    if let Some(upstream_fitted) = upstream_fitted {
2209        if upstream_fitted.dim() != y.dim() {
2210            crate::bail_invalid_estim!(
2211                "Gaussian REML backward fitted upstream shape mismatch: expected {}x{}, got {}x{}",
2212                y.nrows(),
2213                y.ncols(),
2214                upstream_fitted.nrows(),
2215                upstream_fitted.ncols()
2216            );
2217        }
2218        if upstream_fitted.iter().any(|value| !value.is_finite()) {
2219            crate::bail_invalid_estim!("Gaussian REML backward fitted upstream must be finite");
2220        }
2221    }
2222    validate_gaussian_reml_design(x, penalty, None)?;
2223    Ok(())
2224}
2225
2226fn validate_gaussian_reml_forward_fit(
2227    x: ArrayView2<'_, f64>,
2228    y: ArrayView2<'_, f64>,
2229    penalty: ArrayView2<'_, f64>,
2230    weights: Option<ArrayView1<'_, f64>>,
2231    fit: &GaussianRemlMultiResult,
2232) -> Result<(), EstimationError> {
2233    // Fingerprint the canonicalized penalty: caches are keyed on the
2234    // symmetric average, and the caller may hand us a raw input (e.g. a
2235    // single-entry-perturbed matrix produced by ``torch.autograd.gradcheck``).
2236    let penalty_owned = canonicalize_penalty(penalty);
2237    let penalty = penalty_owned.view();
2238    let n = x.nrows();
2239    let p = x.ncols();
2240    let d = y.ncols();
2241    validate_gaussian_reml_design(x, penalty, weights)?;
2242    validate_gaussian_reml_eigen_cache(&fit.cache, p)?;
2243    if y.nrows() != n
2244        || fit.coefficients.dim() != (p, d)
2245        || fit.fitted.dim() != (n, d)
2246        || fit.sigma2.len() != d
2247    {
2248        crate::bail_invalid_estim!(
2249            "Gaussian REML backward forward-state shape mismatch: expected coefficients=({p},{d}), fitted=({n},{d}), sigma2={d}"
2250        );
2251    }
2252    if !(fit.lambda.is_finite()
2253        && fit.lambda > 0.0
2254        && fit.rho.is_finite()
2255        && fit.reml_score.is_finite()
2256        && fit.reml_hess_rho.is_finite()
2257        && fit.edf.is_finite())
2258        || fit.coefficients.iter().any(|value| !value.is_finite())
2259        || fit.fitted.iter().any(|value| !value.is_finite())
2260        || fit.sigma2.iter().any(|value| !value.is_finite())
2261    {
2262        crate::bail_invalid_estim!("Gaussian REML backward forward state must be finite");
2263    }
2264    let penalty_fingerprint = matrix_fingerprint(penalty);
2265    if fit.cache.penalty_fingerprint != penalty_fingerprint {
2266        crate::bail_invalid_estim!("Gaussian REML backward forward-state penalty mismatch");
2267    }
2268    let weight = gaussian_reml_weights(n, weights)?;
2269    let xtwx = dense_xt_diag_x(x, weight.view());
2270    if fit.cache.xtwx_fingerprint != matrix_fingerprint(xtwx.view()) {
2271        crate::bail_invalid_estim!("Gaussian REML backward forward-state X'WX mismatch");
2272    }
2273    Ok(())
2274}
2275
2276fn gaussian_reml_inverse_hessian_from_cache(
2277    cache: &GaussianRemlEigenCache,
2278    lambda: f64,
2279) -> Result<Array2<f64>, EstimationError> {
2280    if !(lambda.is_finite() && lambda > 0.0) {
2281        crate::bail_invalid_estim!(
2282            "Gaussian REML lambda must be finite and positive; got {lambda}"
2283        );
2284    }
2285    let p = cache.penalty_eigenvalues.len();
2286    let mut scaled_basis = cache.coefficient_basis.clone();
2287    for eig in 0..p {
2288        let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2289        for row in 0..p {
2290            scaled_basis[[row, eig]] *= scale;
2291        }
2292    }
2293    let inverse = dense_ab(scaled_basis.view(), cache.coefficient_basis.t());
2294    if inverse.iter().any(|value| !value.is_finite()) {
2295        return Err(EstimationError::ModelIsIllConditioned {
2296            condition_number: f64::INFINITY,
2297        });
2298    }
2299    Ok(inverse)
2300}
2301
2302fn batched_inverse_hessians_from_caches(
2303    problems: &[GaussianRemlMultiBackwardProblem<'_>],
2304) -> Vec<Result<Array2<f64>, EstimationError>> {
2305    if problems.is_empty() {
2306        return Vec::new();
2307    }
2308    let p = problems[0].fit.cache.coefficient_basis.nrows();
2309    let uniform = p > 0
2310        && problems.iter().all(|problem| {
2311            let cache = &problem.fit.cache;
2312            cache.coefficient_basis.dim() == (p, p) && cache.penalty_eigenvalues.len() == p
2313        });
2314    if uniform && problems.len() > 1 {
2315        let mut scaled_basis = Array3::<f64>::zeros((problems.len(), p, p));
2316        let mut basis = Array3::<f64>::zeros((problems.len(), p, p));
2317        let mut valid = true;
2318        for (idx, problem) in problems.iter().enumerate() {
2319            let lambda = problem.fit.lambda;
2320            if !(lambda.is_finite() && lambda > 0.0) {
2321                valid = false;
2322                break;
2323            }
2324            let cache = &problem.fit.cache;
2325            basis
2326                .slice_mut(s![idx, .., ..])
2327                .assign(&cache.coefficient_basis);
2328            for eig in 0..p {
2329                let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
2330                for row in 0..p {
2331                    scaled_basis[[idx, row, eig]] = cache.coefficient_basis[[row, eig]] * scale;
2332                }
2333            }
2334        }
2335        if valid
2336            && let Some(inverses) =
2337                gam_gpu::try_fast_abt_strided_batched(scaled_basis.view(), basis.view())
2338        {
2339            return inverses
2340                .axis_iter(Axis(0))
2341                .map(|inverse| Ok(inverse.to_owned()))
2342                .collect();
2343        }
2344    }
2345    problems
2346        .iter()
2347        .map(|problem| {
2348            gaussian_reml_inverse_hessian_from_cache(&problem.fit.cache, problem.fit.lambda)
2349        })
2350        .collect()
2351}
2352
2353/// Side-effects of the ridge-profile VJP that are independent of λ.
2354///
2355/// Computes the KKT adjoint `m = M^{-1} u` for `u = upstream_beta` and accumulates
2356/// the partials w.r.t. `X`, `y`, `S`, and `w` into the provided gradient buffers.
2357/// Returns `m` so callers that also need `∂L/∂λ` can fold in the λ-adjoint dot
2358/// product `−scale · ⟨m, S β⟩` without recomputing the adjoint solve.
2359fn ridge_profile_vjp_data_partials(
2360    scale: f64,
2361    x: ArrayView2<'_, f64>,
2362    y: ArrayView2<'_, f64>,
2363    penalty: ArrayView2<'_, f64>,
2364    weights: &Array1<f64>,
2365    lambda: f64,
2366    inverse_hessian: &Array2<f64>,
2367    beta: &Array2<f64>,
2368    upstream_beta: ArrayView2<'_, f64>,
2369    grad_x: &mut Array2<f64>,
2370    grad_y: &mut Array2<f64>,
2371    grad_penalty: &mut Array2<f64>,
2372    grad_weights: &mut Array1<f64>,
2373) -> Array2<f64> {
2374    let m = dense_ab(inverse_hessian.view(), upstream_beta);
2375    let c = dense_ab(m.view(), beta.t());
2376    let c_sym = &c + &c.t();
2377    let ymt = dense_ab(y, m.t());
2378    let xcs = dense_ab(x, c_sym.view());
2379    for i in 0..x.nrows() {
2380        let wi = weights[i] * scale;
2381        for k in 0..x.ncols() {
2382            grad_x[[i, k]] += wi * (ymt[[i, k]] - xcs[[i, k]]);
2383        }
2384    }
2385
2386    let xm = dense_ab(x, m.view());
2387    for i in 0..x.nrows() {
2388        let wi = weights[i] * scale;
2389        for j in 0..y.ncols() {
2390            grad_y[[i, j]] += wi * xm[[i, j]];
2391        }
2392    }
2393
2394    let xc = dense_ab(x, c.view());
2395    for i in 0..x.nrows() {
2396        let mut from_b = 0.0;
2397        for j in 0..y.ncols() {
2398            from_b += y[[i, j]] * xm[[i, j]];
2399        }
2400        let mut from_a = 0.0;
2401        for k in 0..x.ncols() {
2402            from_a += x[[i, k]] * xc[[i, k]];
2403        }
2404        grad_weights[i] += scale * (from_b - from_a);
2405    }
2406
2407    for row in 0..penalty.nrows() {
2408        for col in 0..penalty.ncols() {
2409            let mut value = 0.0;
2410            for output in 0..beta.ncols() {
2411                value += m[[row, output]] * beta[[col, output]];
2412            }
2413            grad_penalty[[row, col]] -= scale * lambda * value;
2414        }
2415    }
2416    m
2417}
2418
2419/// Ridge-profile VJP for callers that also need `∂L/∂λ`.
2420///
2421/// Accumulates the data/penalty/weight partials and adds the implicit-function
2422/// λ-adjoint contribution `−scale · ⟨M^{-1} u, S β⟩` into `lambda_adjoint_out`.
2423fn add_ridge_profile_vjp_with_lambda_grad(
2424    scale: f64,
2425    x: ArrayView2<'_, f64>,
2426    y: ArrayView2<'_, f64>,
2427    penalty: ArrayView2<'_, f64>,
2428    weights: &Array1<f64>,
2429    lambda: f64,
2430    inverse_hessian: &Array2<f64>,
2431    beta: &Array2<f64>,
2432    upstream_beta: ArrayView2<'_, f64>,
2433    grad_x: &mut Array2<f64>,
2434    grad_y: &mut Array2<f64>,
2435    grad_penalty: &mut Array2<f64>,
2436    grad_weights: &mut Array1<f64>,
2437    lambda_adjoint_out: &mut f64,
2438) {
2439    let m = ridge_profile_vjp_data_partials(
2440        scale,
2441        x,
2442        y,
2443        penalty,
2444        weights,
2445        lambda,
2446        inverse_hessian,
2447        beta,
2448        upstream_beta,
2449        grad_x,
2450        grad_y,
2451        grad_penalty,
2452        grad_weights,
2453    );
2454    let penalty_beta = dense_ab(penalty, beta.view());
2455    let dot = m
2456        .iter()
2457        .zip(penalty_beta.iter())
2458        .map(|(left, right)| left * right)
2459        .sum::<f64>();
2460    *lambda_adjoint_out += -scale * dot;
2461}
2462
2463/// Ridge-profile VJP for callers that hold λ fixed (e.g. the implicit-root
2464/// partial inside `add_reml_rho_gradient_vjp`). The λ-adjoint dot product is
2465/// skipped entirely — it would be unused work in this branch.
2466fn add_ridge_profile_vjp_fixed_lambda(
2467    scale: f64,
2468    x: ArrayView2<'_, f64>,
2469    y: ArrayView2<'_, f64>,
2470    penalty: ArrayView2<'_, f64>,
2471    weights: &Array1<f64>,
2472    lambda: f64,
2473    inverse_hessian: &Array2<f64>,
2474    beta: &Array2<f64>,
2475    upstream_beta: ArrayView2<'_, f64>,
2476    grad_x: &mut Array2<f64>,
2477    grad_y: &mut Array2<f64>,
2478    grad_penalty: &mut Array2<f64>,
2479    grad_weights: &mut Array1<f64>,
2480) {
2481    ridge_profile_vjp_data_partials(
2482        scale,
2483        x,
2484        y,
2485        penalty,
2486        weights,
2487        lambda,
2488        inverse_hessian,
2489        beta,
2490        upstream_beta,
2491        grad_x,
2492        grad_y,
2493        grad_penalty,
2494        grad_weights,
2495    );
2496}
2497
2498fn add_reml_score_vjp(
2499    scale: f64,
2500    x: ArrayView2<'_, f64>,
2501    weights: &Array1<f64>,
2502    inverse_hessian: &Array2<f64>,
2503    beta: &Array2<f64>,
2504    residual: &Array2<f64>,
2505    sigma2: &Array1<f64>,
2506    nu: f64,
2507    lambda: f64,
2508    cache: &GaussianRemlEigenCache,
2509    grad_x: &mut Array2<f64>,
2510    grad_y: &mut Array2<f64>,
2511    grad_penalty: &mut Array2<f64>,
2512    grad_weights: &mut Array1<f64>,
2513) {
2514    let d = beta.ncols() as f64;
2515    let xp = dense_ab(x, inverse_hessian.view());
2516    let penalty_pinv = gaussian_reml_penalty_pseudoinverse_from_cache(cache);
2517    for row in 0..grad_penalty.nrows() {
2518        for col in 0..grad_penalty.ncols() {
2519            grad_penalty[[row, col]] +=
2520                scale * 0.5 * d * (lambda * inverse_hessian[[col, row]] - penalty_pinv[[col, row]]);
2521        }
2522    }
2523    for i in 0..x.nrows() {
2524        let wi = weights[i] * scale * d;
2525        for k in 0..x.ncols() {
2526            grad_x[[i, k]] += wi * xp[[i, k]];
2527        }
2528        let mut leverage = 0.0;
2529        for k in 0..x.ncols() {
2530            leverage += x[[i, k]] * xp[[i, k]];
2531        }
2532        grad_weights[i] += scale * 0.5 * d * leverage;
2533    }
2534
2535    for j in 0..beta.ncols() {
2536        let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2537        let coef = scale * 0.5 * nu / dp;
2538        add_deviance_profile_vjp(
2539            coef,
2540            j,
2541            x,
2542            weights,
2543            beta,
2544            residual,
2545            grad_x,
2546            grad_y,
2547            grad_weights,
2548        );
2549        add_rank_one_penalty_vjp(coef * lambda, beta.column(j), grad_penalty);
2550    }
2551}
2552
2553/// VJP contribution from an upstream gradient on `edf`.
2554///
2555/// With `M = X^T W X + λ S`, `edf = trace(M^{-1} · X^T W X) = p - λ trace(M^{-1} S)`.
2556/// Holding `λ` fixed, the direct partials are
2557///   ∂edf/∂A = λ M^{-1} S M^{-1}      (A = X^T W X, symmetric)
2558///   ∂edf/∂S = −λ M^{-1} A M^{-1} = −λ M^{-1} + λ² M^{-1} S M^{-1}
2559///   ∂edf/∂λ = −trace(M^{-1} S) + λ trace((M^{-1} S)²)
2560/// The λ-component is returned as the lambda_adjoint contribution and routed
2561/// through the implicit-function chain by the caller (same path as
2562/// `upstream_lambda` and `upstream_reml_score`).
2563fn add_edf_vjp(
2564    scale: f64,
2565    x: ArrayView2<'_, f64>,
2566    penalty: ArrayView2<'_, f64>,
2567    weights: &Array1<f64>,
2568    lambda: f64,
2569    inverse_hessian: &Array2<f64>,
2570    grad_x: &mut Array2<f64>,
2571    grad_penalty: &mut Array2<f64>,
2572    grad_weights: &mut Array1<f64>,
2573) -> f64 {
2574    // m_inv_s = M^{-1} S, then g_a = λ M^{-1} S M^{-1} = ∂edf/∂A.
2575    let m_inv_s = dense_ab(inverse_hessian.view(), penalty);
2576    let mut g_a = dense_ab(m_inv_s.view(), inverse_hessian.view());
2577    g_a.mapv_inplace(|v| v * lambda);
2578
2579    // Chain ∂edf/∂A through A = X^T W X.
2580    //   grad_X += scale · 2 · (W X) · G_A
2581    //   grad_w_i += scale · (X G_A X^T)_{ii}
2582    let xg = dense_ab(x, g_a.view());
2583    // Row-scaled dense accumulate: grad_x[i,:] += (2·scale·weights[i]) · xg[i,:].
2584    // (Inlined here — the former `assembly::add_row_scaled_dense_into` helper was
2585    // removed as "unused" by 0cb722d, which missed this gam-pyffi-reachable caller.)
2586    let leading_scale = 2.0 * scale;
2587    for i in 0..xg.nrows() {
2588        let row_scale = leading_scale * weights[i];
2589        for k in 0..xg.ncols() {
2590            grad_x[[i, k]] += row_scale * xg[[i, k]];
2591        }
2592    }
2593    for i in 0..x.nrows() {
2594        let mut quad = 0.0;
2595        for k in 0..x.ncols() {
2596            quad += x[[i, k]] * xg[[i, k]];
2597        }
2598        grad_weights[i] += scale * quad;
2599    }
2600
2601    // ∂edf/∂S = -λ M^{-1} + λ² M^{-1} S M^{-1} = -λ M^{-1} + λ · g_a
2602    // (since g_a = λ M^{-1} S M^{-1}, so λ · g_a = λ² M^{-1} S M^{-1}).
2603    for row in 0..grad_penalty.nrows() {
2604        for col in 0..grad_penalty.ncols() {
2605            grad_penalty[[row, col]] +=
2606                scale * (-lambda * inverse_hessian[[row, col]] + lambda * g_a[[row, col]]);
2607        }
2608    }
2609
2610    // ∂edf/∂λ (with A, S fixed) = -tr(M^{-1} S) + λ tr((M^{-1} S)²).
2611    let p_dim = m_inv_s.nrows();
2612    let mut tr_m_inv_s = 0.0;
2613    for i in 0..p_dim {
2614        tr_m_inv_s += m_inv_s[[i, i]];
2615    }
2616    let mut tr_squared = 0.0;
2617    for i in 0..p_dim {
2618        for j in 0..p_dim {
2619            tr_squared += m_inv_s[[i, j]] * m_inv_s[[j, i]];
2620        }
2621    }
2622    scale * (-tr_m_inv_s + lambda * tr_squared)
2623}
2624
2625fn add_reml_rho_gradient_vjp(
2626    scale: f64,
2627    x: ArrayView2<'_, f64>,
2628    y: ArrayView2<'_, f64>,
2629    penalty: ArrayView2<'_, f64>,
2630    weights: &Array1<f64>,
2631    lambda: f64,
2632    inverse_hessian: &Array2<f64>,
2633    beta: &Array2<f64>,
2634    residual: &Array2<f64>,
2635    sigma2: &Array1<f64>,
2636    nu: f64,
2637    grad_x: &mut Array2<f64>,
2638    grad_y: &mut Array2<f64>,
2639    grad_penalty: &mut Array2<f64>,
2640    grad_weights: &mut Array1<f64>,
2641) {
2642    let d = beta.ncols() as f64;
2643    let inverse_s = dense_ab(inverse_hessian.view(), penalty);
2644    let trace_kernel = dense_ab(inverse_s.view(), inverse_hessian.view());
2645    for row in 0..grad_penalty.nrows() {
2646        for col in 0..grad_penalty.ncols() {
2647            grad_penalty[[row, col]] += scale
2648                * 0.5
2649                * d
2650                * lambda
2651                * (inverse_hessian[[col, row]] - lambda * trace_kernel[[col, row]]);
2652        }
2653    }
2654    let xt = dense_ab(x, trace_kernel.view());
2655    for i in 0..x.nrows() {
2656        let wi = -scale * d * lambda * weights[i];
2657        for k in 0..x.ncols() {
2658            grad_x[[i, k]] += wi * xt[[i, k]];
2659        }
2660        let mut quad = 0.0;
2661        for k in 0..x.ncols() {
2662            quad += x[[i, k]] * xt[[i, k]];
2663        }
2664        grad_weights[i] -= scale * 0.5 * d * lambda * quad;
2665    }
2666
2667    let s_beta = dense_ab(penalty, beta.view());
2668    let mut upstream_beta = Array2::<f64>::zeros(beta.dim());
2669    for j in 0..beta.ncols() {
2670        let dp = (sigma2[j] * nu).max(MIN_DEVIANCE);
2671        let q = lambda * beta.column(j).dot(&s_beta.column(j));
2672        let q_coef = scale * nu / dp;
2673        for row in 0..beta.nrows() {
2674            upstream_beta[[row, j]] = q_coef * lambda * s_beta[[row, j]];
2675        }
2676        let dp_coef = -scale * 0.5 * nu * q / (dp * dp);
2677        add_rank_one_penalty_vjp(
2678            (0.5 * q_coef + dp_coef) * lambda,
2679            beta.column(j),
2680            grad_penalty,
2681        );
2682        add_deviance_profile_vjp(
2683            dp_coef,
2684            j,
2685            x,
2686            weights,
2687            beta,
2688            residual,
2689            grad_x,
2690            grad_y,
2691            grad_weights,
2692        );
2693    }
2694    // The implicit-root VJP holds lambda fixed inside this partial; only the
2695    // data, penalty, and weight side effects from the ridge solve are needed.
2696    add_ridge_profile_vjp_fixed_lambda(
2697        1.0,
2698        x,
2699        y,
2700        penalty,
2701        weights,
2702        lambda,
2703        inverse_hessian,
2704        beta,
2705        upstream_beta.view(),
2706        grad_x,
2707        grad_y,
2708        grad_penalty,
2709        grad_weights,
2710    );
2711}
2712
2713fn add_rank_one_penalty_vjp(
2714    scale: f64,
2715    beta_col: ArrayView1<'_, f64>,
2716    grad_penalty: &mut Array2<f64>,
2717) {
2718    for row in 0..beta_col.len() {
2719        for col in 0..beta_col.len() {
2720            grad_penalty[[row, col]] += scale * beta_col[row] * beta_col[col];
2721        }
2722    }
2723}
2724
2725fn gaussian_reml_penalty_pseudoinverse_from_cache(cache: &GaussianRemlEigenCache) -> Array2<f64> {
2726    let p = cache.penalty_eigenvalues.len();
2727    let mut scaled_basis = Array2::<f64>::zeros((p, p));
2728    for eig in 0..p {
2729        let delta = cache.penalty_eigenvalues[eig];
2730        if delta > 0.0 {
2731            for row in 0..p {
2732                scaled_basis[[row, eig]] = cache.coefficient_basis[[row, eig]] / delta;
2733            }
2734        }
2735    }
2736    dense_ab(scaled_basis.view(), cache.coefficient_basis.t())
2737}
2738
2739fn add_deviance_profile_vjp(
2740    scale: f64,
2741    output: usize,
2742    x: ArrayView2<'_, f64>,
2743    weights: &Array1<f64>,
2744    beta: &Array2<f64>,
2745    residual: &Array2<f64>,
2746    grad_x: &mut Array2<f64>,
2747    grad_y: &mut Array2<f64>,
2748    grad_weights: &mut Array1<f64>,
2749) {
2750    for i in 0..x.nrows() {
2751        let r = residual[[i, output]];
2752        let wr_scale = scale * weights[i] * r;
2753        grad_y[[i, output]] += 2.0 * wr_scale;
2754        for k in 0..x.ncols() {
2755            grad_x[[i, k]] -= 2.0 * wr_scale * beta[[k, output]];
2756        }
2757        grad_weights[i] += scale * r * r;
2758    }
2759}
2760
2761fn validate_initial_lambda(lambda: f64) -> Result<f64, EstimationError> {
2762    if lambda.is_finite() && lambda > 0.0 {
2763        Ok(lambda)
2764    } else {
2765        Err(EstimationError::InvalidInput(format!(
2766            "Gaussian REML initial lambda must be finite and positive; got {lambda}"
2767        )))
2768    }
2769}
2770
2771fn dense_ab(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
2772    fast_ab(&a, &b)
2773}
2774
2775fn dense_atb(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Array2<f64> {
2776    fast_atb(&a, &b)
2777}
2778
2779fn dense_xt_diag_x(x: ArrayView2<'_, f64>, w: ArrayView1<'_, f64>) -> Array2<f64> {
2780    fast_xt_diag_x(&x, &w)
2781}
2782
2783fn dense_xt_diag_y(
2784    x: ArrayView2<'_, f64>,
2785    w: ArrayView1<'_, f64>,
2786    y: ArrayView2<'_, f64>,
2787) -> Array2<f64> {
2788    fast_xt_diag_y(&x, &w, &y)
2789}
2790
2791fn matrix_fingerprint(matrix: ArrayView2<'_, f64>) -> u64 {
2792    let mut hash = 0xcbf29ce484222325_u64;
2793    hash = fnv1a_mix(hash, matrix.nrows() as u64);
2794    hash = fnv1a_mix(hash, matrix.ncols() as u64);
2795    for &value in matrix {
2796        hash = fnv1a_mix(hash, value.to_bits());
2797    }
2798    hash
2799}
2800
2801fn fnv1a_mix(hash: u64, value: u64) -> u64 {
2802    (hash ^ value).wrapping_mul(0x100000001b3)
2803}
2804
2805/// Build eigen caches for K problems that share the same penalty matrix in a
2806/// single phased pipeline. X'WX construction is batched by the caller; each
2807/// cache then uses the same Cholesky/eigendecomposition implementation as the
2808/// single-fit path.
2809pub fn build_gaussian_reml_eigen_cache_batched(
2810    xtwx_matrices: Vec<Array2<f64>>,
2811    penalty: ArrayView2<'_, f64>,
2812    nullspace_dim: Option<usize>,
2813) -> Vec<Result<GaussianRemlEigenCache, EstimationError>> {
2814    let penalty_owned = canonicalize_penalty(penalty);
2815    let penalty = penalty_owned.view();
2816    let k = xtwx_matrices.len();
2817    if k == 0 {
2818        return Vec::new();
2819    }
2820    let fingerprints: Vec<u64> = xtwx_matrices
2821        .iter()
2822        .map(|m| matrix_fingerprint(m.view()))
2823        .collect();
2824
2825    let p = xtwx_matrices[0].nrows();
2826    let uniform_square = p > 0 && xtwx_matrices.iter().all(|matrix| matrix.dim() == (p, p));
2827    if uniform_square && k > 1 {
2828        let mut lower_matrices = xtwx_matrices.clone();
2829        if gam_gpu::try_cholesky_batched_lower_inplace(&mut lower_matrices).is_some() {
2830            // The batched penalty transform is an optional accelerator. On
2831            // failure we must NOT fabricate an empty Vec (indexing it per-block
2832            // would silently drop the transform for every block and could index
2833            // out of range) — instead route every block through the same
2834            // no-GPU-transform path used when the batched transform is
2835            // unavailable, which recomputes the whitened penalty on CPU from the
2836            // already-valid Cholesky factor `lower`.
2837            let transforms = batched_whitened_penalty_transforms(&lower_matrices, penalty);
2838            return lower_matrices
2839                .into_iter()
2840                .enumerate()
2841                .map(|(b, lower)| {
2842                    let precomputed_transform = transforms.as_ref().map(|t| t[b].clone());
2843                    gaussian_reml_eigen_cache_from_lower_with_transform(
2844                        lower,
2845                        penalty,
2846                        nullspace_dim,
2847                        fingerprints[b],
2848                        precomputed_transform,
2849                    )
2850                })
2851                .collect();
2852        }
2853    }
2854
2855    let mut results = Vec::with_capacity(k);
2856    for (b, xtwx) in xtwx_matrices.into_iter().enumerate() {
2857        let lower = match gaussian_reml_cholesky_lower(xtwx) {
2858            Ok(l) => l,
2859            Err(err) => {
2860                results.push(Err(err));
2861                continue;
2862            }
2863        };
2864        results.push(gaussian_reml_eigen_cache_from_lower_with_transform(
2865            lower,
2866            penalty,
2867            nullspace_dim,
2868            fingerprints[b],
2869            None,
2870        ));
2871    }
2872    results
2873}
2874
2875fn batched_whitened_penalty_transforms(
2876    lowers: &[Array2<f64>],
2877    penalty: ArrayView2<'_, f64>,
2878) -> Option<Vec<Array2<f64>>> {
2879    let first = lowers.first()?;
2880    let p = first.nrows();
2881    if p == 0 || first.ncols() != p || lowers.iter().any(|lower| lower.dim() != (p, p)) {
2882        return None;
2883    }
2884    let mut linv_stack = Array3::<f64>::zeros((lowers.len(), p, p));
2885    for (idx, lower) in lowers.iter().enumerate() {
2886        let l_inv = invert_lower_triangular(lower).ok()?;
2887        linv_stack.slice_mut(s![idx, .., ..]).assign(&l_inv);
2888    }
2889    let penalty_in_metric = gam_gpu::try_fast_ab_broadcast_b_batched(linv_stack.view(), penalty)?;
2890    let transformed =
2891        gam_gpu::try_fast_abt_strided_batched(penalty_in_metric.view(), linv_stack.view())?;
2892    Some(
2893        transformed
2894            .axis_iter(Axis(0))
2895            .map(|matrix| matrix.to_owned())
2896            .collect(),
2897    )
2898}
2899
2900pub fn build_gaussian_reml_eigen_cache(
2901    x: ArrayView2<'_, f64>,
2902    penalty: ArrayView2<'_, f64>,
2903    weights: Option<ArrayView1<'_, f64>>,
2904) -> Result<GaussianRemlEigenCache, EstimationError> {
2905    build_gaussian_reml_eigen_cache_with_nullspace_dim(x, penalty, None, weights)
2906}
2907
2908pub fn build_gaussian_reml_eigen_cache_with_nullspace_dim(
2909    x: ArrayView2<'_, f64>,
2910    penalty: ArrayView2<'_, f64>,
2911    nullspace_dim: Option<usize>,
2912    weights: Option<ArrayView1<'_, f64>>,
2913) -> Result<GaussianRemlEigenCache, EstimationError> {
2914    let penalty_owned = canonicalize_penalty(penalty);
2915    let penalty = penalty_owned.view();
2916    let n = x.nrows();
2917    validate_gaussian_reml_design(x, penalty, weights)?;
2918    let weight = gaussian_reml_weights(n, weights)?;
2919
2920    let xtwx = dense_xt_diag_x(x, weight.view());
2921    gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)
2922}
2923
2924fn validate_gaussian_reml_design(
2925    x: ArrayView2<'_, f64>,
2926    penalty: ArrayView2<'_, f64>,
2927    weights: Option<ArrayView1<'_, f64>>,
2928) -> Result<(), EstimationError> {
2929    let n = x.nrows();
2930    let p = x.ncols();
2931    if penalty.nrows() != p || penalty.ncols() != p {
2932        crate::bail_invalid_estim!(
2933            "Gaussian REML penalty shape mismatch: expected {p}x{p}, got {}x{}",
2934            penalty.nrows(),
2935            penalty.ncols()
2936        );
2937    }
2938    if x.iter().chain(penalty.iter()).any(|v| !v.is_finite()) {
2939        crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
2940    }
2941    if let Some(w) = weights {
2942        if w.len() != n {
2943            crate::bail_invalid_estim!(
2944                "Gaussian REML weights length mismatch: expected {n}, got {}",
2945                w.len()
2946            );
2947        }
2948        if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
2949            crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
2950        }
2951    }
2952    Ok(())
2953}
2954
2955/// Effective observation count for the REML residual degrees of freedom.
2956///
2957/// A prior weight of exactly `0` is the universal "excluded / infinite-variance"
2958/// convention (mgcv, statsmodels): such a row must be equivalent to omitting it
2959/// entirely. The weighted response energy already handles this (`weight[row] *
2960/// y² = 0` for a zero-weight row), and a zero-weight row likewise contributes
2961/// nothing to `XᵀWX` / `XᵀWy`, so it cannot move the coefficients at a fixed
2962/// smoothing parameter. The one place a zero-weight row used to leak in was the
2963/// residual degrees of freedom `ν = n − nullity`, which counted the raw row
2964/// count `n`. That deflated `σ²`, under-smoothed `λ`, and (through `λ`) biased
2965/// the coefficients — growing with the number of zero-weight rows. The residual
2966/// DoF must instead be built from the number of rows that actually enter the
2967/// likelihood, i.e. those with a strictly positive weight.
2968fn effective_observation_count(weight: ArrayView1<'_, f64>) -> usize {
2969    weight.iter().filter(|&&w| w > 0.0).count()
2970}
2971
2972fn gaussian_reml_weights(
2973    n: usize,
2974    weights: Option<ArrayView1<'_, f64>>,
2975) -> Result<Array1<f64>, EstimationError> {
2976    match weights {
2977        Some(w) => {
2978            if w.len() != n {
2979                crate::bail_invalid_estim!(
2980                    "Gaussian REML weights length mismatch: expected {n}, got {}",
2981                    w.len()
2982                );
2983            }
2984            if w.iter().any(|value| !value.is_finite() || *value < 0.0) {
2985                crate::bail_invalid_estim!("Gaussian REML weights must be finite and non-negative");
2986            }
2987            Ok(w.to_owned())
2988        }
2989        None => Ok(Array1::ones(n)),
2990    }
2991}
2992
2993fn gaussian_reml_eigen_cache_from_xtwx(
2994    xtwx: Array2<f64>,
2995    penalty: ArrayView2<'_, f64>,
2996    nullspace_dim: Option<usize>,
2997) -> Result<GaussianRemlEigenCache, EstimationError> {
2998    let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
2999    let lower = gaussian_reml_cholesky_lower(xtwx)?;
3000    gaussian_reml_eigen_cache_from_lower(lower, penalty, nullspace_dim, xtwx_fingerprint)
3001}
3002
3003/// Cache-build entry point for callers that have already computed `L =
3004/// chol(X'WX, lower)`. Used by the batched K-way fit path so a single
3005/// `cusolverDnDpotrfBatched` call factors all K matrices, then each cache
3006/// finishes per-fit without re-doing the Cholesky.
3007fn gaussian_reml_eigen_cache_from_lower(
3008    lower: Array2<f64>,
3009    penalty: ArrayView2<'_, f64>,
3010    nullspace_dim: Option<usize>,
3011    xtwx_fingerprint: u64,
3012) -> Result<GaussianRemlEigenCache, EstimationError> {
3013    gaussian_reml_eigen_cache_from_lower_with_transform(
3014        lower,
3015        penalty,
3016        nullspace_dim,
3017        xtwx_fingerprint,
3018        None,
3019    )
3020}
3021
3022/// Cache-build variant that accepts a pre-computed whitened penalty
3023/// `L⁻¹·S·L⁻ᵀ`. Callers pass `None` to compute it from the Cholesky factor.
3024fn gaussian_reml_eigen_cache_from_lower_with_transform(
3025    lower: Array2<f64>,
3026    penalty: ArrayView2<'_, f64>,
3027    nullspace_dim: Option<usize>,
3028    xtwx_fingerprint: u64,
3029    precomputed_transform: Option<Array2<f64>>,
3030) -> Result<GaussianRemlEigenCache, EstimationError> {
3031    let p = lower.nrows();
3032    if lower.ncols() != p {
3033        crate::bail_invalid_estim!("Gaussian REML Cholesky factor must be square");
3034    }
3035    let penalty_fingerprint = matrix_fingerprint(penalty);
3036    let logdet_xtwx = 2.0 * lower.diag().iter().map(|v| v.ln()).sum::<f64>();
3037    let transformed_penalty = match precomputed_transform {
3038        Some(transformed) => transformed,
3039        None => {
3040            let l_inv = invert_lower_triangular(&lower)?;
3041            let penalty_in_metric = dense_ab(l_inv.view(), penalty);
3042            dense_ab(penalty_in_metric.view(), l_inv.t())
3043        }
3044    };
3045    let (mut penalty_eigenvalues, eigenvectors) =
3046        transformed_penalty.eigh(Side::Lower).map_err(|_| {
3047            EstimationError::ModelIsIllConditioned {
3048                condition_number: f64::INFINITY,
3049            }
3050        })?;
3051    // Rank tolerance must be RELATIVE to the largest eigenvalue — never
3052    // floored at an absolute value. The old `.max(1.0)` clamped the
3053    // tolerance up whenever max|eig| < 1, classifying genuine modes as
3054    // null for small-scale penalties (e.g. Wahba pseudo-spline `m=4`
3055    // with `K(p,p) ≈ 3e-4`). That broke REML's invariance under
3056    // `S → c·S` — the optimum λ rescales but the score landscape
3057    // diverges from the true marginal likelihood, and the smooth
3058    // contribution collapsed to ~0 on smooth truths.
3059    // Fully scale-invariant form: `safety · max|eig| · eps`.
3060    let max_abs_eig = penalty_eigenvalues
3061        .iter()
3062        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3063    let eig_tol = max_abs_eig * EIGEN_REL_TOL;
3064    for value in &mut penalty_eigenvalues {
3065        if *value < 0.0 && value.abs() <= eig_tol {
3066            *value = 0.0;
3067        }
3068        if *value < 0.0 {
3069            crate::bail_invalid_estim!(
3070                "Gaussian REML penalty is not positive semidefinite; eigenvalue={value:.3e}"
3071            );
3072        }
3073    }
3074    let penalty_rank = penalty_eigenvalues
3075        .iter()
3076        .filter(|&&value| value > eig_tol)
3077        .count();
3078    let nullity = p - penalty_rank;
3079    if let Some(expected_nullity) = nullspace_dim
3080        && expected_nullity != nullity
3081    {
3082        crate::bail_invalid_estim!(
3083            "Gaussian REML penalty nullspace mismatch: expected {expected_nullity}, inferred {nullity}"
3084        );
3085    }
3086    let logdet_penalty_positive = gaussian_penalty_positive_logdet(penalty, penalty_rank)?;
3087    let coefficient_basis = solve_upper_triangular_matrix(&lower.t().to_owned(), &eigenvectors)?;
3088
3089    Ok(GaussianRemlEigenCache {
3090        penalty_eigenvalues,
3091        eigenvectors,
3092        coefficient_basis,
3093        xtwx_fingerprint,
3094        penalty_fingerprint,
3095        logdet_xtwx,
3096        logdet_penalty_positive,
3097        penalty_rank,
3098        nullity,
3099    })
3100}
3101
3102fn gaussian_reml_cholesky_lower(xtwx: Array2<f64>) -> Result<Array2<f64>, EstimationError> {
3103    // Attempt Cholesky directly; on failure, retry with a tiny diagonal jitter
3104    // proportional to the matrix trace. X'WX is symmetric positive semidefinite
3105    // by construction, but FP noise (e.g. in a basis whose kernel block is only
3106    // FP-orthogonal to its explicit polynomial nullspace columns, as the
3107    // periodic Duchon basis is) can push the smallest eigenvalue slightly
3108    // negative on adversarial inputs, intermittently failing Cholesky. A
3109    // jitter of 1e-12 * trace/p shifts every eigenvalue up by an amount well
3110    // below the natural scale of the well-conditioned eigenvalues but well
3111    // above f64 FP noise, eliminating the spurious-failure regime.
3112    let mut gpu_candidate = xtwx.clone();
3113    if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3114        return Ok(gpu_candidate);
3115    }
3116    if let Ok(chol) = xtwx.cholesky(Side::Lower) {
3117        return Ok(chol.lower_triangular());
3118    }
3119    let p = xtwx.nrows();
3120    let trace: f64 = (0..p).map(|i| xtwx[[i, i]]).sum();
3121    if !trace.is_finite() || trace <= 0.0 {
3122        return Err(EstimationError::ModelIsIllConditioned {
3123            condition_number: f64::INFINITY,
3124        });
3125    }
3126    escalate_ridge(
3127        RidgeSchedule::geometric(1e-12 * trace / (p as f64), 6),
3128        |jitter| {
3129            let mut jittered = xtwx.clone();
3130            for i in 0..p {
3131                jittered[[i, i]] += jitter;
3132            }
3133            let mut gpu_candidate = jittered.clone();
3134            if gam_gpu::try_cholesky_lower_inplace(&mut gpu_candidate).is_some() {
3135                return Some(gpu_candidate);
3136            }
3137            jittered
3138                .cholesky(Side::Lower)
3139                .ok()
3140                .map(|chol| chol.lower_triangular())
3141        },
3142    )
3143    .map(|success| success.value)
3144    .map_err(|_exhausted| EstimationError::ModelIsIllConditioned {
3145        condition_number: f64::INFINITY,
3146    })
3147}
3148
3149fn gaussian_penalty_positive_logdet(
3150    penalty: ArrayView2<'_, f64>,
3151    penalty_rank: usize,
3152) -> Result<f64, EstimationError> {
3153    if penalty_rank == 0 {
3154        return Ok(0.0);
3155    }
3156    let (pen_eigs, _) = penalty.to_owned().eigh(Side::Lower).map_err(|_| {
3157        EstimationError::ModelIsIllConditioned {
3158            condition_number: f64::INFINITY,
3159        }
3160    })?;
3161    // Scale-invariant relative tolerance — see the cousin site for the
3162    // rationale. Same `.max(1.0)` floor used to live here and corrupted
3163    // the positive-eigenvalue count for small-scale penalties.
3164    let pen_scale = pen_eigs
3165        .iter()
3166        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3167    let pen_tol = pen_scale * EIGEN_REL_TOL;
3168    let mut positive_eigs: Vec<f64> = pen_eigs
3169        .iter()
3170        .copied()
3171        .filter(|&value| value > pen_tol)
3172        .collect();
3173    if positive_eigs.len() != penalty_rank {
3174        positive_eigs = pen_eigs
3175            .iter()
3176            .copied()
3177            .filter(|&value| value > 0.0)
3178            .collect();
3179        positive_eigs.sort_by(|a, b| b.total_cmp(a));
3180        if positive_eigs.len() < penalty_rank {
3181            return Err(EstimationError::ModelIsIllConditioned {
3182                condition_number: f64::INFINITY,
3183            });
3184        }
3185        positive_eigs.truncate(penalty_rank);
3186    }
3187    Ok(positive_eigs.iter().map(|value| value.ln()).sum())
3188}
3189
3190fn validate_gaussian_reml_eigen_cache(
3191    cache: &GaussianRemlEigenCache,
3192    p: usize,
3193) -> Result<(), EstimationError> {
3194    if cache.penalty_eigenvalues.len() != p
3195        || cache.eigenvectors.dim() != (p, p)
3196        || cache.coefficient_basis.dim() != (p, p)
3197    {
3198        crate::bail_invalid_estim!(
3199            "Gaussian REML eigen cache dimension mismatch: expected {p} coefficients"
3200        );
3201    }
3202    if cache.penalty_rank > p || cache.nullity > p || cache.penalty_rank + cache.nullity != p {
3203        crate::bail_invalid_estim!(
3204            "Gaussian REML eigen cache rank/nullity mismatch: rank={}, nullity={}, p={p}",
3205            cache.penalty_rank,
3206            cache.nullity
3207        );
3208    }
3209    if !(cache.logdet_xtwx.is_finite() && cache.logdet_penalty_positive.is_finite()) {
3210        crate::bail_invalid_estim!("Gaussian REML eigen cache log-determinants must be finite");
3211    }
3212    if cache
3213        .penalty_eigenvalues
3214        .iter()
3215        .any(|value| !value.is_finite() || *value < 0.0)
3216        || cache.eigenvectors.iter().any(|value| !value.is_finite())
3217        || cache
3218            .coefficient_basis
3219            .iter()
3220            .any(|value| !value.is_finite())
3221    {
3222        crate::bail_invalid_estim!(
3223            "Gaussian REML eigen cache entries must be finite with non-negative eigenvalues"
3224                .to_string(),
3225        );
3226    }
3227    Ok::<(), _>(())
3228}
3229
3230fn prepare_gaussian_reml(
3231    x: ArrayView2<'_, f64>,
3232    y: ArrayView2<'_, f64>,
3233    penalty: ArrayView2<'_, f64>,
3234    nullspace_dim: Option<usize>,
3235    weights: Option<ArrayView1<'_, f64>>,
3236    eigen_cache: Option<&GaussianRemlEigenCache>,
3237) -> Result<GaussianRemlPrepared, EstimationError> {
3238    // Enforce the symmetric-S contract once at the central forward chokepoint;
3239    // every closed-form forward path funnels through here.
3240    let penalty_owned = canonicalize_penalty(penalty);
3241    let penalty = penalty_owned.view();
3242    let n = x.nrows();
3243    let p = x.ncols();
3244    let d = y.ncols();
3245    validate_gaussian_reml_design(x, penalty, weights)?;
3246    if y.nrows() != n {
3247        crate::bail_invalid_estim!(
3248            "Gaussian REML row mismatch: X has {n} rows but Y has {}",
3249            y.nrows()
3250        );
3251    }
3252    if y.iter().any(|v| !v.is_finite()) {
3253        crate::bail_invalid_estim!("Gaussian REML inputs must be finite");
3254    }
3255    let weight = gaussian_reml_weights(n, weights)?;
3256    let n_effective = effective_observation_count(weight.view());
3257
3258    let xtwy = dense_xt_diag_y(x, weight.view(), y);
3259    let ywy = Array1::from_iter((0..d).map(|j| {
3260        let mut value = 0.0;
3261        for row in 0..n {
3262            value += weight[row] * y[[row, j]] * y[[row, j]];
3263        }
3264        value
3265    }));
3266    let xtwx = dense_xt_diag_x(x, weight.view());
3267
3268    if let Some(cache) = eigen_cache {
3269        validate_gaussian_reml_eigen_cache(cache, p)?;
3270        let xtwx_fingerprint = matrix_fingerprint(xtwx.view());
3271        if cache.xtwx_fingerprint != xtwx_fingerprint {
3272            crate::bail_invalid_estim!("Gaussian REML eigen cache X'WX mismatch");
3273        }
3274        let penalty_fingerprint = matrix_fingerprint(penalty);
3275        if cache.penalty_fingerprint != penalty_fingerprint {
3276            crate::bail_invalid_estim!("Gaussian REML eigen cache penalty mismatch");
3277        }
3278        if let Some(expected_nullity) = nullspace_dim
3279            && expected_nullity != cache.nullity
3280        {
3281            crate::bail_invalid_estim!(
3282                "Gaussian REML eigen cache nullspace mismatch: expected {expected_nullity}, got {}",
3283                cache.nullity
3284            );
3285        }
3286        if n_effective <= cache.nullity {
3287            crate::bail_invalid_estim!(
3288                "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3289                cache.nullity
3290            );
3291        }
3292        let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3293        let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3294        return Ok(GaussianRemlPrepared {
3295            cache: cache.clone(),
3296            ywy,
3297            projected_rhs_squared,
3298            projected_rhs,
3299            n_effective,
3300            n_outputs: d,
3301        });
3302    }
3303
3304    let cache = gaussian_reml_eigen_cache_from_xtwx(xtwx, penalty, nullspace_dim)?;
3305    if n_effective <= cache.nullity {
3306        crate::bail_invalid_estim!(
3307            "Gaussian REML requires more positive-weight rows than the nullspace dimension; got n_effective={n_effective}, nullity={}",
3308            cache.nullity
3309        );
3310    }
3311    let projected_rhs = dense_atb(cache.coefficient_basis.view(), xtwy.view());
3312    let projected_rhs_squared = projected_rhs.mapv(|value| value * value);
3313
3314    Ok(GaussianRemlPrepared {
3315        cache,
3316        ywy,
3317        projected_rhs_squared,
3318        projected_rhs,
3319        n_effective,
3320        n_outputs: d,
3321    })
3322}
3323
3324impl GaussianRemlPrepared {
3325    fn nu(&self) -> f64 {
3326        self.n_effective as f64 - self.cache.nullity as f64
3327    }
3328
3329    fn evaluate(&self, rho: f64) -> ObjectiveEval {
3330        evaluate_reml_parts(
3331            &self.cache,
3332            self.ywy.view(),
3333            self.projected_rhs_squared.view(),
3334            self.n_effective,
3335            self.n_outputs,
3336            rho,
3337        )
3338    }
3339
3340    fn coefficients(&self, lambda: f64) -> Array2<f64> {
3341        let mut scaled = self.projected_rhs.clone();
3342        for i in 0..self.cache.penalty_eigenvalues.len() {
3343            let scale = 1.0 / (1.0 + lambda * self.cache.penalty_eigenvalues[i]);
3344            for value in scaled.row_mut(i) {
3345                *value *= scale;
3346            }
3347        }
3348        dense_ab(self.cache.coefficient_basis.view(), scaled.view())
3349    }
3350
3351    fn sigma2(&self, lambda: f64) -> Array1<f64> {
3352        let nu = self.nu();
3353        Array1::from_iter((0..self.n_outputs).map(|j| {
3354            let mut fitted_quadratic = 0.0;
3355            for i in 0..self.cache.penalty_eigenvalues.len() {
3356                let denom = 1.0 + lambda * self.cache.penalty_eigenvalues[i];
3357                fitted_quadratic += self.projected_rhs_squared[[i, j]] / denom;
3358            }
3359            (self.ywy[j] - fitted_quadratic) / nu
3360        }))
3361    }
3362}
3363
3364/// Certify that every profiled residual is strictly positive at `rho`.
3365/// Residual deviance is monotone increasing in rho, so validating the lower
3366/// search boundary certifies the log-dispersion domain on the entire window.
3367/// A zero/perfect-fit residual has no finite profiled Gaussian scale and must be
3368/// refused; replacing it with a tiny constant would change both the objective
3369/// and its derivatives.
3370fn validate_reml_profile_residuals(
3371    cache: &GaussianRemlEigenCache,
3372    ywy: ArrayView1<'_, f64>,
3373    projected_rhs_squared: ArrayView2<'_, f64>,
3374    rho: f64,
3375) -> Result<(), EstimationError> {
3376    for output in 0..ywy.len() {
3377        let mut fitted_quadratic = 0.0;
3378        for eig in 0..cache.penalty_eigenvalues.len() {
3379            fitted_quadratic += projected_rhs_squared[[eig, output]]
3380                * modal_kernels(rho, cache.penalty_eigenvalues[eig]).v;
3381        }
3382        let residual = ywy[output] - fitted_quadratic;
3383        if !(residual.is_finite() && residual > 0.0) {
3384            return Err(EstimationError::InvalidInput(format!(
3385                "Gaussian REML profiled residual {output} is not strictly positive at rho={rho}: {residual}; the profiled dispersion has no finite value"
3386            )));
3387        }
3388    }
3389    Ok(())
3390}
3391
3392// ============================================================================
3393// Grid-free stationary-point certification for the profiled Gaussian-REML
3394// ρ-objective `V(ρ)` (ρ = ln λ).
3395// ============================================================================
3396//
3397// The previous optimizer sampled `V′` on a fixed 96-point ρ grid and refined
3398// the sign-change cells. A grid can only see stationary points it happens to
3399// bracket: two roots inside one 0.625-wide cell (or a root pair narrower than
3400// the sample spacing) are invisible, so the selected λ̂ was grid-resolution
3401// limited. This replaces the grid with analytic kernel enclosures plus
3402// operation-count roundoff padding. A successful return isolates the stationary
3403// structure to the stated finite-window resolution; an ambiguous cell refuses
3404// the fit through a typed convergence error.
3405//
3406// ---- Analytic structure of V′ (single-sourced with the evaluator) ----------
3407//
3408// With λ = e^ρ and t_i = λ·δ_i (δ_i = `cache.penalty_eigenvalues` ≥ 0), the two
3409// contributions of `gaussian_reml_logdet_term` / `gaussian_reml_dispersion_term`
3410// give, using dt_i/dρ = t_i,
3411//
3412//   V′(ρ) = ½d·( Σ_i t_i/(1+t_i) − rank )                                 (g1)
3413//         + ½ν·Σ_j [ Σ_i c²_ij · t_i/(1+t_i)² ] / dp_j(ρ)                 (g2)
3414//
3415//   dp_j(ρ) = ywy_j − Σ_i c²_ij/(1+t_i)   (residual deviance, strictly > 0,
3416//                                          strictly increasing in ρ).
3417//
3418//   g1: each kernel t/(1+t) ∈ [0,1) is monotone ↑; the sum minus `rank`
3419//       positive eigenvalues is strictly negative and rises to 0⁻ — g1 is
3420//       monotone increasing.
3421//   g2 ≥ 0: numerator kernel t/(1+t)² is a unimodal bump peaking at ¼ when t=1.
3422//
3423// V has poles only at λ = −1/δ_i < 0, i.e. outside the real ρ window, so V is
3424// real-analytic on [RHO_LOWER, RHO_UPPER] ⇒ V′ has finitely many isolated roots
3425// there. That finiteness is what makes exhaustive enumeration well-posed.
3426//
3427// ---- V″ and its kernel critical points -------------------------------------
3428//
3429//   V″(ρ) = ½d·Σ_i t_i/(1+t_i)²
3430//         + ½ν·Σ_j [ dp″_j/dp_j − (dp′_j/dp_j)² ],
3431//   dp′_j = Σ_i c²_ij·t_i/(1+t_i)²,   dp″_j = Σ_i c²_ij·t_i(1−t_i)/(1+t_i)³.
3432//
3433// The only non-monotone / non-unimodal kernel is k(t) = t(1−t)/(1+t)³ in dp″.
3434// Differentiating and clearing (1+t)⁴ (documented derivation):
3435//
3436//   k′(t) = [ (1−2t)(1+t) − 3(t−t²) ] / (1+t)⁴
3437//         = ( 1 − 4t + t² ) / (1+t)⁴.
3438//
3439// So the interior extrema of k are the roots of the fixed quadratic
3440//
3441//        t² − 4t + 1 = 0   ⇒   t = 2 ± √3,
3442//
3443// giving the analytic range for k over any t-window by testing the two endpoints
3444// and whichever of {2−√3, 2+√3} lies strictly inside. Every other kernel is
3445// monotone (t/(1+t), 1/(1+t)) or unimodal with a known peak (t/(1+t)²), so each
3446// admits an endpoint-plus-critical-point range.
3447//
3448// ---- Interval enclosure of (V′, V″) over [a,b] -----------------------------
3449//
3450// log(t_i) ∈ [a+log δ_i, b+log δ_i] (monotone in ρ). Per kernel:
3451//   t/(1+t)   ↑   → endpoint range.
3452//   1/(1+t)   ↓   → endpoint range ⇒ dp endpoints bound dp(a),
3453//                    dp(b) (dp monotone), both > 0.
3454//   t/(1+t)²  unimodal → endpoint range, max replaced by ¼ iff 1∈[t_lo,t_hi].
3455//   k(t)      → endpoints + interior roots 2±√3 (above).
3456// g2 ratio enclosure ½ν·[ Σ num_lo/dp_hi , Σ num_hi/dp_lo ] is conservative
3457// in the ratio. The accumulated bounds are widened by a gamma_n roundoff budget
3458// and checked against both endpoint jets before a cell may be pruned.
3459//
3460// ---- Branch-and-bound (DFS, fixed stack, no heap in the shared core) --------
3461//
3462// For [a,b]: (1) enclose V′; if 0 ∉ enclosure, prune. (2) else enclose V″; if
3463// 0 ∉ enclosure then V′ is monotone on [a,b] (≤ 1 root) — isolate by the shared
3464// refinement iff the evaluated V′(a),V′(b) straddle 0. (3) else split at the
3465// midpoint. Children are pushed right-then-left so the leftmost interval is
3466// processed first and isolated roots are therefore EMITTED IN ASCENDING ρ with
3467// no sort and no heap. Recursion is bounded at MAX_DEPTH =
3468// ⌈log₂((RHO_UPPER−RHO_LOWER)/RHO_BRACKET_RESOLUTION)⌉, where the resolution is
3469// the same ρ-bracket width the safeguarded Newton stop uses. Reaching it without
3470// a monotonicity certificate returns `RemlDidNotConverge`; no best-effort fit is
3471// minted.
3472
3473/// ρ-bracket resolution shared by the enumeration recursion depth and the
3474/// safeguarded-Newton stop: a bracket narrower than `RHO_BRACKET_RESOLUTION·
3475/// (1+|ρ|)` is treated as converged. ρ = ln λ is O(1)–O(10), so 1e-12 pins λ̂ to
3476/// ~12 significant figures — the floor below which cost ordering between two ρ
3477/// candidates is pure rounding noise (the non-smoothness that used to wreck the
3478/// closed-form REML reverse-mode VJP against finite differences).
3479const RHO_BRACKET_RESOLUTION: f64 = 1.0e-12;
3480
3481/// ⌈log₂(range/resolution)⌉ computed at compile time: the smallest depth `d`
3482/// with `resolution·2^d ≥ range`, i.e. the number of midpoint bisections needed
3483/// to drive the window down to the ρ-bracket resolution. `const fn` so the DFS
3484/// stack is a fixed-size array with no heap.
3485const fn dfs_max_depth(range: f64, resolution: f64) -> usize {
3486    let mut width = range;
3487    let mut depth = 0usize;
3488    while width > resolution {
3489        width *= 0.5;
3490        depth += 1;
3491    }
3492    depth
3493}
3494
3495/// Maximum branch-and-bound recursion depth (= 46 for the ±30 window at 1e-12).
3496const MAX_DEPTH: usize = dfs_max_depth(RHO_UPPER - RHO_LOWER, RHO_BRACKET_RESOLUTION);
3497
3498/// A closed real interval `[lo, hi]` used to enclose `V′`/`V″` over a ρ-cell.
3499#[derive(Clone, Copy)]
3500struct Interval {
3501    lo: f64,
3502    hi: f64,
3503}
3504
3505impl Interval {
3506    fn entire() -> Self {
3507        Self {
3508            lo: f64::NEG_INFINITY,
3509            hi: f64::INFINITY,
3510        }
3511    }
3512}
3513
3514/// Next representable f64 strictly below `x` (toward −∞): outward rounding for
3515/// an enclosure lower bound, so the rounded value is provably ≤ the exact one.
3516fn round_down(x: f64) -> f64 {
3517    if x.is_nan() || x == f64::NEG_INFINITY {
3518        return x;
3519    }
3520    if x == 0.0 {
3521        return -f64::from_bits(1);
3522    }
3523    let bits = x.to_bits();
3524    let next = if x > 0.0 { bits - 1 } else { bits + 1 };
3525    f64::from_bits(next)
3526}
3527
3528/// Next representable f64 strictly above `x` (toward +∞): outward rounding for
3529/// an enclosure upper bound, so the rounded value is provably ≥ the exact one.
3530fn round_up(x: f64) -> f64 {
3531    if x.is_nan() || x == f64::INFINITY {
3532        return x;
3533    }
3534    if x == 0.0 {
3535        return f64::from_bits(1);
3536    }
3537    let bits = x.to_bits();
3538    let next = if x > 0.0 { bits + 1 } else { bits - 1 };
3539    f64::from_bits(next)
3540}
3541
3542fn add_down(lhs: f64, rhs: f64) -> f64 {
3543    round_down(lhs + rhs)
3544}
3545
3546fn add_up(lhs: f64, rhs: f64) -> f64 {
3547    round_up(lhs + rhs)
3548}
3549
3550/// Outward product of a non-negative scalar and a non-negative interval.
3551/// Invalid signs/order are not a recoverable numerical perturbation: callers
3552/// must refuse certification rather than silently clamp the interval.
3553fn nonnegative_product_interval(lhs: f64, rhs: Interval) -> Option<Interval> {
3554    if !(lhs.is_finite()
3555        && lhs >= 0.0
3556        && rhs.lo.is_finite()
3557        && rhs.hi.is_finite()
3558        && rhs.lo >= 0.0
3559        && rhs.hi >= rhs.lo)
3560    {
3561        return None;
3562    }
3563    Some(Interval {
3564        lo: round_down(lhs * rhs.lo).max(0.0),
3565        hi: round_up(lhs * rhs.hi),
3566    })
3567}
3568
3569/// Outward square of a non-negative interval.
3570fn nonnegative_square_interval(bounds: Interval) -> Option<Interval> {
3571    if !(bounds.lo.is_finite()
3572        && bounds.hi.is_finite()
3573        && bounds.lo >= 0.0
3574        && bounds.hi >= bounds.lo)
3575    {
3576        return None;
3577    }
3578    Some(Interval {
3579        lo: round_down(bounds.lo * bounds.lo).max(0.0),
3580        hi: round_up(bounds.hi * bounds.hi),
3581    })
3582}
3583
3584/// Enclose accumulated nearest-rounded arithmetic under the standard
3585/// `gamma_n = n*eps/(1-n*eps)` model, then step both endpoints outward once.
3586/// `magnitude` is an absolute sum of the contributing terms, so cancellation
3587/// in the final bound cannot erase its roundoff allowance. Non-finite
3588/// arithmetic refuses pruning by returning the entire real line.
3589fn conservative_interval(lo: f64, hi: f64, magnitude: f64, operations: usize) -> Interval {
3590    if !(lo.is_finite() && hi.is_finite() && magnitude.is_finite() && lo <= hi) {
3591        return Interval::entire();
3592    }
3593    let n_eps = (operations as f64) * f64::EPSILON;
3594    if n_eps >= 1.0 {
3595        return Interval::entire();
3596    }
3597    let pad =
3598        (n_eps / (1.0 - n_eps)) * magnitude.max(lo.abs()).max(hi.abs()).max(f64::MIN_POSITIVE);
3599    Interval {
3600        lo: round_down(lo - pad),
3601        hi: round_up(hi + pad),
3602    }
3603}
3604
3605/// Analytic per-eigenvalue ranges of the four `V′`/`V″` kernels over a monotone
3606/// log-`t` window. See the module derivation above:
3607/// `u=t/(1+t)` ↑, `v=1/(1+t)` ↓, `w=t/(1+t)²` unimodal (peak ¼ at t=1),
3608/// `k=t(1−t)/(1+t)³` with interior extrema at t = 2 ± √3.
3609#[derive(Clone, Copy)]
3610struct KernelRange {
3611    u_lo: f64,
3612    u_hi: f64,
3613    v_lo: f64,
3614    v_hi: f64,
3615    w_lo: f64,
3616    w_hi: f64,
3617    k_lo: f64,
3618    k_hi: f64,
3619}
3620
3621fn kernel_ranges(log_t_lo: f64, log_t_hi: f64) -> KernelRange {
3622    let kernels = |log_t: f64| modal_kernels(log_t, 1.0);
3623    let left = kernels(log_t_lo);
3624    let right = kernels(log_t_hi);
3625
3626    // t/(1+t) increasing; 1/(1+t) decreasing. Evaluating in log-t space
3627    // retains the finite limiting values when exp(log_t) is not representable.
3628    let u_lo = left.u;
3629    let u_hi = right.u;
3630    let v_lo = right.v;
3631    let v_hi = left.v;
3632
3633    // t/(1+t)² unimodal, single interior peak ¼ at t=1.
3634    let w_a = left.w;
3635    let w_b = right.w;
3636    let w_lo = w_a.min(w_b);
3637    let w_hi = if log_t_lo <= 0.0 && 0.0 <= log_t_hi {
3638        0.25
3639    } else {
3640        w_a.max(w_b)
3641    };
3642
3643    // k(t)=t(1−t)/(1+t)³: interior extrema are the roots t = 2 ± √3 of the fixed
3644    // quadratic t²−4t+1 (derived in the module comment).
3645    let sqrt3 = 3.0_f64.sqrt();
3646    let cp_lo = (2.0 - sqrt3).ln();
3647    let cp_hi = (2.0 + sqrt3).ln();
3648    let mut k_lo = left.k.min(right.k);
3649    let mut k_hi = left.k.max(right.k);
3650    if log_t_lo < cp_lo && cp_lo < log_t_hi {
3651        let kc = kernels(cp_lo).k;
3652        k_lo = k_lo.min(kc);
3653        k_hi = k_hi.max(kc);
3654    }
3655    if log_t_lo < cp_hi && cp_hi < log_t_hi {
3656        let kc = kernels(cp_hi).k;
3657        k_lo = k_lo.min(kc);
3658        k_hi = k_hi.max(kc);
3659    }
3660
3661    KernelRange {
3662        u_lo: round_down(u_lo).max(0.0),
3663        u_hi: round_up(u_hi),
3664        v_lo: round_down(v_lo).max(0.0),
3665        v_hi: round_up(v_hi),
3666        w_lo: round_down(w_lo).max(0.0),
3667        w_hi: round_up(w_hi),
3668        k_lo: round_down(k_lo),
3669        k_hi: round_up(k_hi),
3670    }
3671}
3672
3673/// Outward-rounded interval enclosure of `(V′([a,b]), V″([a,b]))` for the DFS.
3674/// Both intervals are conservative bounds for the analytic profiled derivative
3675/// range over the ρ-cell. Kernel extrema are included explicitly and the final
3676/// accumulated arithmetic is padded by an operation-count roundoff bound. A
3677/// non-finite or non-positive residual bound returns the entire line, which
3678/// prevents pruning and therefore ends in a typed unresolved-search refusal if
3679/// tighter children cannot certify the cell.
3680fn reml_deriv_enclosure(
3681    cache: &GaussianRemlEigenCache,
3682    ywy: ArrayView1<'_, f64>,
3683    projected_rhs_squared: ArrayView2<'_, f64>,
3684    n_effective: usize,
3685    n_outputs: usize,
3686    a: f64,
3687    b: f64,
3688) -> (Interval, Interval) {
3689    reml_deriv_enclosure_profile(
3690        cache,
3691        ywy,
3692        projected_rhs_squared,
3693        n_outputs,
3694        n_effective as f64 - cache.nullity as f64,
3695        a,
3696        b,
3697    )
3698}
3699
3700/// Derivative enclosure for an arbitrary response-dispersion profile.
3701/// `logdet_output_count` prices the independent coefficient columns, while
3702/// `dispersion_dof` is the degrees of freedom of each pooled deviance column in
3703/// `ywy` / `projected_rhs_squared`.  The ordinary multi-response objective uses
3704/// `d` separate columns each with `n-q` degrees of freedom; shared-dispersion
3705/// REML supplies one pooled column with `d(n-q)` degrees of freedom.
3706fn reml_deriv_enclosure_profile(
3707    cache: &GaussianRemlEigenCache,
3708    ywy: ArrayView1<'_, f64>,
3709    projected_rhs_squared: ArrayView2<'_, f64>,
3710    logdet_output_count: usize,
3711    dispersion_dof: f64,
3712    a: f64,
3713    b: f64,
3714) -> (Interval, Interval) {
3715    let d = logdet_output_count as f64;
3716    let rank = cache.penalty_rank as f64;
3717    let half_d = 0.5 * d;
3718    let half_nu = 0.5 * dispersion_dof;
3719    // g1 = ½d(Σ t/(1+t) − rank) and the logdet part of V″ = ½d·Σ t/(1+t)²,
3720    // both summing only over the strictly positive penalty eigenvalues.
3721    let mut sum_u_lo = 0.0;
3722    let mut sum_u_hi = 0.0;
3723    let mut sum_w_lo = 0.0;
3724    let mut sum_w_hi = 0.0;
3725    for &delta in &cache.penalty_eigenvalues {
3726        if delta > 0.0 {
3727            let log_delta = delta.ln();
3728            let kr = kernel_ranges(a + log_delta, b + log_delta);
3729            sum_u_lo = add_down(sum_u_lo, kr.u_lo);
3730            sum_u_hi = add_up(sum_u_hi, kr.u_hi);
3731            sum_w_lo = add_down(sum_w_lo, kr.w_lo);
3732            sum_w_hi = add_up(sum_w_hi, kr.w_hi);
3733        }
3734    }
3735    let g1_lo = round_down(half_d * round_down(sum_u_lo - rank));
3736    let g1_hi = round_up(half_d * round_up(sum_u_hi - rank));
3737
3738    // Dispersion contributions to V′ (g2) and V″, folded per output so no
3739    // per-output heap buffer is needed (the shared core is Vec-free).
3740    let mut g2_lo = 0.0;
3741    let mut g2_hi = 0.0;
3742    let mut vpp_disp_lo = 0.0;
3743    let mut vpp_disp_hi = 0.0;
3744    for j in 0..ywy.len() {
3745        let mut num_lo = 0.0; // Σ c² · w   (= dp′, ≥ 0)
3746        let mut num_hi = 0.0;
3747        let mut sv_lo = 0.0; // Σ c² · v
3748        let mut sv_hi = 0.0;
3749        let mut dph_lo = 0.0; // Σ c² · k   (= dp″, sign-indefinite)
3750        let mut dph_hi = 0.0;
3751        for eig in 0..cache.penalty_eigenvalues.len() {
3752            let delta = cache.penalty_eigenvalues[eig];
3753            let c2 = projected_rhs_squared[[eig, j]];
3754            let log_delta = if delta == 0.0 {
3755                f64::NEG_INFINITY
3756            } else {
3757                delta.ln()
3758            };
3759            let kr = kernel_ranges(a + log_delta, b + log_delta);
3760            let Some(w_product) = nonnegative_product_interval(
3761                c2,
3762                Interval {
3763                    lo: kr.w_lo,
3764                    hi: kr.w_hi,
3765                },
3766            ) else {
3767                return (Interval::entire(), Interval::entire());
3768            };
3769            let Some(v_product) = nonnegative_product_interval(
3770                c2,
3771                Interval {
3772                    lo: kr.v_lo,
3773                    hi: kr.v_hi,
3774                },
3775            ) else {
3776                return (Interval::entire(), Interval::entire());
3777            };
3778            num_lo = add_down(num_lo, w_product.lo);
3779            num_hi = add_up(num_hi, w_product.hi);
3780            sv_lo = add_down(sv_lo, v_product.lo);
3781            sv_hi = add_up(sv_hi, v_product.hi);
3782            dph_lo = add_down(dph_lo, round_down(c2 * kr.k_lo));
3783            dph_hi = add_up(dph_hi, round_up(c2 * kr.k_hi));
3784        }
3785        // dp is monotone increasing. A non-positive conservative lower bound
3786        // means the profiled log residual cannot be certified on this cell;
3787        // never replace that mathematical failure with a tiny positive floor.
3788        let dp_lo = round_down(ywy[j] - sv_hi);
3789        let dp_hi = round_up(ywy[j] - sv_lo);
3790        if !(dp_lo.is_finite() && dp_hi.is_finite() && dp_lo > 0.0 && dp_hi >= dp_lo) {
3791            return (Interval::entire(), Interval::entire());
3792        }
3793
3794        // g2_j = num_j / dp_j  (num ≥ 0, dp > 0).
3795        let ratio_lo = round_down(num_lo / dp_hi).max(0.0);
3796        let ratio_hi = round_up(num_hi / dp_lo);
3797        g2_lo = add_down(g2_lo, ratio_lo);
3798        g2_hi = add_up(g2_hi, ratio_hi);
3799
3800        // dp″/dp with dp″ sign-indefinite: exact four-corner range over the
3801        // strictly positive denominator interval.
3802        let quotients = [
3803            dph_lo / dp_lo,
3804            dph_lo / dp_hi,
3805            dph_hi / dp_lo,
3806            dph_hi / dp_hi,
3807        ];
3808        let adp_lo = round_down(quotients.iter().copied().fold(f64::INFINITY, f64::min));
3809        let adp_hi = round_up(quotients.iter().copied().fold(f64::NEG_INFINITY, f64::max));
3810
3811        // (dp′/dp)² with dp′ ≥ 0, dp > 0.
3812        let bl = round_down(num_lo / dp_hi).max(0.0);
3813        let bh = round_up(num_hi / dp_lo);
3814        let Some(squared_ratio) = nonnegative_square_interval(Interval { lo: bl, hi: bh }) else {
3815            return (Interval::entire(), Interval::entire());
3816        };
3817
3818        // term_j = dp″/dp − (dp′/dp)².
3819        vpp_disp_lo = add_down(vpp_disp_lo, round_down(adp_lo - squared_ratio.hi));
3820        vpp_disp_hi = add_up(vpp_disp_hi, round_up(adp_hi - squared_ratio.lo));
3821    }
3822
3823    let vp_lo = add_down(g1_lo, round_down(half_nu * g2_lo));
3824    let vp_hi = add_up(g1_hi, round_up(half_nu * g2_hi));
3825    let vpp_lo = add_down(
3826        round_down(half_d * sum_w_lo),
3827        round_down(half_nu * vpp_disp_lo),
3828    );
3829    let vpp_hi = add_up(round_up(half_d * sum_w_hi), round_up(half_nu * vpp_disp_hi));
3830
3831    let operations = 64usize.saturating_add(
3832        32usize.saturating_mul(
3833            cache
3834                .penalty_eigenvalues
3835                .len()
3836                .saturating_mul(ywy.len().max(1)),
3837        ),
3838    );
3839    let vp_magnitude = g1_lo.abs() + g1_hi.abs() + half_nu.abs() * (g2_lo.abs() + g2_hi.abs());
3840    let vpp_magnitude = half_d.abs() * (sum_w_lo.abs() + sum_w_hi.abs())
3841        + half_nu.abs() * (vpp_disp_lo.abs() + vpp_disp_hi.abs());
3842    (
3843        conservative_interval(vp_lo, vp_hi, vp_magnitude, operations),
3844        conservative_interval(vpp_lo, vpp_hi, vpp_magnitude, operations),
3845    )
3846}
3847
3848#[derive(Clone, Copy, Debug)]
3849struct StationaryRoot {
3850    rho: f64,
3851    bracket: [f64; 2],
3852}
3853
3854#[derive(Clone, Copy, Debug)]
3855struct ProfileSelection {
3856    rho: f64,
3857    projected_gradient_residual: f64,
3858}
3859
3860#[derive(Clone, Copy)]
3861struct ProfileSearchControls {
3862    lower: f64,
3863    upper: f64,
3864    resolution: f64,
3865    max_depth: usize,
3866}
3867
3868impl ProfileSearchControls {
3869    const PRODUCTION: Self = Self {
3870        lower: RHO_LOWER,
3871        upper: RHO_UPPER,
3872        resolution: RHO_BRACKET_RESOLUTION,
3873        max_depth: MAX_DEPTH,
3874    };
3875}
3876
3877fn profile_search_refusal(
3878    eval: &impl Fn(f64) -> ObjectiveEval,
3879    checkpoint: f64,
3880    reason: String,
3881) -> EstimationError {
3882    let e = eval(checkpoint);
3883    EstimationError::RemlDidNotConverge {
3884        context: "closed-form Gaussian profiled REML stationary search".to_string(),
3885        reason,
3886        iterations: 0,
3887        final_value: e.cost,
3888        projected_grad_norm: e.grad.is_finite().then_some(e.grad.abs()),
3889        stationarity_bound: GRAD_TOL * (1.0 + e.cost.abs()),
3890        rho_checkpoint: vec![checkpoint],
3891    }
3892}
3893
3894/// Isolate one unique derivative root to a geometric rho bracket. Newton is
3895/// accepted only in the central half of the maintained sign bracket, so every
3896/// iteration contracts it by at least one quarter. There is no iteration cap:
3897/// termination follows from geometric contraction, and loss of a representable
3898/// interior point is a typed refusal rather than a best-effort root.
3899fn refine_stationary_rho_core(
3900    eval: &impl Fn(f64) -> ObjectiveEval,
3901    mut lo: f64,
3902    mut hi: f64,
3903    resolution: f64,
3904    mut hint: Option<f64>,
3905) -> Result<StationaryRoot, EstimationError> {
3906    let mut left = eval(lo);
3907    let mut right = eval(hi);
3908    if left.grad == 0.0 {
3909        return Ok(StationaryRoot {
3910            rho: lo,
3911            bracket: [lo, lo],
3912        });
3913    }
3914    if right.grad == 0.0 {
3915        return Ok(StationaryRoot {
3916            rho: hi,
3917            bracket: [hi, hi],
3918        });
3919    }
3920    if left.grad.is_sign_positive() == right.grad.is_sign_positive() {
3921        return Err(profile_search_refusal(
3922            eval,
3923            0.5 * (lo + hi),
3924            format!("stationary refinement received a non-bracketing cell [{lo}, {hi}]"),
3925        ));
3926    }
3927
3928    loop {
3929        let width = hi - lo;
3930        let scale = 1.0 + lo.abs().max(hi.abs());
3931        if width <= resolution * scale {
3932            let midpoint = lo + 0.5 * width;
3933            let middle = if midpoint > lo && midpoint < hi {
3934                Some((midpoint, eval(midpoint)))
3935            } else {
3936                None
3937            };
3938            let mut representative = (lo, left);
3939            if right.grad.abs() < representative.1.grad.abs() {
3940                representative = (hi, right);
3941            }
3942            if let Some(candidate) = middle
3943                && candidate.1.grad.abs() < representative.1.grad.abs()
3944            {
3945                representative = candidate;
3946            }
3947            return Ok(StationaryRoot {
3948                rho: representative.0,
3949                bracket: [lo, hi],
3950            });
3951        }
3952
3953        let midpoint = lo + 0.5 * width;
3954        if !(midpoint > lo && midpoint < hi) {
3955            return Err(profile_search_refusal(
3956                eval,
3957                midpoint,
3958                format!(
3959                    "stationary root on [{lo}, {hi}] reached floating-point spacing before rho resolution {resolution}"
3960                ),
3961            ));
3962        }
3963        let guard = 0.25 * width;
3964        let base = if left.grad.abs() <= right.grad.abs() {
3965            (lo, left)
3966        } else {
3967            (hi, right)
3968        };
3969        let newton = if base.1.hess != 0.0 {
3970            base.0 - base.1.grad / base.1.hess
3971        } else {
3972            f64::NAN
3973        };
3974        let candidate = hint
3975            .take()
3976            .filter(|&rho| rho >= lo + guard && rho <= hi - guard)
3977            .or_else(|| {
3978                (newton.is_finite() && newton >= lo + guard && newton <= hi - guard)
3979                    .then_some(newton)
3980            })
3981            .unwrap_or(midpoint);
3982        if !(candidate > lo && candidate < hi) {
3983            return Err(profile_search_refusal(
3984                eval,
3985                midpoint,
3986                format!(
3987                    "stationary refinement could not represent an interior point on [{lo}, {hi}]"
3988                ),
3989            ));
3990        }
3991        let current = eval(candidate);
3992        if current.grad == 0.0 {
3993            return Ok(StationaryRoot {
3994                rho: candidate,
3995                bracket: [candidate, candidate],
3996            });
3997        }
3998        if current.grad.is_sign_positive() == left.grad.is_sign_positive() {
3999            lo = candidate;
4000            left = current;
4001        } else {
4002            hi = candidate;
4003            right = current;
4004        }
4005    }
4006}
4007
4008fn interval_contains(interval: Interval, value: f64) -> bool {
4009    value.is_finite() && interval.lo <= value && value <= interval.hi
4010}
4011
4012/// Certify the stationary structure of the actual profiled objective on the
4013/// finite rho window, then compare one representative of every isolated root
4014/// with both boundaries. `init_rho` is only a refinement hint; an arbitrary
4015/// nonstationary seed is never eligible to become the estimator.
4016fn enumerate_and_select_rho_with_controls(
4017    eval: impl Fn(f64) -> ObjectiveEval,
4018    enclose: impl Fn(f64, f64) -> (Interval, Interval),
4019    init_rho: Option<f64>,
4020    controls: ProfileSearchControls,
4021    mut visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4022) -> Result<ProfileSelection, EstimationError> {
4023    const CAP: usize = MAX_DEPTH + 4;
4024    let mut stack = [(0.0f64, 0.0f64, 0usize); CAP];
4025    let mut top = 0usize;
4026    stack[top] = (controls.lower, controls.upper, 0);
4027    top += 1;
4028
4029    let lower_eval = eval(controls.lower);
4030    let upper_eval = eval(controls.upper);
4031    let (mut best_rho, mut best_eval) = if upper_eval.cost < lower_eval.cost {
4032        (controls.upper, upper_eval)
4033    } else {
4034        (controls.lower, lower_eval)
4035    };
4036    let mut last_root: Option<StationaryRoot> = None;
4037
4038    while top > 0 {
4039        top -= 1;
4040        let (a, b, depth) = stack[top];
4041        let ea = eval(a);
4042        let eb = eval(b);
4043        let (dv, dvv) = enclose(a, b);
4044        if !(interval_contains(dv, ea.grad)
4045            && interval_contains(dv, eb.grad)
4046            && interval_contains(dvv, ea.hess)
4047            && interval_contains(dvv, eb.hess))
4048        {
4049            return Err(profile_search_refusal(
4050                &eval,
4051                0.5 * (a + b),
4052                format!(
4053                    "analytic derivative enclosure [{}, {}] / curvature enclosure [{}, {}] missed an endpoint jet on [{a}, {b}]",
4054                    dv.lo, dv.hi, dvv.lo, dvv.hi
4055                ),
4056            ));
4057        }
4058        if dv.lo > 0.0 || dv.hi < 0.0 {
4059            continue;
4060        }
4061
4062        let monotone = dvv.lo > 0.0 || dvv.hi < 0.0;
4063        let at_floor = depth >= controls.max_depth
4064            || (b - a) <= controls.resolution * (1.0 + a.abs().max(b.abs()));
4065        if !monotone && at_floor {
4066            return Err(profile_search_refusal(
4067                &eval,
4068                0.5 * (a + b),
4069                format!(
4070                    "stationary structure remained non-monotone on [{a}, {b}] at rho resolution {}",
4071                    controls.resolution
4072                ),
4073            ));
4074        }
4075
4076        if monotone {
4077            let crosses = (ea.grad <= 0.0 && eb.grad >= 0.0) || (ea.grad >= 0.0 && eb.grad <= 0.0);
4078            if crosses {
4079                let hint = init_rho.filter(|rho| rho.is_finite() && *rho >= a && *rho <= b);
4080                let root = refine_stationary_rho_core(&eval, a, b, controls.resolution, hint)?;
4081                let duplicate = last_root.is_some_and(|previous| {
4082                    root.rho.to_bits() == previous.rho.to_bits()
4083                        || (root.bracket[0] <= previous.bracket[1]
4084                            && previous.bracket[0] <= root.bracket[1])
4085                });
4086                if !duplicate {
4087                    let e = eval(root.rho);
4088                    if e.cost < best_eval.cost {
4089                        best_rho = root.rho;
4090                        best_eval = e;
4091                    }
4092                    visit(root, &e);
4093                    last_root = Some(root);
4094                }
4095            }
4096            continue;
4097        }
4098
4099        let mid = a + 0.5 * (b - a);
4100        if !(mid > a && mid < b) || top + 2 > CAP {
4101            return Err(profile_search_refusal(
4102                &eval,
4103                mid,
4104                format!("stationary subdivision could not continue on [{a}, {b}]"),
4105            ));
4106        }
4107        stack[top] = (mid, b, depth + 1);
4108        top += 1;
4109        stack[top] = (a, mid, depth + 1);
4110        top += 1;
4111    }
4112
4113    if !(best_eval.cost.is_finite() && best_eval.grad.is_finite()) {
4114        return Err(EstimationError::InvalidInput(
4115            "Gaussian REML profiled search produced no finite candidate".to_string(),
4116        ));
4117    }
4118    let projected_gradient_residual = if best_rho == controls.lower {
4119        (-best_eval.grad).max(0.0)
4120    } else if best_rho == controls.upper {
4121        best_eval.grad.max(0.0)
4122    } else {
4123        best_eval.grad.abs()
4124    };
4125    Ok(ProfileSelection {
4126        rho: best_rho,
4127        projected_gradient_residual,
4128    })
4129}
4130
4131fn enumerate_and_select_rho(
4132    eval: impl Fn(f64) -> ObjectiveEval,
4133    enclose: impl Fn(f64, f64) -> (Interval, Interval),
4134    init_rho: Option<f64>,
4135    visit: impl FnMut(StationaryRoot, &ObjectiveEval),
4136) -> Result<ProfileSelection, EstimationError> {
4137    enumerate_and_select_rho_with_controls(
4138        eval,
4139        enclose,
4140        init_rho,
4141        ProfileSearchControls::PRODUCTION,
4142        visit,
4143    )
4144}
4145
4146/// Analytic compactified endpoint costs `[V(ρ→−∞), V(ρ→+∞)]`.
4147///
4148/// As ρ→−∞ (λ→0) the log-det numerator `log|H|` stays finite while
4149/// `log|S|₊ = logdet_penalty_positive + r·ρ → −∞`, so `V → +∞`: the small-λ
4150/// boundary is never an optimum. As ρ→+∞ (λ→∞) the ρ-linear parts of `log|H|`
4151/// and `log|S|₊` cancel (both grow like `r·ρ`), leaving a finite limit; the
4152/// profiled residual saturates at `dp_j = ywy_j − Σ_{δ_i=0} c²_ij`.
4153fn compactified_limit_costs(
4154    cache: &GaussianRemlEigenCache,
4155    ywy: ArrayView1<'_, f64>,
4156    projected_rhs_squared: ArrayView2<'_, f64>,
4157    n_outputs: usize,
4158    nu: f64,
4159) -> [f64; 2] {
4160    let mut sum_log_delta_pos = 0.0;
4161    for &delta in &cache.penalty_eigenvalues {
4162        if delta > 0.0 {
4163            sum_log_delta_pos += delta.ln();
4164        }
4165    }
4166    let logdet_limit = cache.logdet_xtwx + sum_log_delta_pos - cache.logdet_penalty_positive;
4167    let mut plus_inf = 0.5 * (n_outputs as f64) * logdet_limit;
4168    for j in 0..ywy.len() {
4169        let mut null_mass = 0.0;
4170        for i in 0..cache.penalty_eigenvalues.len() {
4171            if cache.penalty_eigenvalues[i] == 0.0 {
4172                null_mass += projected_rhs_squared[[i, j]];
4173            }
4174        }
4175        let dp_inf = ywy[j] - null_mass;
4176        if !(dp_inf.is_finite() && dp_inf > 0.0) {
4177            plus_inf = f64::INFINITY;
4178            break;
4179        }
4180        plus_inf += 0.5 * nu * (1.0 + (2.0 * std::f64::consts::PI * dp_inf / nu).ln());
4181    }
4182    [f64::INFINITY, plus_inf]
4183}
4184
4185/// Certified topology of the profiled-REML ρ-landscape.
4186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4187pub enum RhoLandscape {
4188    /// No interior stationary point: `V` is monotone and the optimum is the
4189    /// `ρ→+∞` (large-λ) compactified boundary.
4190    NoInteriorOptimum,
4191    /// Exactly one interior stationary point — a unique interior optimum.
4192    UniqueInterior,
4193    /// More than one interior stationary point.
4194    MultipleInterior,
4195}
4196
4197/// Interval-enclosure certificate for the profiled Gaussian-REML ρ-landscape.
4198///
4199/// `stationary_count` is the number of interior stationary points isolated by
4200/// branch-and-bound using outward-rounded first/second-derivative enclosures.
4201/// A cell whose stationary structure remains ambiguous at the resolution floor
4202/// returns typed non-convergence and cannot mint a certificate.
4203/// `limit_costs` compactifies the search to `[0, ∞]` by appending the analytic
4204/// `ρ→∓∞` endpoint costs. When the interior is monotone/all-noise the
4205/// `landscape` short-circuits to [`RhoLandscape::NoInteriorOptimum`].
4206#[derive(Clone, Debug)]
4207pub struct RhoLandscapeCertificate {
4208    pub stationary_count: usize,
4209    pub root_brackets: Vec<[f64; 2]>,
4210    pub landscape: RhoLandscape,
4211    pub window_costs: [f64; 2],
4212    pub limit_costs: [f64; 2],
4213    pub selected_rho: f64,
4214    pub boundary_optimum: bool,
4215    pub rho_window: [f64; 2],
4216}
4217
4218fn rho_landscape_certificate_from_parts(
4219    cache: &GaussianRemlEigenCache,
4220    ywy: ArrayView1<'_, f64>,
4221    projected_rhs_squared: ArrayView2<'_, f64>,
4222    n_effective: usize,
4223    n_outputs: usize,
4224    init_rho: Option<f64>,
4225) -> Result<RhoLandscapeCertificate, EstimationError> {
4226    validate_reml_profile_residuals(cache, ywy, projected_rhs_squared, RHO_LOWER)?;
4227    let nu = n_effective as f64 - cache.nullity as f64;
4228    let eval = |rho: f64| {
4229        evaluate_reml_parts(
4230            cache,
4231            ywy,
4232            projected_rhs_squared,
4233            n_effective,
4234            n_outputs,
4235            rho,
4236        )
4237    };
4238    let window_costs = [eval(RHO_LOWER).cost, eval(RHO_UPPER).cost];
4239    let limit_costs = compactified_limit_costs(cache, ywy, projected_rhs_squared, n_outputs, nu);
4240
4241    let mut root_brackets = Vec::new();
4242    let selection = if cache.penalty_rank == 0 {
4243        ProfileSelection {
4244            rho: init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER),
4245            projected_gradient_residual: 0.0,
4246        }
4247    } else {
4248        let enclose = |a: f64, b: f64| {
4249            reml_deriv_enclosure(
4250                cache,
4251                ywy,
4252                projected_rhs_squared,
4253                n_effective,
4254                n_outputs,
4255                a,
4256                b,
4257            )
4258        };
4259        enumerate_and_select_rho(&eval, &enclose, init_rho, |root, _e| {
4260            root_brackets.push(root.bracket)
4261        })?
4262    };
4263
4264    let stationary_count = root_brackets.len();
4265    let landscape = match stationary_count {
4266        0 => RhoLandscape::NoInteriorOptimum,
4267        1 => RhoLandscape::UniqueInterior,
4268        _ => RhoLandscape::MultipleInterior,
4269    };
4270    let boundary_optimum = matches!(landscape, RhoLandscape::NoInteriorOptimum);
4271
4272    Ok(RhoLandscapeCertificate {
4273        stationary_count,
4274        root_brackets,
4275        landscape,
4276        window_costs,
4277        limit_costs,
4278        selected_rho: selection.rho,
4279        boundary_optimum,
4280        rho_window: [RHO_LOWER, RHO_UPPER],
4281    })
4282}
4283
4284/// Certified profiled Gaussian-REML ρ-landscape at the given design: the
4285/// outward-enclosed branch-and-bound stationary brackets, their decided count,
4286/// and the compactified `ρ→∓∞` endpoint costs.
4287pub fn gaussian_reml_rho_landscape_certificate(
4288    x: ArrayView2<'_, f64>,
4289    y: ArrayView1<'_, f64>,
4290    penalty: ArrayView2<'_, f64>,
4291    nullspace_dim: Option<usize>,
4292    weights: Option<ArrayView1<'_, f64>>,
4293    init_rho: Option<f64>,
4294) -> Result<RhoLandscapeCertificate, EstimationError> {
4295    if init_rho.is_some_and(|rho| !rho.is_finite()) {
4296        crate::bail_invalid_estim!(
4297            "Gaussian REML rho-landscape certificate requires a finite rho hint"
4298        );
4299    }
4300    let y2 = y.insert_axis(Axis(1));
4301    let prepared = prepare_gaussian_reml(x, y2.view(), penalty, nullspace_dim, weights, None)?;
4302    rho_landscape_certificate_from_parts(
4303        &prepared.cache,
4304        prepared.ywy.view(),
4305        prepared.projected_rhs_squared.view(),
4306        prepared.n_effective,
4307        prepared.n_outputs,
4308        init_rho,
4309    )
4310}
4311
4312/// Select ρ̂ = ln λ̂ by grid-free stationary-point enumeration (allocating path).
4313fn optimize_rho(
4314    prepared: &GaussianRemlPrepared,
4315    init_rho: Option<f64>,
4316) -> Result<f64, EstimationError> {
4317    validate_reml_profile_residuals(
4318        &prepared.cache,
4319        prepared.ywy.view(),
4320        prepared.projected_rhs_squared.view(),
4321        RHO_LOWER,
4322    )?;
4323    if prepared.cache.penalty_rank == 0 {
4324        return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4325    }
4326    let eval = |rho: f64| prepared.evaluate(rho);
4327    let enclose = |a: f64, b: f64| {
4328        reml_deriv_enclosure(
4329            &prepared.cache,
4330            prepared.ywy.view(),
4331            prepared.projected_rhs_squared.view(),
4332            prepared.n_effective,
4333            prepared.n_outputs,
4334            a,
4335            b,
4336        )
4337    };
4338    Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4339}
4340
4341fn fill_weighted_rhs_no_alloc(
4342    x: ArrayView2<'_, f64>,
4343    y: ArrayView2<'_, f64>,
4344    weights: Option<ArrayView1<'_, f64>>,
4345    workspace: &mut GaussianRemlNoAllocWorkspace,
4346) -> Result<(), EstimationError> {
4347    let d = y.ncols();
4348
4349    // XᵀWY and YᵀWY via faer BLAS. Both `fast_xt_diag_y` and `fast_atb`
4350    // dispatch to faer's SIMD-optimized GEMM (with chunked weight scaling
4351    // when weights are present), replacing the previous scalar triple loop
4352    // over (n, p, d). For YᵀWY we only need the diagonal entries, but d is
4353    // small (typically 1–10) so computing the full d×d Gram is negligible.
4354    let (xtwy, ywy_full) = match weights {
4355        Some(w) => (fast_xt_diag_y(&x, &w, &y), fast_xt_diag_y(&y, &w, &y)),
4356        None => (fast_atb(&x, &y), fast_atb(&y, &y)),
4357    };
4358    workspace.xtwy.assign(&xtwy);
4359    for output in 0..d {
4360        workspace.ywy[output] = ywy_full[[output, output]];
4361    }
4362
4363    if workspace
4364        .xtwy
4365        .iter()
4366        .chain(workspace.ywy.iter())
4367        .any(|value| !value.is_finite())
4368    {
4369        crate::bail_invalid_estim!("Gaussian REML weighted cross-products must be finite");
4370    }
4371    Ok(())
4372}
4373
4374fn project_rhs_no_alloc(
4375    cache: &GaussianRemlEigenCache,
4376    workspace: &mut GaussianRemlNoAllocWorkspace,
4377) {
4378    // projected_rhs = coefficient_basisᵀ · xtwy, computed via faer BLAS
4379    // (was previously a scalar triple loop over (p, d, p)).
4380    let projected = fast_atb(&cache.coefficient_basis, &workspace.xtwy);
4381    workspace.projected_rhs.assign(&projected);
4382    let p = cache.penalty_eigenvalues.len();
4383    let d = workspace.ywy.len();
4384    for eig in 0..p {
4385        for output in 0..d {
4386            let value = workspace.projected_rhs[[eig, output]];
4387            workspace.projected_rhs_squared[[eig, output]] = value * value;
4388        }
4389    }
4390}
4391
4392fn evaluate_reml_parts(
4393    cache: &GaussianRemlEigenCache,
4394    ywy: ArrayView1<'_, f64>,
4395    projected_rhs_squared: ArrayView2<'_, f64>,
4396    n_effective: usize,
4397    n_outputs: usize,
4398    rho: f64,
4399) -> ObjectiveEval {
4400    evaluate_reml_profile(
4401        cache,
4402        ywy,
4403        projected_rhs_squared,
4404        n_outputs,
4405        n_effective as f64 - cache.nullity as f64,
4406        rho,
4407    )
4408}
4409
4410/// Evaluate the REML objective under either separate or pooled response
4411/// dispersions.  See [`reml_deriv_enclosure_profile`] for the two independent
4412/// dimensions of the profile contract.
4413fn evaluate_reml_profile(
4414    cache: &GaussianRemlEigenCache,
4415    ywy: ArrayView1<'_, f64>,
4416    projected_rhs_squared: ArrayView2<'_, f64>,
4417    logdet_output_count: usize,
4418    dispersion_dof: f64,
4419    rho: f64,
4420) -> ObjectiveEval {
4421    let d = logdet_output_count as f64;
4422
4423    // Each term's value and its ρ-derivatives come back from ONE function so
4424    // they cannot be edited independently; `+=` folds the triple in lock-step.
4425    let (logdet_term, edf) = gaussian_reml_logdet_term(cache, rho, d);
4426    let mut eval = ObjectiveEval {
4427        cost: 0.0,
4428        grad: 0.0,
4429        hess: 0.0,
4430        edf,
4431    };
4432    eval += logdet_term;
4433    for output in 0..ywy.len() {
4434        eval += gaussian_reml_dispersion_term(
4435            cache,
4436            ywy,
4437            projected_rhs_squared,
4438            output,
4439            dispersion_dof,
4440            rho,
4441        );
4442    }
4443    eval
4444}
4445
4446/// Select ρ̂ by the same grid-free enumeration as [`optimize_rho`], driven from
4447/// raw cache/rhs parts with zero heap allocation (the DFS stack is a fixed-size
4448/// on-stack array and both closures capture only borrowed views). Reduces
4449/// through the shared `enumerate_and_select_rho`, so it returns a bit-identical
4450/// ρ to the allocating path on identical inputs.
4451fn optimize_rho_no_alloc(
4452    cache: &GaussianRemlEigenCache,
4453    ywy: ArrayView1<'_, f64>,
4454    projected_rhs_squared: ArrayView2<'_, f64>,
4455    n_effective: usize,
4456    n_outputs: usize,
4457    init_rho: Option<f64>,
4458) -> Result<f64, EstimationError> {
4459    validate_reml_profile_residuals(cache, ywy.view(), projected_rhs_squared.view(), RHO_LOWER)?;
4460    if cache.penalty_rank == 0 {
4461        return Ok(init_rho.unwrap_or(0.0).clamp(RHO_LOWER, RHO_UPPER));
4462    }
4463    let eval = |rho: f64| {
4464        evaluate_reml_parts(
4465            cache,
4466            ywy,
4467            projected_rhs_squared,
4468            n_effective,
4469            n_outputs,
4470            rho,
4471        )
4472    };
4473    let enclose = |a: f64, b: f64| {
4474        reml_deriv_enclosure(
4475            cache,
4476            ywy,
4477            projected_rhs_squared,
4478            n_effective,
4479            n_outputs,
4480            a,
4481            b,
4482        )
4483    };
4484    Ok(enumerate_and_select_rho(eval, enclose, init_rho, |_r, _e| {})?.rho)
4485}
4486
4487fn fill_coefficients_no_alloc(
4488    cache: &GaussianRemlEigenCache,
4489    workspace: &mut GaussianRemlNoAllocWorkspace,
4490    lambda: f64,
4491    mut coefficients: ArrayViewMut2<'_, f64>,
4492) {
4493    let p = cache.penalty_eigenvalues.len();
4494    let d = workspace.ywy.len();
4495    for eig in 0..p {
4496        let scale = 1.0 / (1.0 + lambda * cache.penalty_eigenvalues[eig]);
4497        for output in 0..d {
4498            workspace.scaled_projected_rhs[[eig, output]] =
4499                workspace.projected_rhs[[eig, output]] * scale;
4500        }
4501    }
4502
4503    for col in 0..p {
4504        for output in 0..d {
4505            let mut value = 0.0;
4506            for eig in 0..p {
4507                value += cache.coefficient_basis[[col, eig]]
4508                    * workspace.scaled_projected_rhs[[eig, output]];
4509            }
4510            coefficients[[col, output]] = value;
4511        }
4512    }
4513}
4514
4515fn fill_fitted_no_alloc(
4516    x: ArrayView2<'_, f64>,
4517    coefficients: ArrayView2<'_, f64>,
4518    mut fitted: ArrayViewMut2<'_, f64>,
4519) {
4520    let n = x.nrows();
4521    let p = x.ncols();
4522    let d = coefficients.ncols();
4523    for row in 0..n {
4524        for output in 0..d {
4525            let mut value = 0.0;
4526            for col in 0..p {
4527                value += x[[row, col]] * coefficients[[col, output]];
4528            }
4529            fitted[[row, output]] = value;
4530        }
4531    }
4532}
4533
4534fn fill_sigma2_no_alloc(
4535    cache: &GaussianRemlEigenCache,
4536    ywy: ArrayView1<'_, f64>,
4537    projected_rhs_squared: ArrayView2<'_, f64>,
4538    n_effective: usize,
4539    n_outputs: usize,
4540    lambda: f64,
4541    mut sigma2: ArrayViewMut1<'_, f64>,
4542) {
4543    let nu = n_effective as f64 - cache.nullity as f64;
4544    for output in 0..n_outputs {
4545        let mut fitted_quadratic = 0.0;
4546        for eig in 0..cache.penalty_eigenvalues.len() {
4547            let denom = 1.0 + lambda * cache.penalty_eigenvalues[eig];
4548            fitted_quadratic += projected_rhs_squared[[eig, output]] / denom;
4549        }
4550        sigma2[output] = (ywy[output] - fitted_quadratic) / nu;
4551    }
4552}
4553
4554fn invert_lower_triangular(lower: &Array2<f64>) -> Result<Array2<f64>, EstimationError> {
4555    let n = lower.nrows();
4556    if lower.ncols() != n {
4557        crate::bail_invalid_estim!("lower-triangular solve requires a square matrix");
4558    }
4559    let eye = Array2::eye(n);
4560    solve_lower_triangular_matrix(lower, &eye)
4561}
4562
4563fn solve_lower_triangular_matrix(
4564    lower: &Array2<f64>,
4565    rhs: &Array2<f64>,
4566) -> Result<Array2<f64>, EstimationError> {
4567    let n = lower.nrows();
4568    if lower.ncols() != n || rhs.nrows() != n {
4569        crate::bail_invalid_estim!("lower-triangular solve dimension mismatch");
4570    }
4571    if let Some(out) = gam_gpu::try_solve_lower_triangular_matrix(lower.view(), rhs.view()) {
4572        return Ok(out);
4573    }
4574    let mut out = Array2::<f64>::zeros(rhs.dim());
4575    for col in 0..rhs.ncols() {
4576        for i in 0..n {
4577            let mut value = rhs[[i, col]];
4578            for k in 0..i {
4579                value -= lower[[i, k]] * out[[k, col]];
4580            }
4581            let diag = lower[[i, i]];
4582            if !(diag.is_finite() && diag.abs() > 0.0) {
4583                return Err(EstimationError::ModelIsIllConditioned {
4584                    condition_number: f64::INFINITY,
4585                });
4586            }
4587            out[[i, col]] = value / diag;
4588        }
4589    }
4590    Ok(out)
4591}
4592
4593/// Solve the SPD system `L Lᵀ X = rhs` for `X` given the lower Cholesky factor
4594/// `L` (as returned by [`gaussian_reml_cholesky_lower`]): a forward solve
4595/// against `L` followed by a back solve against `Lᵀ`.
4596fn solve_spd_from_lower_factor(
4597    lower: &Array2<f64>,
4598    rhs: &Array2<f64>,
4599) -> Result<Array2<f64>, EstimationError> {
4600    let forward = solve_lower_triangular_matrix(lower, rhs)?;
4601    solve_upper_triangular_matrix(&lower.t().to_owned(), &forward)
4602}
4603
4604fn solve_upper_triangular_matrix(
4605    upper: &Array2<f64>,
4606    rhs: &Array2<f64>,
4607) -> Result<Array2<f64>, EstimationError> {
4608    let n = upper.nrows();
4609    if upper.ncols() != n || rhs.nrows() != n {
4610        crate::bail_invalid_estim!("upper-triangular solve dimension mismatch");
4611    }
4612    if let Some(out) = gam_gpu::try_solve_upper_triangular_matrix(upper.view(), rhs.view()) {
4613        return Ok(out);
4614    }
4615    let mut out = Array2::<f64>::zeros(rhs.dim());
4616    for col in 0..rhs.ncols() {
4617        for i_rev in 0..n {
4618            let i = n - 1 - i_rev;
4619            let mut value = rhs[[i, col]];
4620            for k in (i + 1)..n {
4621                value -= upper[[i, k]] * out[[k, col]];
4622            }
4623            let diag = upper[[i, i]];
4624            if !(diag.is_finite() && diag.abs() > 0.0) {
4625                return Err(EstimationError::ModelIsIllConditioned {
4626                    condition_number: f64::INFINITY,
4627                });
4628            }
4629            out[[i, col]] = value / diag;
4630        }
4631    }
4632    Ok(out)
4633}
4634
4635#[cfg(test)]
4636mod tests {
4637    use super::*;
4638    use ndarray::array;
4639
4640    #[test]
4641    fn edf_does_not_double_count_penalty_nullspace() {
4642        let x = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0], [1.0, 4.0],];
4643        let y = array![[0.0], [1.0], [1.8], [3.2], [4.1]];
4644        let penalty = array![[0.0, 0.0], [0.0, 1.0]];
4645        let result =
4646            gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
4647                .expect("small full-rank Gaussian REML fit");
4648
4649        assert!(result.edf >= result.cache.nullity as f64);
4650        assert!(result.edf <= x.ncols() as f64 + 1.0e-10);
4651    }
4652
4653    #[test]
4654    fn shared_dispersion_pools_projection_exact_and_missed_outputs() {
4655        let n = 12usize;
4656        let mut x = Array2::<f64>::zeros((n, 2));
4657        let mut y = Array2::<f64>::zeros((n, 2));
4658        for row in 0..n {
4659            let t = row as f64 - 5.5;
4660            x[[row, 0]] = 1.0;
4661            x[[row, 1]] = t;
4662            // The first ambient output is exactly the chart coordinate: this is
4663            // the tautological zero-residual channel a PCA chart creates.
4664            y[[row, 0]] = t;
4665            // The second output is deliberately outside the linear chart.
4666            y[[row, 1]] = if row % 2 == 0 { -2.0 } else { 3.0 };
4667        }
4668        let penalty = Array2::<f64>::zeros((2, 2));
4669        let fit = gaussian_reml_multi_shared_dispersion_closed_form(
4670            x.view(),
4671            y.view(),
4672            penalty.view(),
4673            None,
4674            None,
4675        )
4676        .expect("shared-dispersion vector REML fit");
4677
4678        assert_eq!(fit.sigma2[0].to_bits(), fit.sigma2[1].to_bits());
4679        let mut pooled_rss = 0.0_f64;
4680        for row in 0..n {
4681            for output in 0..2 {
4682                let residual = y[[row, output]] - fit.fitted[[row, output]];
4683                pooled_rss += residual * residual;
4684            }
4685        }
4686        let shared_nu = (2 * (n - fit.cache.nullity)) as f64;
4687        let expected_sigma2 = pooled_rss / shared_nu;
4688        assert!(expected_sigma2 > 0.0);
4689        assert!(
4690            (fit.sigma2[0] - expected_sigma2).abs()
4691                <= f64::EPSILON.sqrt() * expected_sigma2.max(1.0),
4692            "shared sigma2 {} must equal pooled vector deviance / shared dof {}",
4693            fit.sigma2[0],
4694            expected_sigma2
4695        );
4696    }
4697
4698    #[test]
4699    fn shared_dispersion_penalty_envelope_gradient_matches_refitted_direction() {
4700        let n = 24usize;
4701        let mut x = Array2::<f64>::zeros((n, 3));
4702        let mut y = Array2::<f64>::zeros((n, 2));
4703        for row in 0..n {
4704            let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
4705            x[[row, 0]] = 1.0;
4706            x[[row, 1]] = t;
4707            x[[row, 2]] = t * t;
4708            y[[row, 0]] = 0.3 + 1.2 * t - 0.8 * t * t + 0.04 * (7.0 * t).sin();
4709            y[[row, 1]] = -0.2 + 0.5 * t + 0.4 * t * t + 0.03 * (5.0 * t).cos();
4710        }
4711        let penalty = array![[0.0, 0.0, 0.0], [0.0, 0.7, 0.1], [0.0, 0.1, 1.4]];
4712        let direction = array![[0.0, 0.0, 0.0], [0.0, 0.3, -0.08], [0.0, -0.08, 0.6]];
4713        let fit = gaussian_reml_multi_shared_dispersion_closed_form(
4714            x.view(),
4715            y.view(),
4716            penalty.view(),
4717            None,
4718            None,
4719        )
4720        .unwrap();
4721        let gradient = gaussian_reml_multi_shared_dispersion_penalty_gradient_from_fit(
4722            x.view(),
4723            y.view(),
4724            penalty.view(),
4725            None,
4726            &fit,
4727        )
4728        .unwrap();
4729        let analytic = gradient
4730            .iter()
4731            .zip(direction.iter())
4732            .map(|(gradient, direction)| gradient * direction)
4733            .sum::<f64>();
4734
4735        let step = f64::EPSILON.cbrt();
4736        let plus_penalty = &penalty + &(direction.mapv(|value| step * value));
4737        let minus_penalty = &penalty - &(direction.mapv(|value| step * value));
4738        let plus = gaussian_reml_multi_shared_dispersion_closed_form(
4739            x.view(),
4740            y.view(),
4741            plus_penalty.view(),
4742            None,
4743            Some(fit.rho),
4744        )
4745        .unwrap();
4746        let minus = gaussian_reml_multi_shared_dispersion_closed_form(
4747            x.view(),
4748            y.view(),
4749            minus_penalty.view(),
4750            None,
4751            Some(fit.rho),
4752        )
4753        .unwrap();
4754        let numerical = (plus.reml_score - minus.reml_score) / (2.0 * step);
4755        let scale = analytic.abs().max(numerical.abs()).max(1.0);
4756        assert!(
4757            (analytic - numerical).abs() <= 2.0e-5 * scale,
4758            "shared-dispersion penalty envelope derivative mismatch: analytic={analytic}, refitted={numerical}"
4759        );
4760    }
4761
4762    #[test]
4763    fn block_orthogonal_score_matches_the_objective_derivative() {
4764        let gram = array![[3.0, 0.4], [0.4, 2.0]];
4765        let rhs = array![[1.2, -0.3], [0.6, 0.9]];
4766        let penalty = array![[1.0, 0.2], [0.2, 0.8]];
4767        let scale = array![1.3, 0.8];
4768        let rho = 0.37;
4769        let step = 1.0e-6;
4770        let eval = block_orthogonal_eval(&gram, &rhs, &penalty, rho).unwrap();
4771        let analytic = block_orthogonal_scale_objective(&eval, rho, scale.view(), 2).grad;
4772        let value_at = |candidate_rho: f64| {
4773            let candidate = block_orthogonal_eval(&gram, &rhs, &penalty, candidate_rho).unwrap();
4774            block_orthogonal_scale_objective(&candidate, candidate_rho, scale.view(), 2).value
4775        };
4776        let numerical = (value_at(rho + step) - value_at(rho - step)) / (2.0 * step);
4777        assert!(
4778            (analytic - numerical).abs() <= 1.0e-7 * analytic.abs().max(1.0),
4779            "analytic score {analytic:.12e} != objective derivative {numerical:.12e}"
4780        );
4781    }
4782
4783    #[test]
4784    fn block_orthogonal_profile_hessian_matches_the_profiled_objective() {
4785        let grams = [
4786            array![[3.0, 0.4], [0.4, 2.0]],
4787            array![[2.5, -0.2], [-0.2, 1.8]],
4788        ];
4789        let rhs = [
4790            array![[1.2, -0.3], [0.6, 0.9]],
4791            array![[0.5, 0.8], [-0.4, 0.7]],
4792        ];
4793        let penalties = [
4794            array![[1.0, 0.2], [0.2, 0.8]],
4795            array![[0.9, -0.1], [-0.1, 1.1]],
4796        ];
4797        let ranks = [2_usize, 2_usize];
4798        let ywy = array![8.0, 9.0];
4799        let nu = 7.0;
4800        let rhos = array![0.37, -0.21];
4801        let profile_value = |candidate_rhos: ArrayView1<'_, f64>| {
4802            let evals = (0..2)
4803                .map(|block| {
4804                    block_orthogonal_eval(
4805                        &grams[block],
4806                        &rhs[block],
4807                        &penalties[block],
4808                        candidate_rhos[block],
4809                    )
4810                    .unwrap()
4811                })
4812                .collect::<Vec<_>>();
4813            let mut q = ywy.clone();
4814            for eval in &evals {
4815                q -= &eval.fitted_energy;
4816            }
4817            let determinant_term = evals
4818                .iter()
4819                .enumerate()
4820                .map(|(block, eval)| eval.logdet - ranks[block] as f64 * candidate_rhos[block])
4821                .sum::<f64>();
4822            0.5 * 2.0 * determinant_term + 0.5 * nu * q.iter().map(|value| value.ln()).sum::<f64>()
4823        };
4824        let evals = (0..2)
4825            .map(|block| {
4826                block_orthogonal_eval(&grams[block], &rhs[block], &penalties[block], rhos[block])
4827                    .unwrap()
4828            })
4829            .collect::<Vec<_>>();
4830        let scale = block_orthogonal_conditional_scale(&evals, ywy.view(), nu).unwrap();
4831        let analytic =
4832            block_orthogonal_profile_hessian(&evals, rhos.view(), scale.view(), &ranks, nu)
4833                .unwrap();
4834        let step = 1.0e-4;
4835        let center = profile_value(rhos.view());
4836        let mut numerical = Array2::<f64>::zeros((2, 2));
4837        for coordinate in 0..2 {
4838            let mut plus = rhos.clone();
4839            let mut minus = rhos.clone();
4840            plus[coordinate] += step;
4841            minus[coordinate] -= step;
4842            numerical[[coordinate, coordinate]] = (profile_value(plus.view()) - 2.0 * center
4843                + profile_value(minus.view()))
4844                / (step * step);
4845        }
4846        let mut plus_plus = rhos.clone();
4847        let mut plus_minus = rhos.clone();
4848        let mut minus_plus = rhos.clone();
4849        let mut minus_minus = rhos.clone();
4850        plus_plus[0] += step;
4851        plus_plus[1] += step;
4852        plus_minus[0] += step;
4853        plus_minus[1] -= step;
4854        minus_plus[0] -= step;
4855        minus_plus[1] += step;
4856        minus_minus[0] -= step;
4857        minus_minus[1] -= step;
4858        let cross = (profile_value(plus_plus.view())
4859            - profile_value(plus_minus.view())
4860            - profile_value(minus_plus.view())
4861            + profile_value(minus_minus.view()))
4862            / (4.0 * step * step);
4863        numerical[[0, 1]] = cross;
4864        numerical[[1, 0]] = cross;
4865        for row in 0..2 {
4866            for col in 0..2 {
4867                assert!(
4868                    (analytic[[row, col]] - numerical[[row, col]]).abs()
4869                        <= 2.0e-6 * analytic[[row, col]].abs().max(1.0),
4870                    "profile Hessian ({row}, {col}) analytic {:.12e} != numerical {:.12e}",
4871                    analytic[[row, col]],
4872                    numerical[[row, col]]
4873                );
4874            }
4875        }
4876    }
4877
4878    #[test]
4879    fn block_orthogonal_shared_scale_fit_carries_a_score_certificate() {
4880        // Two mutually orthogonal ±1 blocks (Hadamard columns) with full-rank
4881        // penalties. A minted fit must satisfy the joint first-order REML
4882        // score certificate at its own returned iterate — re-derived here from
4883        // the same production primitives the solver certifies with, so a
4884        // regression that lets an iteration cap select the estimator fails.
4885        let c0 = [1.0_f64; 8];
4886        let c1 = [1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0];
4887        let c2 = [1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0];
4888        let c3 = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
4889        let mut d1 = Array2::<f64>::zeros((8, 2));
4890        let mut d2 = Array2::<f64>::zeros((8, 2));
4891        for i in 0..8 {
4892            d1[[i, 0]] = c0[i];
4893            d1[[i, 1]] = c1[i];
4894            d2[[i, 0]] = c2[i];
4895            d2[[i, 1]] = c3[i];
4896        }
4897        let penalties = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
4898        let bumps = [0.03, -0.05, 0.02, 0.01, -0.02, 0.04, -0.01, -0.02];
4899        let mut y = Array2::<f64>::zeros((8, 1));
4900        for i in 0..8 {
4901            y[[i, 0]] = c0[i] + 0.5 * c1[i] + 0.25 * c2[i] + bumps[i];
4902        }
4903
4904        let result = gaussian_reml_blocks_orthogonal_shared_scale(
4905            &[d1.clone(), d2.clone()],
4906            &penalties,
4907            y.view(),
4908            None,
4909            None,
4910        )
4911        .expect("well-posed orthogonal-block fit must certify and mint");
4912
4913        let weight = Array1::<f64>::ones(8);
4914        let ywy = (0..8).map(|i| y[[i, 0]] * y[[i, 0]]).sum::<f64>();
4915        // Full-rank penalties: zero total nullity, so nu = n.
4916        let nu = 8.0_f64;
4917        let mut evals = Vec::new();
4918        for (block, design) in [&d1, &d2].into_iter().enumerate() {
4919            let gram = canonicalize_penalty(dense_xt_diag_x(design.view(), weight.view()).view());
4920            let rhs = dense_xt_diag_y(design.view(), weight.view(), y.view());
4921            let pen = canonicalize_penalty(penalties[block].view());
4922            evals.push(
4923                block_orthogonal_eval(&gram, &rhs, &pen, result.log_lambdas[block])
4924                    .expect("block eval at the minted rho"),
4925            );
4926        }
4927        let explained: f64 = evals.iter().map(|eval| eval.fitted_energy[0]).sum();
4928        let q = ywy - explained;
4929        assert!(q > 0.0);
4930        let scale = Array1::from_vec(vec![nu / q]);
4931        for (block, eval) in evals.iter().enumerate() {
4932            let derivs =
4933                block_orthogonal_scale_objective(eval, result.log_lambdas[block], scale.view(), 2);
4934            let residual = derivs.grad.abs() / 2.0;
4935            assert!(
4936                residual <= BLOCK_ORTHOGONAL_SCORE_TOL,
4937                "block {block} score residual {residual:.3e} exceeds the certificate tolerance"
4938            );
4939        }
4940        let curvature = block_orthogonal_profile_curvature(
4941            &evals,
4942            result.log_lambdas.view(),
4943            scale.view(),
4944            &[2, 2],
4945            nu,
4946        )
4947        .unwrap();
4948        assert!(
4949            curvature.min_eigenvalue >= -curvature.roundoff,
4950            "minted fit has negative profiled curvature {:.6e} beyond roundoff {:.3e}",
4951            curvature.min_eigenvalue,
4952            curvature.roundoff
4953        );
4954
4955        let err = gaussian_reml_blocks_orthogonal_shared_scale_with_controls(
4956            &[d1, d2],
4957            &penalties,
4958            y.view(),
4959            None,
4960            None,
4961            BlockOrthogonalControls {
4962                max_outer_passes: 0,
4963                ..BlockOrthogonalControls::default()
4964            },
4965        )
4966        .unwrap_err();
4967        match err {
4968            EstimationError::BlockOrthogonalRemlDidNotConverge {
4969                iterations,
4970                max_score_residual,
4971                rho_checkpoint,
4972                ..
4973            } => {
4974                assert_eq!(iterations, 0);
4975                assert!(max_score_residual.is_infinite());
4976                assert_eq!(rho_checkpoint, vec![0.0, 0.0]);
4977            }
4978            other => panic!("expected typed block-orthogonal exhaustion, got {other}"),
4979        }
4980    }
4981
4982    #[test]
4983    fn block_orthogonal_solver_rejects_cross_block_signal() {
4984        let first = array![[1.0], [1.0], [1.0], [1.0], [1.0], [1.0]];
4985        let second = array![[0.0], [1.0], [2.0], [3.0], [4.0], [5.0]];
4986        let penalties = vec![Array2::<f64>::eye(1), Array2::<f64>::eye(1)];
4987        let y = array![[0.2], [0.8], [1.7], [3.1], [3.9], [5.2]];
4988        let err = gaussian_reml_blocks_orthogonal_shared_scale(
4989            &[first, second],
4990            &penalties,
4991            y.view(),
4992            None,
4993            None,
4994        )
4995        .unwrap_err();
4996        assert!(
4997            matches!(&err, EstimationError::InvalidInput(_)),
4998            "nonorthogonal blocks must fail the decomposed-objective contract: {err}"
4999        );
5000        assert!(err.to_string().contains("weighted cross-product"));
5001    }
5002
5003    #[test]
5004    fn multi_output_duplicate_columns_match_scalar_fit() {
5005        let x = array![
5006            [1.0, -1.0],
5007            [1.0, -0.5],
5008            [1.0, 0.0],
5009            [1.0, 0.5],
5010            [1.0, 1.0],
5011            [1.0, 1.5],
5012        ];
5013        let y1 = array![0.5, 0.2, 0.0, 0.3, 1.1, 2.0];
5014        let y = Array2::from_shape_fn(
5015            (y1.len(), 2),
5016            |(i, j)| if j == 0 { y1[i] } else { 2.0 * y1[i] },
5017        );
5018        let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5019
5020        let scalar =
5021            gaussian_reml_closed_form(x.view(), y1.view(), penalty.view(), None, Some(0.0))
5022                .expect("scalar Gaussian REML fit");
5023        let multi =
5024            gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5025                .expect("multi-output Gaussian REML fit");
5026
5027        assert!((multi.rho - scalar.rho).abs() <= 1.0e-8);
5028        for i in 0..x.ncols() {
5029            assert!((multi.coefficients[[i, 0]] - scalar.coefficients[i]).abs() <= 1.0e-8);
5030            assert!((multi.coefficients[[i, 1]] - 2.0 * scalar.coefficients[i]).abs() <= 1.0e-8);
5031        }
5032    }
5033
5034    #[test]
5035    fn warm_start_reuses_cache_and_lambda_seed() {
5036        let x = array![
5037            [1.0, -1.0],
5038            [1.0, -0.25],
5039            [1.0, 0.5],
5040            [1.0, 1.25],
5041            [1.0, 2.0],
5042        ];
5043        let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5044        let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5045
5046        let cold =
5047            gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
5048                .expect("cold fit");
5049        let warm_start = GaussianRemlWarmStart::from_multi_result(&cold);
5050        let warm = gaussian_reml_multi_closed_form_warm_started(
5051            x.view(),
5052            y.view(),
5053            penalty.view(),
5054            None,
5055            Some(&warm_start),
5056        )
5057        .expect("warm-started fit");
5058
5059        assert!((cold.lambda - warm.lambda).abs() <= 1.0e-10);
5060        assert_eq!(cold.cache.xtwx_fingerprint, warm.cache.xtwx_fingerprint);
5061        for i in 0..x.ncols() {
5062            assert!((cold.coefficients[[i, 0]] - warm.coefficients[[i, 0]]).abs() <= 1.0e-10);
5063        }
5064    }
5065
5066    #[test]
5067    fn warm_start_cache_rejects_different_penalty_geometry() {
5068        let x = array![
5069            [1.0, -1.0],
5070            [1.0, -0.25],
5071            [1.0, 0.5],
5072            [1.0, 1.25],
5073            [1.0, 2.0],
5074        ];
5075        let y = array![[0.1], [0.4], [0.7], [1.4], [2.2]];
5076        let penalty_a = array![[0.0, 0.0], [0.0, 1.0]];
5077        let penalty_b = array![[1.0, -1.0], [-1.0, 1.0]];
5078
5079        let first =
5080            gaussian_reml_multi_closed_form(x.view(), y.view(), penalty_a.view(), None, Some(0.0))
5081                .expect("first fit");
5082        let warm_start = GaussianRemlWarmStart::from_multi_result(&first);
5083        let err = gaussian_reml_multi_closed_form_warm_started(
5084            x.view(),
5085            y.view(),
5086            penalty_b.view(),
5087            None,
5088            Some(&warm_start),
5089        )
5090        .expect_err("penalty-mismatched cache must be rejected");
5091
5092        assert!(err.to_string().contains("penalty mismatch"));
5093    }
5094
5095    #[test]
5096    fn no_alloc_cache_path_matches_allocating_fit() {
5097        let x = array![
5098            [1.0, -1.0, 0.25],
5099            [1.0, -0.5, 0.10],
5100            [1.0, 0.0, -0.20],
5101            [1.0, 0.5, -0.05],
5102            [1.0, 1.0, 0.30],
5103            [1.0, 1.5, 0.60],
5104        ];
5105        let y = array![
5106            [0.0, 0.2],
5107            [0.3, 0.1],
5108            [0.4, -0.1],
5109            [0.9, 0.3],
5110            [1.6, 0.8],
5111            [2.2, 1.2],
5112        ];
5113        let weights = array![1.0, 0.8, 1.2, 1.1, 0.9, 1.3];
5114        let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 4.0]];
5115
5116        let allocating = gaussian_reml_multi_closed_form_with_cache(
5117            x.view(),
5118            y.view(),
5119            penalty.view(),
5120            Some(weights.view()),
5121            Some(1.0),
5122            None,
5123        )
5124        .expect("allocating fit");
5125        let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5126        let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5127        let mut fitted = Array2::zeros(y.dim());
5128        let mut sigma2 = Array1::zeros(y.ncols());
5129
5130        let no_alloc = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5131            x.view(),
5132            y.view(),
5133            penalty.view(),
5134            Some(weights.view()),
5135            Some(allocating.lambda),
5136            &allocating.cache,
5137            &mut workspace,
5138            coefficients.view_mut(),
5139            fitted.view_mut(),
5140            sigma2.view_mut(),
5141        )
5142        .expect("no-alloc cached fit");
5143
5144        assert!((no_alloc.lambda - allocating.lambda).abs() <= 1.0e-10);
5145        assert!((no_alloc.reml_score - allocating.reml_score).abs() <= 1.0e-8);
5146        assert!((no_alloc.reml_grad_rho - allocating.reml_grad_rho).abs() <= 1.0e-8);
5147        assert!((no_alloc.reml_hess_rho - allocating.reml_hess_rho).abs() <= 1.0e-8);
5148        assert!((no_alloc.edf - allocating.edf).abs() <= 1.0e-10);
5149        for i in 0..x.ncols() {
5150            for j in 0..y.ncols() {
5151                assert!((coefficients[[i, j]] - allocating.coefficients[[i, j]]).abs() <= 1.0e-8);
5152            }
5153        }
5154        for i in 0..x.nrows() {
5155            for j in 0..y.ncols() {
5156                assert!((fitted[[i, j]] - allocating.fitted[[i, j]]).abs() <= 1.0e-8);
5157            }
5158        }
5159        for j in 0..y.ncols() {
5160            assert!((sigma2[j] - allocating.sigma2[j]).abs() <= 1.0e-10);
5161        }
5162    }
5163
5164    #[test]
5165    fn no_alloc_cache_path_rejects_bad_shapes_and_penalty_mismatch() {
5166        let x = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
5167        let y = array![[0.0], [0.2], [0.9], [1.8]];
5168        let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5169        let cache = build_gaussian_reml_eigen_cache(x.view(), penalty.view(), None)
5170            .expect("Gaussian REML cache");
5171
5172        let mut bad_workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols() + 1);
5173        let mut coefficients = Array2::zeros((x.ncols(), y.ncols()));
5174        let mut fitted = Array2::zeros(y.dim());
5175        let mut sigma2 = Array1::zeros(y.ncols());
5176        let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5177            x.view(),
5178            y.view(),
5179            penalty.view(),
5180            None,
5181            Some(1.0),
5182            &cache,
5183            &mut bad_workspace,
5184            coefficients.view_mut(),
5185            fitted.view_mut(),
5186            sigma2.view_mut(),
5187        )
5188        .expect_err("workspace shape mismatch must be rejected");
5189        assert!(err.to_string().contains("workspace shape mismatch"));
5190
5191        let penalty_mismatch = array![[1.0, -1.0], [-1.0, 1.0]];
5192        let mut workspace = GaussianRemlNoAllocWorkspace::new(x.ncols(), y.ncols());
5193        let err = gaussian_reml_multi_closed_form_with_cache_no_alloc(
5194            x.view(),
5195            y.view(),
5196            penalty_mismatch.view(),
5197            None,
5198            Some(1.0),
5199            &cache,
5200            &mut workspace,
5201            coefficients.view_mut(),
5202            fitted.view_mut(),
5203            sigma2.view_mut(),
5204        )
5205        .expect_err("penalty mismatch must be rejected");
5206        assert!(err.to_string().contains("penalty mismatch"));
5207    }
5208
5209    #[derive(Clone, Copy, Debug)]
5210    enum ForwardScalar {
5211        Lambda,
5212        RemlScore,
5213        Coefficient(usize, usize),
5214        Fitted(usize, usize),
5215        Edf,
5216    }
5217
5218    fn finite_difference_design() -> Array2<f64> {
5219        Array2::from_shape_fn((20, 5), |(row, col)| {
5220            let t = (row as f64 - 9.5) / 10.0;
5221            match col {
5222                0 => 1.0,
5223                1 => t,
5224                2 => 0.5 * (3.0 * t * t - 1.0),
5225                3 => 0.5 * (5.0 * t * t * t - 3.0 * t),
5226                4 => (35.0 * t.powi(4) - 30.0 * t * t + 3.0) / 8.0,
5227                _ => unreachable!(),
5228            }
5229        })
5230    }
5231
5232    fn finite_difference_response(outputs: usize) -> Array2<f64> {
5233        // The truth must NOT lie (essentially) in span(X). The 5-column design
5234        // is Legendre P_0..P_4, so a low-order polynomial + low-frequency sin
5235        // would be fit to near machine precision — driving σ² → 0, dp → 0,
5236        // and ∂score/∂y ≈ ν w r / dp → ∞. Central finite differences with
5237        // Richardson extrapolation cannot resolve such steep, highly-nonlinear
5238        // surfaces at 1e-6 relative because the truncation term scales with
5239        // f^(5)(y), which explodes in that regime. The high-frequency sin
5240        // below is well outside span(P_0..P_4) on t ∈ [-0.95, 0.95], leaving
5241        // a genuine residual (σ² ≈ 1e-3) and an interior REML optimum
5242        // (ρ ≈ -3) at which the analytic-vs-FD comparison is meaningful.
5243        Array2::from_shape_fn((20, outputs), |(row, output)| {
5244            let t = (row as f64 - 9.5) / 10.0;
5245            let phase = output as f64 + 1.0;
5246            0.2 + 0.25 * phase * t - 0.12 * t * t
5247                + (0.08 + 0.03 * phase) * (1.1 * t + 0.3 * phase).sin()
5248                + 0.05 * (7.0 * t + 0.5 * phase).sin()
5249        })
5250    }
5251
5252    fn finite_difference_penalty() -> Array2<f64> {
5253        Array2::from_diag(&array![0.0, 0.8, 1.2, 1.7, 2.3])
5254    }
5255
5256    fn finite_difference_weights() -> Array1<f64> {
5257        Array1::from_shape_fn(20, |row| {
5258            let t = (row as f64 - 9.5) / 10.0;
5259            1.0 + 0.025 * (1.1 * t).sin() + 0.01 * t
5260        })
5261    }
5262
5263    /// Fallible forward-scalar probe. Returns `None` when the closed-form fit
5264    /// rejects the inputs — the relevant case being a penalty perturbation that
5265    /// pushes `S` out of the PSD cone (a single-entry central bump on a
5266    /// null-direction entry drives one eigenvalue slightly negative). Such a
5267    /// point has no well-defined REML objective, so the caller skips it rather
5268    /// than panicking.
5269    fn one_hot_objective_try(
5270        x: ArrayView2<'_, f64>,
5271        y: ArrayView2<'_, f64>,
5272        penalty: ArrayView2<'_, f64>,
5273        weights: ArrayView1<'_, f64>,
5274        target: ForwardScalar,
5275    ) -> Option<f64> {
5276        let fit = gaussian_reml_multi_closed_form_with_cache(
5277            x,
5278            y,
5279            penalty,
5280            Some(weights),
5281            Some(0.85),
5282            None,
5283        )
5284        .ok()?;
5285        Some(match target {
5286            ForwardScalar::Lambda => fit.lambda,
5287            ForwardScalar::RemlScore => fit.reml_score,
5288            ForwardScalar::Coefficient(row, col) => fit.coefficients[[row, col]],
5289            ForwardScalar::Fitted(row, col) => fit.fitted[[row, col]],
5290            ForwardScalar::Edf => fit.edf,
5291        })
5292    }
5293
5294    fn one_hot_objective(
5295        x: ArrayView2<'_, f64>,
5296        y: ArrayView2<'_, f64>,
5297        penalty: ArrayView2<'_, f64>,
5298        weights: ArrayView1<'_, f64>,
5299        target: ForwardScalar,
5300    ) -> f64 {
5301        one_hot_objective_try(x, y, penalty, weights, target)
5302            .expect("finite-difference forward fit")
5303    }
5304
5305    fn one_hot_backward(
5306        x: ArrayView2<'_, f64>,
5307        y: ArrayView2<'_, f64>,
5308        penalty: ArrayView2<'_, f64>,
5309        weights: ArrayView1<'_, f64>,
5310        target: ForwardScalar,
5311    ) -> GaussianRemlBackwardResult {
5312        let mut grad_coefficients = Array2::<f64>::zeros((x.ncols(), y.ncols()));
5313        let mut grad_fitted = Array2::<f64>::zeros(y.dim());
5314        let (grad_lambda, grad_score, grad_edf, coefficient_upstream, fitted_upstream) =
5315            match target {
5316                ForwardScalar::Lambda => (1.0, 0.0, 0.0, None, None),
5317                ForwardScalar::RemlScore => (0.0, 1.0, 0.0, None, None),
5318                ForwardScalar::Coefficient(row, col) => {
5319                    grad_coefficients[[row, col]] = 1.0;
5320                    (0.0, 0.0, 0.0, Some(grad_coefficients.view()), None)
5321                }
5322                ForwardScalar::Fitted(row, col) => {
5323                    grad_fitted[[row, col]] = 1.0;
5324                    (0.0, 0.0, 0.0, None, Some(grad_fitted.view()))
5325                }
5326                ForwardScalar::Edf => (0.0, 0.0, 1.0, None, None),
5327            };
5328        gaussian_reml_multi_closed_form_backward(
5329            x,
5330            y,
5331            penalty,
5332            Some(weights),
5333            Some(0.85),
5334            grad_lambda,
5335            coefficient_upstream,
5336            fitted_upstream,
5337            grad_score,
5338            grad_edf,
5339        )
5340        .expect("analytic backward VJP")
5341    }
5342
5343    fn assert_fd_close(label: &str, analytic: f64, finite_difference: f64) {
5344        let rel_tol = 1.0e-6_f64;
5345        let abs_tol = 1.0e-6_f64;
5346        let tol = abs_tol.max(rel_tol * analytic.abs().max(finite_difference.abs()));
5347        let diff = (analytic - finite_difference).abs();
5348        assert!(
5349            diff <= tol,
5350            "{label}: analytic={analytic:.12e}, finite_difference={finite_difference:.12e}, diff={diff:.3e}, tol={tol:.3e}"
5351        );
5352    }
5353
5354    fn adaptive_central_difference(mut eval: impl FnMut(f64) -> f64) -> f64 {
5355        let steps: [f64; 5] = [1.0e-3, 5.0e-4, 2.5e-4, 1.25e-4, 6.25e-5];
5356        let mut best = f64::NAN;
5357        let mut best_delta = f64::INFINITY;
5358        let mut previous: Option<f64> = None;
5359        for h in steps {
5360            let d1 = (eval(h) - eval(-h)) / (2.0 * h);
5361            let half_h = 0.5 * h;
5362            let d2 = (eval(half_h) - eval(-half_h)) / (2.0 * half_h);
5363            let estimate: f64 = d2 + (d2 - d1) / 3.0;
5364            if let Some(prev) = previous {
5365                let delta = (estimate - prev).abs();
5366                if delta < best_delta {
5367                    best_delta = delta;
5368                    best = estimate;
5369                }
5370            } else {
5371                best = estimate;
5372            }
5373            previous = Some(estimate);
5374        }
5375        best
5376    }
5377
5378    fn assert_backward_matches_forward_finite_difference(outputs: usize) {
5379        let x = finite_difference_design();
5380        let y = finite_difference_response(outputs);
5381        let penalty = finite_difference_penalty();
5382        let weights = finite_difference_weights();
5383        let targets = [
5384            ForwardScalar::Lambda,
5385            ForwardScalar::RemlScore,
5386            ForwardScalar::Coefficient(3, outputs - 1),
5387            ForwardScalar::Fitted(12, outputs - 1),
5388            ForwardScalar::Edf,
5389        ];
5390        for target in targets {
5391            let backward =
5392                one_hot_backward(x.view(), y.view(), penalty.view(), weights.view(), target);
5393
5394            for row in 0..x.nrows() {
5395                for col in 0..x.ncols() {
5396                    let eval = |delta: f64| {
5397                        let mut candidate = x.clone();
5398                        candidate[[row, col]] += delta;
5399                        one_hot_objective(
5400                            candidate.view(),
5401                            y.view(),
5402                            penalty.view(),
5403                            weights.view(),
5404                            target,
5405                        )
5406                    };
5407                    let fd = adaptive_central_difference(eval);
5408                    assert_fd_close(
5409                        &format!("target={target:?} x[{row},{col}]"),
5410                        backward.grad_x[[row, col]],
5411                        fd,
5412                    );
5413                }
5414            }
5415
5416            for row in 0..y.nrows() {
5417                for col in 0..y.ncols() {
5418                    let eval = |delta: f64| {
5419                        let mut candidate = y.clone();
5420                        candidate[[row, col]] += delta;
5421                        one_hot_objective(
5422                            x.view(),
5423                            candidate.view(),
5424                            penalty.view(),
5425                            weights.view(),
5426                            target,
5427                        )
5428                    };
5429                    let fd = adaptive_central_difference(eval);
5430                    assert_fd_close(
5431                        &format!("target={target:?} y[{row},{col}]"),
5432                        backward.grad_y[[row, col]],
5433                        fd,
5434                    );
5435                }
5436            }
5437
5438            for row in 0..weights.len() {
5439                let eval = |delta: f64| {
5440                    let mut candidate = weights.clone();
5441                    candidate[row] += delta;
5442                    one_hot_objective(x.view(), y.view(), penalty.view(), candidate.view(), target)
5443                };
5444                let fd = adaptive_central_difference(eval);
5445                assert_fd_close(
5446                    &format!("target={target:?} weights[{row}]"),
5447                    backward.grad_weights[row],
5448                    fd,
5449                );
5450            }
5451
5452            // ∂L/∂S over the RANGE-SPACE penalty entries. The REML objective
5453            // carries −½d·log|S|₊ (the pseudo-determinant over the NONZERO
5454            // eigenvalues), so ∂L/∂S is only a finite, FD-verifiable derivative
5455            // where a central ±h bump keeps S inside the PSD cone WITHOUT
5456            // changing its rank. A single-entry bump touching the null
5457            // direction violates both: the −h side drives an eigenvalue
5458            // slightly negative (leaves the cone → fit Err) and the +h side
5459            // turns the zero eigenvalue into a tiny positive one that joins
5460            // log|S|₊ as a −log(ε) term (a rank-change discontinuity in L).
5461            // The null-direction component of the analytic S-gradient is a
5462            // gauge convention for the null space (the L-metric pseudoinverse
5463            // `penalty_pinv` = L⁻ᵀ T⁺ L⁻¹), validated by algebra/consumer, not
5464            // FD. So restrict to the strictly-positive diagonal block (both
5465            // indices in 1..p for the diag([0, 0.8, 1.2, 1.7, 2.3]) fixture,
5466            // where S_rr > 0 and ±h stays PSD at full rank). The forward
5467            // consumes only `S_canon = 0.5(S + Sᵀ)` and the backward returns
5468            // the symmetrized gradient, so a single-entry bump of S[r, c]
5469            // (asymmetric) compares directly against `grad_penalty[r, c]` =
5470            // 0.5(G[r, c] + G[c, r]). Defensively, any entry whose largest ±h
5471            // probe leaves the cone is skipped (cone membership is monotone in
5472            // |h| here, so probing the largest step suffices).
5473            let null_index = 0usize; // diag([0.0, ...]) ⇒ coordinate 0 is the null direction.
5474            let probe_h = 1.0e-3_f64; // matches the largest adaptive_central_difference step.
5475            for r in 0..penalty.nrows() {
5476                for c in 0..penalty.ncols() {
5477                    if r == null_index || c == null_index {
5478                        continue;
5479                    }
5480                    let eval = |delta: f64| {
5481                        let mut candidate = penalty.clone();
5482                        candidate[[r, c]] += delta;
5483                        one_hot_objective(
5484                            x.view(),
5485                            y.view(),
5486                            candidate.view(),
5487                            weights.view(),
5488                            target,
5489                        )
5490                    };
5491                    let cone_safe = {
5492                        let mut s_plus = penalty.clone();
5493                        let mut s_minus = penalty.clone();
5494                        s_plus[[r, c]] += probe_h;
5495                        s_minus[[r, c]] -= probe_h;
5496                        one_hot_objective_try(
5497                            x.view(),
5498                            y.view(),
5499                            s_plus.view(),
5500                            weights.view(),
5501                            target,
5502                        )
5503                        .is_some()
5504                            && one_hot_objective_try(
5505                                x.view(),
5506                                y.view(),
5507                                s_minus.view(),
5508                                weights.view(),
5509                                target,
5510                            )
5511                            .is_some()
5512                    };
5513                    if !cone_safe {
5514                        continue;
5515                    }
5516                    let fd = adaptive_central_difference(eval);
5517                    assert_fd_close(
5518                        &format!("target={target:?} penalty[{r},{c}]"),
5519                        backward.grad_penalty[[r, c]],
5520                        fd,
5521                    );
5522                }
5523            }
5524        }
5525    }
5526
5527    #[test]
5528    fn scalar_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5529        assert_backward_matches_forward_finite_difference(1);
5530    }
5531
5532    #[test]
5533    fn multi_output_backward_matches_forward_finite_difference_for_all_x_y_and_weight_entries() {
5534        assert_backward_matches_forward_finite_difference(3);
5535    }
5536
5537    #[test]
5538    fn backward_vjp_matches_finite_difference() {
5539        let x = array![
5540            [1.0, -1.0, 0.2],
5541            [1.0, -0.3, -0.1],
5542            [1.0, 0.2, 0.4],
5543            [1.0, 0.8, 0.1],
5544            [1.0, 1.4, 0.5],
5545            [1.0, 2.0, 0.9],
5546        ];
5547        let y = array![
5548            [0.1, -0.2],
5549            [0.2, 0.1],
5550            [0.7, 0.0],
5551            [1.1, 0.3],
5552            [1.8, 0.9],
5553            [2.4, 1.4],
5554        ];
5555        let weights = array![1.0, 0.9, 1.1, 1.2, 0.8, 1.3];
5556        let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.2], [0.0, 0.2, 1.7]];
5557        let upstream_coefficients = array![[0.2, -0.1], [0.05, 0.03], [-0.04, 0.07]];
5558        let upstream_fitted = array![
5559            [0.01, -0.02],
5560            [0.03, 0.01],
5561            [-0.01, 0.02],
5562            [0.04, -0.03],
5563            [0.02, 0.05],
5564            [-0.02, 0.01],
5565        ];
5566        let upstream_lambda = 0.17;
5567        let upstream_score = -0.11;
5568
5569        let backward = gaussian_reml_multi_closed_form_backward(
5570            x.view(),
5571            y.view(),
5572            penalty.view(),
5573            Some(weights.view()),
5574            Some(0.8),
5575            upstream_lambda,
5576            Some(upstream_coefficients.view()),
5577            Some(upstream_fitted.view()),
5578            upstream_score,
5579            0.0,
5580        )
5581        .expect("backward VJP");
5582
5583        let objective = |x_eval: &Array2<f64>, y_eval: &Array2<f64>, w_eval: &Array1<f64>| {
5584            let fit = gaussian_reml_multi_closed_form_with_cache(
5585                x_eval.view(),
5586                y_eval.view(),
5587                penalty.view(),
5588                Some(w_eval.view()),
5589                Some(0.8),
5590                None,
5591            )
5592            .expect("fit for objective");
5593            upstream_lambda * fit.lambda
5594                + upstream_score * fit.reml_score
5595                + (&fit.coefficients * &upstream_coefficients).sum()
5596                + (&fit.fitted * &upstream_fitted).sum()
5597        };
5598        let eps = 1.0e-6;
5599        assert!(objective(&x, &y, &weights).is_finite());
5600
5601        let mut x_plus = x.clone();
5602        let mut x_minus = x.clone();
5603        x_plus[[3, 2]] += eps;
5604        x_minus[[3, 2]] -= eps;
5605        let fd_x =
5606            (objective(&x_plus, &y, &weights) - objective(&x_minus, &y, &weights)) / (2.0 * eps);
5607        assert!(
5608            (fd_x - backward.grad_x[[3, 2]]).abs() <= 2.0e-4,
5609            "grad_x mismatch: analytic={} fd={}",
5610            backward.grad_x[[3, 2]],
5611            fd_x
5612        );
5613
5614        let mut y_plus = y.clone();
5615        let mut y_minus = y.clone();
5616        y_plus[[4, 1]] += eps;
5617        y_minus[[4, 1]] -= eps;
5618        let fd_y =
5619            (objective(&x, &y_plus, &weights) - objective(&x, &y_minus, &weights)) / (2.0 * eps);
5620        assert!(
5621            (fd_y - backward.grad_y[[4, 1]]).abs() <= 2.0e-4,
5622            "grad_y mismatch: analytic={} fd={}",
5623            backward.grad_y[[4, 1]],
5624            fd_y
5625        );
5626
5627        let mut w_plus = weights.clone();
5628        let mut w_minus = weights.clone();
5629        w_plus[2] += eps;
5630        w_minus[2] -= eps;
5631        let fd_w = (objective(&x, &y, &w_plus) - objective(&x, &y, &w_minus)) / (2.0 * eps);
5632        assert!(
5633            (fd_w - backward.grad_weights[2]).abs() <= 2.0e-4,
5634            "grad_weight mismatch: analytic={} fd={}",
5635            backward.grad_weights[2],
5636            fd_w
5637        );
5638
5639        // Combined-seed ∂L/∂S spot-check: perturb individual penalty entries with
5640        // x/y/w held at base, under mixed (λ, score, β, fitted) seeds. The penalty
5641        // [[0,0,0],[0,1,0.2],[0,0.2,1.7]] is nullity 1 (coordinate 0 is the null
5642        // direction); ∂L/∂S is FD-verifiable only on the strictly-positive
5643        // RANGE block (indices 1,2), where a central ±h bump keeps S PSD at full
5644        // rank. Null-touching entries (any index 0) are non-FD-verifiable — the
5645        // −½d·log|S|₊ pseudo-determinant term makes L either cone-leaving or
5646        // rank-change-discontinuous there (see the exhaustive S loop above). A
5647        // single-entry asymmetric bump of S[r, c] compares directly to
5648        // grad_penalty[[r, c]] = 0.5(G[r,c] + G[c,r]), exercising the backward
5649        // symmetrization.
5650        let objective_s = |s_eval: &Array2<f64>| {
5651            let fit = gaussian_reml_multi_closed_form_with_cache(
5652                x.view(),
5653                y.view(),
5654                s_eval.view(),
5655                Some(weights.view()),
5656                Some(0.8),
5657                None,
5658            )
5659            .expect("fit for penalty objective");
5660            upstream_lambda * fit.lambda
5661                + upstream_score * fit.reml_score
5662                + (&fit.coefficients * &upstream_coefficients).sum()
5663                + (&fit.fitted * &upstream_fitted).sum()
5664        };
5665        // (1,1) full-rank diagonal; (1,2) pure off-diagonal between two penalized
5666        // directions; (2,2) full-rank diagonal. All in the strictly-positive
5667        // range block, so ±h stays PSD at full rank.
5668        for (r, c) in [(1usize, 1usize), (1, 2), (2, 2)] {
5669            let mut s_plus = penalty.clone();
5670            let mut s_minus = penalty.clone();
5671            s_plus[[r, c]] += eps;
5672            s_minus[[r, c]] -= eps;
5673            let fd_s = (objective_s(&s_plus) - objective_s(&s_minus)) / (2.0 * eps);
5674            assert!(
5675                (fd_s - backward.grad_penalty[[r, c]]).abs() <= 2.0e-4,
5676                "grad_penalty[{r},{c}] mismatch: analytic={} fd={}",
5677                backward.grad_penalty[[r, c]],
5678                fd_s
5679            );
5680        }
5681    }
5682
5683    #[test]
5684    fn batched_eigen_cache_matches_per_fit_build() {
5685        // Three K=3 problems sharing the same penalty matrix. The batched
5686        // pipeline must produce caches that are bit-exact identical to what
5687        // the per-fit `gaussian_reml_eigen_cache_from_xtwx` builder produces,
5688        // regardless of whether the GPU batched Cholesky kicks in or the
5689        // helper falls through to per-fit Cholesky.
5690        let xtwx_a = array![[4.0, 1.0], [1.0, 3.0]];
5691        let xtwx_b = array![[2.5, -0.5], [-0.5, 1.7]];
5692        let xtwx_c = array![[7.2, 0.3], [0.3, 5.1]];
5693        let penalty = array![[0.0, 0.0], [0.0, 1.0]];
5694
5695        let batched = build_gaussian_reml_eigen_cache_batched(
5696            vec![xtwx_a.clone(), xtwx_b.clone(), xtwx_c.clone()],
5697            penalty.view(),
5698            None,
5699        );
5700        assert_eq!(batched.len(), 3);
5701
5702        for (xtwx, batched_cache) in [&xtwx_a, &xtwx_b, &xtwx_c].into_iter().zip(batched.iter()) {
5703            let single = gaussian_reml_eigen_cache_from_xtwx(xtwx.clone(), penalty.view(), None)
5704                .expect("per-fit cache");
5705            let batched_cache = batched_cache.as_ref().expect("batched cache");
5706            assert_eq!(batched_cache.penalty_rank, single.penalty_rank);
5707            assert_eq!(batched_cache.nullity, single.nullity);
5708            assert_eq!(batched_cache.xtwx_fingerprint, single.xtwx_fingerprint);
5709            assert_eq!(
5710                batched_cache.penalty_fingerprint,
5711                single.penalty_fingerprint
5712            );
5713            assert!((batched_cache.logdet_xtwx - single.logdet_xtwx).abs() <= 1.0e-12);
5714            assert!(
5715                (batched_cache.logdet_penalty_positive - single.logdet_penalty_positive).abs()
5716                    <= 1.0e-12
5717            );
5718            for (a, b) in batched_cache
5719                .penalty_eigenvalues
5720                .iter()
5721                .zip(single.penalty_eigenvalues.iter())
5722            {
5723                assert!((a - b).abs() <= 1.0e-12);
5724            }
5725            for ((a, b), _) in batched_cache
5726                .coefficient_basis
5727                .iter()
5728                .zip(single.coefficient_basis.iter())
5729                .zip(0..)
5730            {
5731                assert!((a - b).abs() <= 1.0e-12);
5732            }
5733        }
5734    }
5735
5736    #[test]
5737    fn scalar_rho_optimizer_chooses_lowest_cost_stationary_point() {
5738        let cache = GaussianRemlEigenCache {
5739            penalty_eigenvalues: array![5.2430192311066924e-05, 81734184.18548436],
5740            eigenvectors: Array2::eye(2),
5741            coefficient_basis: Array2::eye(2),
5742            xtwx_fingerprint: 0,
5743            penalty_fingerprint: 0,
5744            logdet_xtwx: 0.0,
5745            logdet_penalty_positive: 0.0,
5746            penalty_rank: 2,
5747            nullity: 0,
5748        };
5749        let prepared = GaussianRemlPrepared {
5750            cache: cache.clone(),
5751            ywy: array![0.5021347226586624],
5752            projected_rhs_squared: array![[0.361060218768292], [0.01014486085547482]],
5753            projected_rhs: array![
5754                [0.361060218768292_f64.sqrt()],
5755                [0.01014486085547482_f64.sqrt()]
5756            ],
5757            n_effective: 100,
5758            n_outputs: 1,
5759        };
5760
5761        let rho = optimize_rho(&prepared, None).expect("allocating rho optimizer");
5762        let no_alloc_rho = optimize_rho_no_alloc(
5763            &cache,
5764            prepared.ywy.view(),
5765            prepared.projected_rhs_squared.view(),
5766            prepared.n_effective,
5767            prepared.n_outputs,
5768            None,
5769        )
5770        .expect("no-alloc rho optimizer");
5771
5772        assert!(
5773            (rho - 4.3251059890).abs() < 1.0e-6,
5774            "rho optimizer selected {rho}, expected the lower-cost later stationary point"
5775        );
5776        // Both paths reduce through `enumerate_and_select_rho` with identical
5777        // candidate order and a single strict-`<` running-best over numerically
5778        // identical evaluations, so the selected ρ is bit-for-bit equal.
5779        assert_eq!(
5780            no_alloc_rho, rho,
5781            "no-alloc optimizer selected {no_alloc_rho}, allocating selected {rho}"
5782        );
5783        assert!(prepared.evaluate(rho).cost < prepared.evaluate(-18.9277503549).cost);
5784    }
5785
5786    /// Deterministic linear-congruential generator (Knuth/MMIX constants) so the
5787    /// enumeration stress tests are fully reproducible — no time/thread seeding.
5788    struct Lcg(u64);
5789    impl Lcg {
5790        fn new(seed: u64) -> Self {
5791            Lcg(seed)
5792        }
5793        fn next_u64(&mut self) -> u64 {
5794            self.0 = self
5795                .0
5796                .wrapping_mul(6364136223846793005)
5797                .wrapping_add(1442695040888963407);
5798            self.0
5799        }
5800        fn unit(&mut self) -> f64 {
5801            (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
5802        }
5803        fn range(&mut self, lo: f64, hi: f64) -> f64 {
5804            lo + (hi - lo) * self.unit()
5805        }
5806    }
5807
5808    /// Synthetic eigen-cache with identity bases (the enumerator only reads
5809    /// `penalty_eigenvalues`, `penalty_rank`, `nullity` and the additive logdet
5810    /// constants), so tests can drive `evaluate_reml_parts` /
5811    /// `reml_deriv_enclosure` directly from a spectrum.
5812    fn synthetic_cache(eigs: &[f64]) -> GaussianRemlEigenCache {
5813        let n = eigs.len();
5814        let rank = eigs.iter().filter(|&&delta| delta > 0.0).count();
5815        GaussianRemlEigenCache {
5816            penalty_eigenvalues: Array1::from(eigs.to_vec()),
5817            eigenvectors: Array2::eye(n),
5818            coefficient_basis: Array2::eye(n),
5819            xtwx_fingerprint: 0,
5820            penalty_fingerprint: 0,
5821            logdet_xtwx: 0.0,
5822            logdet_penalty_positive: 0.0,
5823            penalty_rank: rank,
5824            nullity: n - rank,
5825        }
5826    }
5827
5828    /// One-mode profiled REML has an analytic stationary point. With
5829    /// `q = c²`, irreducible residual `r`, residual dof `n`, and `t = λδ`,
5830    ///
5831    /// `dp(t) = r + q t/(1+t)` and `V'(rho)=0`
5832    /// iff `t = r / ((n-1)q-r)`.
5833    ///
5834    /// This pins the objective actually implemented here (dispersion profiled
5835    /// at every rho), not the fixed-sigma surrogate proposed in #2312.
5836    #[test]
5837    fn profiled_one_mode_certificate_matches_analytic_root_and_ignores_seed_as_candidate() {
5838        let delta = 4.0;
5839        let q = 2.0;
5840        let irreducible_residual = 3.0;
5841        let n_effective = 10usize;
5842        let cache = synthetic_cache(&[delta]);
5843        let ywy = array![q + irreducible_residual];
5844        let projected = array![[q]];
5845        let eval = |rho: f64| {
5846            evaluate_reml_parts(&cache, ywy.view(), projected.view(), n_effective, 1, rho)
5847        };
5848        let enclose = |a: f64, b: f64| {
5849            reml_deriv_enclosure(&cache, ywy.view(), projected.view(), n_effective, 1, a, b)
5850        };
5851        let expected_t =
5852            irreducible_residual / (((n_effective - 1) as f64) * q - irreducible_residual);
5853        let expected_rho = (expected_t / delta).ln();
5854        let mut roots = Vec::new();
5855        let selection =
5856            enumerate_and_select_rho(&eval, &enclose, Some(-20.0), |root, _| roots.push(root))
5857                .expect("profile certificate");
5858
5859        assert_eq!(roots.len(), 1, "unexpected stationary set");
5860        assert!(
5861            roots[0].bracket[0] <= expected_rho && expected_rho <= roots[0].bracket[1],
5862            "analytic root {expected_rho} outside certified bracket {:?}",
5863            roots[0].bracket
5864        );
5865        assert!(
5866            (selection.rho - expected_rho).abs()
5867                <= RHO_BRACKET_RESOLUTION * (1.0 + expected_rho.abs()),
5868            "selected rho {} differs from analytic profiled root {expected_rho}",
5869            selection.rho
5870        );
5871        assert_ne!(
5872            selection.rho.to_bits(),
5873            (-20.0_f64).to_bits(),
5874            "a nonstationary warm hint must never enter the objective argmin"
5875        );
5876    }
5877
5878    #[test]
5879    fn unresolved_stationary_structure_is_a_typed_refusal() {
5880        let eval = |rho: f64| ObjectiveEval {
5881            cost: rho * rho,
5882            grad: 2.0 * rho,
5883            hess: 2.0,
5884            edf: 0.0,
5885        };
5886        // Deliberately uninformative but endpoint-valid enclosures force the
5887        // resolution-floor branch without an expensive production-depth tree.
5888        let enclose = |_a: f64, _b: f64| (Interval::entire(), Interval::entire());
5889        let error = enumerate_and_select_rho_with_controls(
5890            eval,
5891            enclose,
5892            None,
5893            ProfileSearchControls {
5894                lower: -1.0,
5895                upper: 1.0,
5896                resolution: 0.25,
5897                max_depth: 0,
5898            },
5899            |_root, _eval| {},
5900        )
5901        .expect_err("ambiguous stationary structure must refuse");
5902        assert!(matches!(error, EstimationError::RemlDidNotConverge { .. }));
5903    }
5904
5905    #[test]
5906    fn profiled_modal_evaluation_is_finite_beyond_exp_range() {
5907        let cache = synthetic_cache(&[4.0]);
5908        let ywy = array![5.0];
5909        let projected = array![[2.0]];
5910        for rho in [-1_000.0, 1_000.0] {
5911            let mode = modal_kernels(rho, 4.0);
5912            assert!(mode.log_one_plus_t.is_finite());
5913            assert!(mode.u.is_finite());
5914            assert!(mode.v.is_finite());
5915            assert!(mode.w.is_finite());
5916            assert!(mode.k.is_finite());
5917            let value = evaluate_reml_parts(&cache, ywy.view(), projected.view(), 10, 1, rho);
5918            assert!(value.cost.is_finite(), "non-finite cost at rho={rho}");
5919            assert!(value.grad.is_finite(), "non-finite gradient at rho={rho}");
5920            assert!(value.hess.is_finite(), "non-finite Hessian at rho={rho}");
5921        }
5922    }
5923
5924    /// The landscape API must expose the decided branch-and-bound topology on
5925    /// small deterministic designs (no RNG, scan, or secondary root oracle).
5926    /// The one-mode profiled design has an analytic single stationary point;
5927    /// the two-mode design exercises multi-mode enclosure assembly.
5928    #[test]
5929    fn landscape_certificate_classifies_small_designs() {
5930        // One-mode configs: each has exactly one interior stationary point.
5931        let one_mode: &[(f64, f64, f64)] = &[(4.0, 2.0, 3.0), (0.5, 1.5, 2.0), (9.0, 0.8, 1.2)];
5932        for &(delta, q, resid) in one_mode {
5933            let cache = synthetic_cache(&[delta]);
5934            let ywy = array![q + resid];
5935            let prs = array![[q]];
5936            let n_eff = 12usize;
5937            let cert = rho_landscape_certificate_from_parts(
5938                &cache,
5939                ywy.view(),
5940                prs.view(),
5941                n_eff,
5942                1,
5943                None,
5944            )
5945            .expect("one-mode certificate");
5946            assert_eq!(cert.stationary_count, 1);
5947            assert_eq!(cert.landscape, RhoLandscape::UniqueInterior);
5948            assert_eq!(cert.root_brackets.len(), cert.stationary_count);
5949        }
5950
5951        // Two-mode design (well separated δ) exercises m>1 enclosure assembly.
5952        let cache = synthetic_cache(&[0.5, 3.0]);
5953        let prs = array![[1.0], [0.4]];
5954        let ywy = array![1.0 + 0.4 + 1.5];
5955        let cert =
5956            rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 30, 1, None)
5957                .expect("two-mode certificate");
5958        assert_eq!(cert.root_brackets.len(), cert.stationary_count);
5959    }
5960
5961    /// Compactified `[0,∞]` solve: when the interior has NO stationary point the
5962    /// optimum is the `ρ→+∞` boundary. A design orthogonal to the penalized
5963    /// directions (`c²=0`) makes `V′<0` throughout, so the certificate reports a
5964    /// monotone landscape and the finite `ρ→+∞` limit cost undercuts the small-λ
5965    /// endpoint, while the `ρ→−∞` limit diverges.
5966    #[test]
5967    fn compactified_limit_cost_selects_boundary_when_no_interior_stationary_point() {
5968        let cache = synthetic_cache(&[1.0, 2.0]);
5969        let prs = array![[0.0], [0.0]];
5970        let ywy = array![1.0];
5971        let cert =
5972            rho_landscape_certificate_from_parts(&cache, ywy.view(), prs.view(), 20, 1, None)
5973                .expect("monotone certificate");
5974        assert_eq!(cert.stationary_count, 0);
5975        assert_eq!(cert.landscape, RhoLandscape::NoInteriorOptimum);
5976        assert!(cert.boundary_optimum);
5977        assert!(
5978            cert.limit_costs[0].is_infinite() && cert.limit_costs[0] > 0.0,
5979            "rho->-inf cost must diverge to +inf, got {}",
5980            cert.limit_costs[0]
5981        );
5982        assert!(
5983            cert.limit_costs[1].is_finite(),
5984            "rho->+inf limit cost must be finite, got {}",
5985            cert.limit_costs[1]
5986        );
5987        assert!(
5988            cert.limit_costs[1] < cert.window_costs[0],
5989            "large-λ boundary cost {} must undercut the small-λ endpoint {}",
5990            cert.limit_costs[1],
5991            cert.window_costs[0]
5992        );
5993    }
5994
5995    /// The selected representative must have cost no larger than every isolated
5996    /// stationary representative and both finite-window endpoints.
5997    #[test]
5998    fn selected_rho_beats_every_certified_profile_candidate() {
5999        let mut rng = Lcg::new(0x9911_7733_5522_0044);
6000        for _case in 0..40 {
6001            let n_eig = 2 + (rng.next_u64() % 4) as usize;
6002            let eigs: Vec<f64> = (0..n_eig).map(|_| rng.range(-5.0, 6.0).exp()).collect();
6003            let cache = synthetic_cache(&eigs);
6004            let c2: Vec<f64> = (0..n_eig)
6005                .map(|_| {
6006                    let v = rng.range(0.0, 2.5);
6007                    v * v
6008                })
6009                .collect();
6010            let sum_c2: f64 = c2.iter().sum();
6011            let prs = Array2::from_shape_vec((n_eig, 1), c2).unwrap();
6012            let ywy = Array1::from(vec![sum_c2 + rng.range(0.05, 2.0)]);
6013            let n_eff = 80usize;
6014            let n_out = 1usize;
6015
6016            let eval =
6017                |rho: f64| evaluate_reml_parts(&cache, ywy.view(), prs.view(), n_eff, n_out, rho);
6018            let enclose = |a: f64, b: f64| {
6019                reml_deriv_enclosure(&cache, ywy.view(), prs.view(), n_eff, n_out, a, b)
6020            };
6021            let mut roots = Vec::new();
6022            let selection =
6023                enumerate_and_select_rho(&eval, &enclose, None, |root, _| roots.push(root.rho))
6024                    .unwrap();
6025            let selected = selection.rho;
6026            let selected_cost = eval(selected).cost;
6027            let tol = 1.0e-8 * (1.0 + selected_cost.abs());
6028
6029            for &r in &roots {
6030                assert!(selected_cost <= eval(r).cost + tol);
6031            }
6032            assert!(selected_cost <= eval(RHO_LOWER).cost + tol);
6033            assert!(selected_cost <= eval(RHO_UPPER).cost + tol);
6034        }
6035    }
6036
6037    #[test]
6038    fn backward_from_fit_matches_backward_with_refit() {
6039        // The Task 3 state round-trip in pyffi calls `_from_fit`; that path
6040        // must be numerically identical to the refitting `_backward` entry
6041        // when fed the same forward result. This guards the optimization
6042        // against drift when either path is touched.
6043        let x = array![[1.0, -0.9], [1.0, -0.4], [1.0, 0.1], [1.0, 0.6], [1.0, 1.1],];
6044        let y = array![[0.2, -0.1], [0.4, 0.1], [0.7, 0.3], [1.0, 0.5], [1.5, 0.8]];
6045        let penalty = array![[0.0, 0.0], [0.0, 1.5]];
6046        let weights = array![1.05, 0.95, 1.01, 0.99, 1.03];
6047
6048        let refit = gaussian_reml_multi_closed_form_backward(
6049            x.view(),
6050            y.view(),
6051            penalty.view(),
6052            Some(weights.view()),
6053            Some(0.85),
6054            0.2,
6055            None,
6056            None,
6057            -0.1,
6058            0.0,
6059        )
6060        .expect("refit backward");
6061
6062        let fit = gaussian_reml_multi_closed_form_with_cache(
6063            x.view(),
6064            y.view(),
6065            penalty.view(),
6066            Some(weights.view()),
6067            Some(0.85),
6068            None,
6069        )
6070        .expect("forward fit");
6071        let from_fit = gaussian_reml_multi_closed_form_backward_from_fit(
6072            x.view(),
6073            y.view(),
6074            penalty.view(),
6075            Some(weights.view()),
6076            &fit,
6077            0.2,
6078            None,
6079            None,
6080            -0.1,
6081            0.0,
6082        )
6083        .expect("from_fit backward");
6084
6085        for (a, b) in refit.grad_x.iter().zip(from_fit.grad_x.iter()) {
6086            assert!((a - b).abs() <= 1.0e-12);
6087        }
6088        for (a, b) in refit.grad_y.iter().zip(from_fit.grad_y.iter()) {
6089            assert!((a - b).abs() <= 1.0e-12);
6090        }
6091        for (a, b) in refit.grad_weights.iter().zip(from_fit.grad_weights.iter()) {
6092            assert!((a - b).abs() <= 1.0e-12);
6093        }
6094    }
6095
6096    /// Regression: when `K = XᵀWX + λS` is effectively rank-deficient (e.g.
6097    /// `λ` has saturated very large), the backward must NOT error — it must
6098    /// degrade gracefully and return zero gradients of the correct shape.
6099    /// This is the production-training scenario where individual atoms can
6100    /// saturate `λ_k` in early batches; raising here would crash an entire
6101    /// step. We construct the degenerate state by running a real forward
6102    /// fit and then corrupting `reml_hess_rho` to 0 (the gate variable the
6103    /// backward checks). We assert: (a) no error, (b) all gradients finite,
6104    /// (c) shapes match the inputs.
6105    #[test]
6106    fn backward_degrades_gracefully_when_k_is_near_singular() {
6107        // Small, full-rank S with a moderately-conditioned X. The exact
6108        // numbers don't matter; what matters is that we then force the
6109        // ill-conditioned gate to fire.
6110        let x = array![
6111            [1.0, -1.0, 0.5],
6112            [1.0, -0.5, 0.2],
6113            [1.0, 0.0, -0.1],
6114            [1.0, 0.5, 0.3],
6115            [1.0, 1.0, 0.8],
6116            [1.0, 1.5, 1.1],
6117            [1.0, 2.0, 1.5],
6118            [1.0, 2.5, 2.0],
6119            [1.0, 3.0, 2.6],
6120            [1.0, 3.5, 3.1],
6121        ];
6122        let y = array![
6123            [0.1],
6124            [0.3],
6125            [0.4],
6126            [0.7],
6127            [1.0],
6128            [1.5],
6129            [2.0],
6130            [2.7],
6131            [3.3],
6132            [4.0]
6133        ];
6134        // Full-rank S to keep the forward well-posed.
6135        let penalty = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
6136
6137        let mut fit =
6138            gaussian_reml_multi_closed_form(x.view(), y.view(), penalty.view(), None, Some(0.0))
6139                .expect("forward fit must succeed for well-posed input");
6140        // Force the ill-conditioned gate to fire by zeroing the REML
6141        // Hessian w.r.t. rho — this is exactly what happens in production
6142        // when `λ` saturates to 1e10+ and `d²ℓ/dρ² → 0`.
6143        fit.reml_hess_rho = 0.0;
6144
6145        let result = gaussian_reml_multi_closed_form_backward_from_fit(
6146            x.view(),
6147            y.view(),
6148            penalty.view(),
6149            None,
6150            &fit,
6151            // Nonzero upstreams to force the backward to actually try to
6152            // populate gradients (rather than short-circuit on zero seeds).
6153            1.0,
6154            None,
6155            None,
6156            1.0,
6157            1.0,
6158        )
6159        .expect("backward must NOT error on near-singular K");
6160
6161        assert_eq!(result.grad_x.dim(), (x.nrows(), x.ncols()));
6162        assert_eq!(result.grad_y.dim(), (y.nrows(), y.ncols()));
6163        assert_eq!(result.grad_penalty.dim(), (x.ncols(), x.ncols()));
6164        assert_eq!(result.grad_weights.dim(), x.nrows());
6165        for v in result.grad_x.iter() {
6166            assert!(v.is_finite(), "grad_x must be finite, got {v}");
6167        }
6168        for v in result.grad_y.iter() {
6169            assert!(v.is_finite(), "grad_y must be finite, got {v}");
6170        }
6171        for v in result.grad_penalty.iter() {
6172            assert!(v.is_finite(), "grad_penalty must be finite, got {v}");
6173        }
6174        for v in result.grad_weights.iter() {
6175            assert!(v.is_finite(), "grad_weights must be finite, got {v}");
6176        }
6177    }
6178}
6179
6180/// Vector–Jacobian products of the multi-block per-smooth-λ Gaussian REML
6181/// forward fit ([`gaussian_reml_blocks_orthogonal_shared_scale`]), back to the
6182/// design blocks, penalty blocks, response, and weights.
6183pub struct GaussianRemlBlocksBackwardAnalytic {
6184    pub grad_designs: Vec<Array2<f64>>,
6185    pub grad_penalties: Vec<Array2<f64>>,
6186    pub grad_y: Array2<f64>,
6187    pub grad_weights: Array1<f64>,
6188}
6189
6190/// Analytic backward for the multi-block per-smooth-λ Gaussian REML forward.
6191///
6192/// Computes VJPs of (coefficients, fitted, lambdas, log_lambdas, reml_score,
6193/// edf) back to (design_blocks, penalty_blocks, y, weights). The VJP is
6194/// assembled at the converged log-λ vector: fixed-ρ β/fitted/profiled-REML/EDF
6195/// terms are accumulated first, then the smoothing-parameter sensitivity is
6196/// routed through the F×F profiled REML score Hessian from the implicit optimum.
6197/// Pairs with the forward [`gaussian_reml_blocks_orthogonal_shared_scale`].
6198pub fn gaussian_reml_fit_blocks_backward_analytic(
6199    designs: &[Array2<f64>],
6200    penalties_raw: &[Array2<f64>],
6201    y: ArrayView1<'_, f64>,
6202    weights: ArrayView1<'_, f64>,
6203    rhos: &[f64],
6204    grad_coefficients: Option<ArrayView2<'_, f64>>,
6205    grad_fitted: Option<ArrayView2<'_, f64>>,
6206    grad_lambdas: Option<ArrayView1<'_, f64>>,
6207    grad_log_lambdas: Option<ArrayView1<'_, f64>>,
6208    grad_reml_score: f64,
6209    grad_edf: Option<ArrayView1<'_, f64>>,
6210) -> Result<GaussianRemlBlocksBackwardAnalytic, EstimationError> {
6211    let n = y.len();
6212    let f_blocks = designs.len();
6213    let mut offsets = Vec::with_capacity(f_blocks + 1);
6214    offsets.push(0_usize);
6215    for design in designs {
6216        offsets.push(offsets.last().copied().unwrap() + design.ncols());
6217    }
6218    let p_total = *offsets.last().unwrap();
6219    if n == 0 || p_total == 0 {
6220        return Err(EstimationError::InvalidInput(
6221            "gaussian_reml_fit_blocks_backward requires non-empty rows and at least one coefficient column"
6222                .to_string(),
6223        ));
6224    }
6225
6226    if rhos.len() != f_blocks {
6227        return Err(EstimationError::InvalidInput(format!(
6228            "log_lambdas length mismatch: expected {f_blocks}, got {}",
6229            rhos.len()
6230        )));
6231    }
6232    if let Some(gc) = grad_coefficients {
6233        if gc.dim() != (p_total, 1) {
6234            return Err(EstimationError::InvalidInput(format!(
6235                "grad_coefficients shape mismatch: expected {}x1, got {}x{}",
6236                p_total,
6237                gc.nrows(),
6238                gc.ncols()
6239            )));
6240        }
6241    }
6242    if let Some(gf) = grad_fitted {
6243        if gf.dim() != (n, 1) {
6244            return Err(EstimationError::InvalidInput(format!(
6245                "grad_fitted shape mismatch: expected {}x1, got {}x{}",
6246                n,
6247                gf.nrows(),
6248                gf.ncols()
6249            )));
6250        }
6251    }
6252    if !grad_reml_score.is_finite() {
6253        return Err(EstimationError::InvalidInput(format!(
6254            "grad_reml_score must be finite; got {grad_reml_score}"
6255        )));
6256    }
6257    if let Some(vec) = grad_lambdas {
6258        if vec.len() != f_blocks {
6259            return Err(EstimationError::InvalidInput(format!(
6260                "grad_lambdas length mismatch: expected {f_blocks}, got {}",
6261                vec.len()
6262            )));
6263        }
6264    }
6265    if let Some(vec) = grad_log_lambdas {
6266        if vec.len() != f_blocks {
6267            return Err(EstimationError::InvalidInput(format!(
6268                "grad_log_lambdas length mismatch: expected {f_blocks}, got {}",
6269                vec.len()
6270            )));
6271        }
6272    }
6273    if let Some(vec) = grad_edf {
6274        if vec.len() != f_blocks {
6275            return Err(EstimationError::InvalidInput(format!(
6276                "grad_edf length mismatch: expected {f_blocks}, got {}",
6277                vec.len()
6278            )));
6279        }
6280    }
6281    if let Some(gc) = grad_coefficients {
6282        if let Some(((row, col), value)) = gc.indexed_iter().find(|(_, value)| !value.is_finite()) {
6283            return Err(EstimationError::InvalidInput(format!(
6284                "grad_coefficients[{row},{col}] must be finite; got {value}"
6285            )));
6286        }
6287    }
6288    if let Some(gf) = grad_fitted {
6289        if let Some(((row, col), value)) = gf.indexed_iter().find(|(_, value)| !value.is_finite()) {
6290            return Err(EstimationError::InvalidInput(format!(
6291                "grad_fitted[{row},{col}] must be finite; got {value}"
6292            )));
6293        }
6294    }
6295    if let Some(vec) = grad_lambdas {
6296        if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6297            return Err(EstimationError::InvalidInput(format!(
6298                "grad_lambdas[{block}] must be finite; got {value}"
6299            )));
6300        }
6301    }
6302    if let Some(vec) = grad_log_lambdas {
6303        if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6304            return Err(EstimationError::InvalidInput(format!(
6305                "grad_log_lambdas[{block}] must be finite; got {value}"
6306            )));
6307        }
6308    }
6309    if let Some(vec) = grad_edf {
6310        if let Some((block, value)) = vec.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6311            return Err(EstimationError::InvalidInput(format!(
6312                "grad_edf[{block}] must be finite; got {value}"
6313            )));
6314        }
6315    }
6316    for (block, design) in designs.iter().enumerate() {
6317        if let Some(((row, col), value)) =
6318            design.indexed_iter().find(|(_, value)| !value.is_finite())
6319        {
6320            return Err(EstimationError::InvalidInput(format!(
6321                "designs[{block}][{row},{col}] must be finite; got {value}"
6322            )));
6323        }
6324    }
6325    for (block, penalty) in penalties_raw.iter().enumerate() {
6326        if let Some(((row, col), value)) =
6327            penalty.indexed_iter().find(|(_, value)| !value.is_finite())
6328        {
6329            return Err(EstimationError::InvalidInput(format!(
6330                "penalties[{block}][{row},{col}] must be finite; got {value}"
6331            )));
6332        }
6333    }
6334    if let Some((row, value)) = y.iter().enumerate().find(|(_, value)| !value.is_finite()) {
6335        return Err(EstimationError::InvalidInput(format!(
6336            "y[{row}] must be finite; got {value}"
6337        )));
6338    }
6339    if let Some((row, value)) = weights
6340        .iter()
6341        .enumerate()
6342        .find(|(_, value)| !value.is_finite() || **value < 0.0)
6343    {
6344        return Err(EstimationError::InvalidInput(format!(
6345            "weights[{row}] must be finite and non-negative; got {value}"
6346        )));
6347    }
6348
6349    let mut z = Array2::<f64>::zeros((n, p_total));
6350    for k in 0..f_blocks {
6351        z.slice_mut(s![.., offsets[k]..offsets[k + 1]])
6352            .assign(&designs[k]);
6353    }
6354
6355    let penalties: Vec<Array2<f64>> = penalties_raw
6356        .iter()
6357        .map(|p| {
6358            let mut out = p.clone();
6359            gam_linalg::matrix::symmetrize_in_place(&mut out);
6360            out
6361        })
6362        .collect();
6363    let mut ranks = Vec::with_capacity(f_blocks);
6364    let mut pinvs = Vec::with_capacity(f_blocks);
6365    for penalty in &penalties {
6366        let geometry = gam_linalg::utils::rank_certified_psd_pseudoinverse(penalty, 1.0e-10)?;
6367        ranks.push(geometry.rank());
6368        pinvs.push(geometry.into_pseudoinverse());
6369    }
6370
6371    let lambdas = Array1::from_iter(rhos.iter().map(|rho| rho.exp()));
6372    if let Some((block, lambda)) = lambdas
6373        .iter()
6374        .enumerate()
6375        .find(|(_, lambda)| !lambda.is_finite() || **lambda <= 0.0)
6376    {
6377        return Err(EstimationError::InvalidInput(format!(
6378            "exp(log_lambdas[{block}]) must be finite and positive; got {lambda}"
6379        )));
6380    }
6381    let mut k_matrix = fast_xt_diag_x(&z.view(), &weights);
6382    for block in 0..f_blocks {
6383        let lambda = lambdas[block];
6384        for local_i in 0..penalties[block].nrows() {
6385            let global_i = offsets[block] + local_i;
6386            for local_j in 0..penalties[block].ncols() {
6387                let global_j = offsets[block] + local_j;
6388                k_matrix[[global_i, global_j]] += lambda * penalties[block][[local_i, local_j]];
6389            }
6390        }
6391    }
6392    let r = gam_linalg::utils::certified_spd_inverse(
6393        &k_matrix,
6394        "block Gaussian REML penalized normal matrix",
6395    )
6396    .map(gam_linalg::utils::CertifiedSpdInverse::into_inverse)
6397    .map_err(|error| {
6398        EstimationError::InvalidInput(format!(
6399            "block Gaussian REML requires an exact SPD penalized normal matrix: {error}"
6400        ))
6401    })?;
6402
6403    let mut xtwy = Array1::<f64>::zeros(p_total);
6404    for row in 0..n {
6405        let wy = weights[row] * y[row];
6406        for col in 0..p_total {
6407            xtwy[col] += z[[row, col]] * wy;
6408        }
6409    }
6410    let beta = r.dot(&xtwy);
6411    let fitted = z.dot(&beta);
6412    if let Some((col, value)) = beta
6413        .iter()
6414        .enumerate()
6415        .find(|(_, value)| !value.is_finite())
6416    {
6417        return Err(EstimationError::InvalidInput(format!(
6418            "solved coefficient {col} is non-finite: {value}"
6419        )));
6420    }
6421    let residual = &y.to_owned() - &fitted;
6422    let weighted_residual = &residual * &weights.to_owned();
6423    let ywy = y
6424        .iter()
6425        .zip(weights.iter())
6426        .map(|(&yi, &wi)| wi * yi * yi)
6427        .sum::<f64>();
6428    let q_raw = ywy - xtwy.dot(&beta);
6429    if !q_raw.is_finite() {
6430        return Err(EstimationError::InvalidInput(format!(
6431            "Gaussian REML residual quadratic form must be finite; got {q_raw}"
6432        )));
6433    }
6434    let q = q_raw.max(1.0e-300);
6435    let nullity = penalties
6436        .iter()
6437        .zip(ranks.iter())
6438        .map(|(penalty, rank)| penalty.nrows().saturating_sub(*rank))
6439        .sum::<usize>();
6440    // Match the block-orthogonal forward's effective sample size: zero
6441    // prior-weight rows are excluded from the residual degrees of freedom.
6442    let nu = effective_observation_count(weights) as f64 - nullity as f64;
6443    if !(nu.is_finite() && nu > 0.0) {
6444        return Err(EstimationError::InvalidInput(format!(
6445            "Gaussian REML residual degrees of freedom must be positive; got {nu}"
6446        )));
6447    }
6448    let tau = nu / q;
6449    let tau_q = -nu / (q * q);
6450    if !(tau.is_finite() && tau_q.is_finite()) {
6451        return Err(EstimationError::InvalidInput(format!(
6452            "Gaussian REML scale derivatives are non-finite: tau={tau}, tau_q={tau_q}"
6453        )));
6454    }
6455
6456    let mut grad_z = Array2::<f64>::zeros((n, p_total));
6457    let mut g_kernel = Array2::<f64>::zeros((p_total, p_total));
6458    let mut h_kernel = Array1::<f64>::zeros(p_total);
6459    let mut q_kernel = 0.0_f64;
6460    let mut j_blocks: Vec<Array2<f64>> = penalties
6461        .iter()
6462        .map(|p| Array2::<f64>::zeros(p.dim()))
6463        .collect();
6464
6465    let mut beta_tilde = Array1::<f64>::zeros(p_total);
6466    if let Some(gc) = grad_coefficients {
6467        beta_tilde += &gc.column(0).to_owned();
6468    }
6469    if let Some(gf) = grad_fitted {
6470        let gf_col = gf.column(0).to_owned();
6471        beta_tilde += &z.t().dot(&gf_col);
6472        for row in 0..n {
6473            for col in 0..p_total {
6474                grad_z[[row, col]] += gf_col[row] * beta[col];
6475            }
6476        }
6477    }
6478
6479    // Generic downstream losses that explicitly seed beta_hat or fitted
6480    // values cannot use the REML envelope shortcut. Route those seeds through
6481    // the fixed-rho KKT adjoint K u = beta_tilde before differentiating
6482    // designs, penalties, y, weights, and rho.
6483    let u = r.dot(&beta_tilde);
6484    h_kernel += &u;
6485    for i in 0..p_total {
6486        for j in 0..p_total {
6487            g_kernel[[i, j]] -= 0.5 * (beta[i] * u[j] + u[i] * beta[j]);
6488        }
6489    }
6490
6491    let mut alpha = Array1::<f64>::zeros(f_blocks);
6492    if let Some(gl) = grad_lambdas {
6493        for block in 0..f_blocks {
6494            alpha[block] += gl[block] * lambdas[block];
6495        }
6496    }
6497    if let Some(grho) = grad_log_lambdas {
6498        alpha += &grho.to_owned();
6499    }
6500
6501    let mut p_betas = Vec::with_capacity(f_blocks);
6502    let mut m_vectors = Vec::with_capacity(f_blocks);
6503    let mut rp_matrices = Vec::with_capacity(f_blocks);
6504    let mut rpr_matrices = Vec::with_capacity(f_blocks);
6505    let mut b_values = Array1::<f64>::zeros(f_blocks);
6506    let mut t_values = Array1::<f64>::zeros(f_blocks);
6507
6508    for block in 0..f_blocks {
6509        let start = offsets[block];
6510        let end = offsets[block + 1];
6511        let beta_k = beta.slice(s![start..end]).to_owned();
6512        let s_beta = penalties[block].dot(&beta_k);
6513        let lambda = lambdas[block];
6514        let lambda_s_beta = s_beta.mapv(|value| lambda * value);
6515        let mut p_beta = Array1::<f64>::zeros(p_total);
6516        for local_i in 0..(end - start) {
6517            p_beta[start + local_i] = lambda_s_beta[local_i];
6518        }
6519        let weighted_penalty = penalties[block].mapv(|value| lambda * value);
6520        let rp_block = r.slice(s![.., start..end]).dot(&weighted_penalty);
6521        let mut rp = Array2::<f64>::zeros((p_total, p_total));
6522        rp.slice_mut(s![.., start..end]).assign(&rp_block);
6523        let rpr = rp_block.dot(&r.slice(s![start..end, ..]));
6524        let m = r.slice(s![.., start..end]).dot(&lambda_s_beta);
6525        b_values[block] = beta.dot(&p_beta);
6526        t_values[block] = (0..(end - start))
6527            .map(|local_i| rp_block[[start + local_i, local_i]])
6528            .sum::<f64>();
6529        alpha[block] -= u.dot(&p_beta);
6530        p_betas.push(p_beta);
6531        m_vectors.push(m);
6532        rp_matrices.push(rp);
6533        rpr_matrices.push(rpr);
6534    }
6535
6536    if grad_reml_score != 0.0 {
6537        q_kernel += 0.5 * grad_reml_score * tau;
6538        g_kernel += &(r.clone() * (0.5 * grad_reml_score));
6539        for block in 0..f_blocks {
6540            j_blocks[block] -= &(pinvs[block].clone() * (0.5 * grad_reml_score / lambdas[block]));
6541        }
6542    }
6543
6544    let mut trace_pairs = Array2::<f64>::zeros((f_blocks, f_blocks));
6545    for i in 0..f_blocks {
6546        for j in 0..f_blocks {
6547            trace_pairs[[i, j]] =
6548                gam_linalg::utils::trace_of_product(rp_matrices[i].view(), rp_matrices[j].view());
6549        }
6550    }
6551
6552    if let Some(ge) = grad_edf {
6553        for edf_block in 0..f_blocks {
6554            let scale = ge[edf_block];
6555            if scale == 0.0 {
6556                continue;
6557            }
6558            let start = offsets[edf_block];
6559            let end = offsets[edf_block + 1];
6560            g_kernel += &(rpr_matrices[edf_block].clone() * scale);
6561            j_blocks[edf_block] -= &(r.slice(s![start..end, start..end]).to_owned() * scale);
6562            for rho_block in 0..f_blocks {
6563                alpha[rho_block] += scale * trace_pairs[[edf_block, rho_block]];
6564                if rho_block == edf_block {
6565                    alpha[rho_block] -= scale * t_values[edf_block];
6566                }
6567            }
6568        }
6569    }
6570
6571    if let Some((block, value)) = alpha
6572        .iter()
6573        .enumerate()
6574        .find(|(_, value)| !value.is_finite())
6575    {
6576        return Err(EstimationError::InvalidInput(format!(
6577            "rho adjoint seed for block {block} is non-finite: {value}"
6578        )));
6579    }
6580
6581    if alpha.iter().any(|value| *value != 0.0) {
6582        let mut outer_h = Array2::<f64>::zeros((f_blocks, f_blocks));
6583        for k in 0..f_blocks {
6584            for j in 0..f_blocks {
6585                let beta_pk_r_pj_beta = p_betas[k].dot(&m_vectors[j]);
6586                outer_h[[k, j]] = 0.5 * trace_pairs[[k, j]] + tau * beta_pk_r_pj_beta
6587                    - if k == j {
6588                        0.5 * (t_values[k] + tau * b_values[k])
6589                    } else {
6590                        0.0
6591                    }
6592                    - 0.5 * tau_q * b_values[k] * b_values[j];
6593            }
6594        }
6595        // `outer_h` is the Jacobian of the negative profiled REML estimating
6596        // equation. Preserve every signed curvature direction exactly; a
6597        // singular Jacobian means this VJP is not identified and must fail,
6598        // rather than silently replacing its spectrum with a floored one.
6599        gam_linalg::matrix::symmetrize_in_place(&mut outer_h);
6600        if let Some(((row, col), value)) =
6601            outer_h.indexed_iter().find(|(_, value)| !value.is_finite())
6602        {
6603            return Err(EstimationError::InvalidInput(format!(
6604                "outer rho curvature entry ({row},{col}) is non-finite: {value}"
6605            )));
6606        }
6607        let rho_adj = gam_linalg::utils::certified_symmetric_solve(
6608            &outer_h,
6609            &alpha,
6610            "block Gaussian REML outer-rho adjoint",
6611        )
6612        .map(gam_linalg::utils::CertifiedSymmetricSolution::into_solution)
6613        .map_err(|error| {
6614            EstimationError::InvalidInput(format!(
6615                "block Gaussian REML outer-rho adjoint is not exactly solvable: {error}"
6616            ))
6617        })?;
6618        if let Some((block, value)) = rho_adj
6619            .iter()
6620            .enumerate()
6621            .find(|(_, value)| !value.is_finite())
6622        {
6623            return Err(EstimationError::InvalidInput(format!(
6624                "outer rho adjoint for block {block} is non-finite: {value}"
6625            )));
6626        }
6627        let weighted_b_sum = rho_adj
6628            .iter()
6629            .zip(b_values.iter())
6630            .map(|(&zk, &bk)| zk * bk)
6631            .sum::<f64>();
6632        q_kernel += 0.5 * tau_q * weighted_b_sum;
6633        for block in 0..f_blocks {
6634            let zk = rho_adj[block];
6635            if zk == 0.0 {
6636                continue;
6637            }
6638            g_kernel -= &(rpr_matrices[block].clone() * (0.5 * zk));
6639            let m = &m_vectors[block];
6640            for i in 0..p_total {
6641                h_kernel[i] += tau * zk * m[i];
6642                for j in 0..p_total {
6643                    g_kernel[[i, j]] -= 0.5 * tau * zk * (beta[i] * m[j] + m[i] * beta[j]);
6644                }
6645            }
6646            let start = offsets[block];
6647            let end = offsets[block + 1];
6648            j_blocks[block] += &(r.slice(s![start..end, start..end]).to_owned() * (0.5 * zk));
6649            for i in 0..(end - start) {
6650                for j in 0..(end - start) {
6651                    j_blocks[block][[i, j]] += 0.5 * tau * zk * beta[start + i] * beta[start + j];
6652                }
6653            }
6654        }
6655    }
6656
6657    for row in 0..n {
6658        for col in 0..p_total {
6659            grad_z[[row, col]] += -2.0 * q_kernel * weighted_residual[row] * beta[col];
6660        }
6661    }
6662    let zg = z.dot(&g_kernel);
6663    for row in 0..n {
6664        for col in 0..p_total {
6665            grad_z[[row, col]] += 2.0 * weights[row] * zg[[row, col]];
6666        }
6667    }
6668    let wy = y.to_owned() * &weights.to_owned();
6669    for row in 0..n {
6670        for col in 0..p_total {
6671            grad_z[[row, col]] += wy[row] * h_kernel[col];
6672        }
6673    }
6674
6675    let mut grad_y = Array2::<f64>::zeros((n, 1));
6676    let zh = z.dot(&h_kernel);
6677    for row in 0..n {
6678        grad_y[[row, 0]] = 2.0 * q_kernel * weighted_residual[row] + weights[row] * zh[row];
6679    }
6680
6681    let mut grad_weights = Array1::<f64>::zeros(n);
6682    for row in 0..n {
6683        let diag_zgz = (0..p_total)
6684            .map(|col| z[[row, col]] * zg[[row, col]])
6685            .sum::<f64>();
6686        grad_weights[row] = q_kernel * residual[row] * residual[row] + diag_zgz + y[row] * zh[row];
6687    }
6688
6689    // Weight-scale invariance of the REML score (issue #877). The Gaussian REML
6690    // criterion the score adjoint targets — the profiled cost assembled in
6691    // `reml_outer_engine::objective` — carries the data-density normalization
6692    // `−½ Σ_{wᵢ>0} log(wᵢ)` (the `|W|^{½}` factor of the weighted normal
6693    // likelihood) together with the geometric-mean weight anchor on ρ. Their
6694    // net effect is that the *score* depends on the observation weights only up
6695    // to a global scale: replacing `w → c·w` leaves it unchanged. By Euler's
6696    // homogeneity identity that invariance is exactly
6697    //   Σ_i wᵢ · ∂(score)/∂wᵢ = 0,
6698    // i.e. the score's weight-gradient is orthogonal to the scaling direction
6699    // `1/wᵢ`. The kernel propagation above produces the *raw* (un-normalized)
6700    // weight partials `aᵢ`, which do not satisfy this constraint; the missing
6701    // piece is the projection that removes the scaling component. Subtract the
6702    // multiple of `1/wᵢ` that restores `Σ_i wᵢ·gradᵢ = 0`:
6703    //   gradᵢ ← aᵢ − μ/wᵢ,  μ = (Σ_{j:wⱼ>0} wⱼ aⱼ) / n₊.
6704    // Only the score seed is scale-invariant — β̂, fitted = Zβ̂ and the EDF all
6705    // scale with the weights (the λS term in K = ZᵀWZ + λS does not), so their
6706    // adjoints must NOT be projected. We therefore form the score-only partials
6707    // `aᵢˢ = ½·grs·(τ·rᵢ² + zᵢᵀ R zᵢ)` from the score's own kernel
6708    // contributions (q_kernel ← ½·grs·τ, g_kernel ← ½·grs·R) and project just
6709    // those, leaving the coefficient/fitted/EDF/λ weight-gradients intact.
6710    if grad_reml_score != 0.0 {
6711        let q_kernel_score = 0.5 * grad_reml_score * tau;
6712        let zr = z.dot(&r);
6713        let n_pos = (0..n).filter(|&i| weights[i] > 0.0).count();
6714        if n_pos > 0 {
6715            let mut weighted_score_partial_sum = 0.0_f64;
6716            for row in 0..n {
6717                if weights[row] <= 0.0 {
6718                    continue;
6719                }
6720                let z_r_z = (0..p_total)
6721                    .map(|col| z[[row, col]] * zr[[row, col]])
6722                    .sum::<f64>();
6723                let a_score =
6724                    q_kernel_score * residual[row] * residual[row] + 0.5 * grad_reml_score * z_r_z;
6725                weighted_score_partial_sum += weights[row] * a_score;
6726            }
6727            let projection = weighted_score_partial_sum / n_pos as f64;
6728            for row in 0..n {
6729                if weights[row] > 0.0 {
6730                    grad_weights[row] -= projection / weights[row];
6731                }
6732            }
6733        }
6734    }
6735
6736    let mut grad_penalties = Vec::with_capacity(f_blocks);
6737    for block in 0..f_blocks {
6738        let start = offsets[block];
6739        let end = offsets[block + 1];
6740        let mut local = g_kernel.slice(s![start..end, start..end]).to_owned();
6741        for i in 0..(end - start) {
6742            for j in 0..(end - start) {
6743                local[[i, j]] += q_kernel * beta[start + i] * beta[start + j];
6744            }
6745        }
6746        local += &j_blocks[block];
6747        local *= lambdas[block];
6748        gam_linalg::matrix::symmetrize_in_place(&mut local);
6749        grad_penalties.push(local);
6750    }
6751
6752    let mut grad_designs = Vec::with_capacity(f_blocks);
6753    for block in 0..f_blocks {
6754        grad_designs.push(
6755            grad_z
6756                .slice(s![.., offsets[block]..offsets[block + 1]])
6757                .to_owned(),
6758        );
6759    }
6760
6761    Ok(GaussianRemlBlocksBackwardAnalytic {
6762        grad_designs,
6763        grad_penalties,
6764        grad_y,
6765        grad_weights,
6766    })
6767}
6768
6769/// Fixed-λ multi-output Gaussian fit under a per-row dense Fisher–Rao precision
6770/// metric: coefficients, fitted values, per-output residual scale, and the
6771/// penalized Fisher-weighted objective.
6772pub struct DenseFisherGaussianFit {
6773    pub coefficients: Array2<f64>,
6774    pub fitted: Array2<f64>,
6775    pub sigma2: Array1<f64>,
6776    pub objective: f64,
6777}
6778
6779/// Add a block-diagonal `λ·S` penalty (one `S` block per output) into a stacked
6780/// `(k·n_outputs)` Hessian in place, symmetrizing `S`.
6781pub fn add_block_diagonal_penalty(
6782    hessian: &mut Array2<f64>,
6783    penalty: ArrayView2<'_, f64>,
6784    lambda: f64,
6785    n_outputs: usize,
6786) -> Result<(), EstimationError> {
6787    let k = penalty.ncols();
6788    if penalty.nrows() != k {
6789        return Err(EstimationError::InvalidInput(format!(
6790            "penalty must be square for dense Fisher fit; got {}x{}",
6791            penalty.nrows(),
6792            penalty.ncols()
6793        )));
6794    }
6795    if hessian.dim() != (k * n_outputs, k * n_outputs) {
6796        return Err(EstimationError::InvalidInput(
6797            "dense Fisher Hessian shape mismatch while adding penalty".to_string(),
6798        ));
6799    }
6800    for output in 0..n_outputs {
6801        let offset = output * k;
6802        for row in 0..k {
6803            for col in 0..k {
6804                let s_sym = 0.5 * (penalty[[row, col]] + penalty[[col, row]]);
6805                hessian[[offset + row, offset + col]] += lambda * s_sym;
6806            }
6807        }
6808    }
6809    Ok(())
6810}
6811
6812/// Closed-form fixed-λ multi-output Gaussian fit with a per-row dense Fisher–Rao
6813/// precision metric. Assembles the block `XᵀWX` (+ block-diagonal `λS`) and
6814/// `XᵀWY` via the dense Fisher block kernels, solves, then forms fitted values,
6815/// per-output residual scale `sigma2`, and the penalized Fisher-weighted
6816/// objective seeded by `latent_prior_score`. `row_weights` are the (already
6817/// resolved) per-observation likelihood weights.
6818pub fn dense_fisher_gaussian_fit(
6819    design: ArrayView2<'_, f64>,
6820    y: ArrayView2<'_, f64>,
6821    penalty: ArrayView2<'_, f64>,
6822    row_weights: ArrayView1<'_, f64>,
6823    fisher_w: ArrayView3<'_, f64>,
6824    lambda: f64,
6825    latent_prior_score: f64,
6826) -> Result<DenseFisherGaussianFit, EstimationError> {
6827    let n_obs = design.nrows();
6828    let k = design.ncols();
6829    let n_outputs = y.ncols();
6830    let mut hessian = crate::pirls::dense_block_xtwx(design, fisher_w, Some(row_weights))?;
6831    add_block_diagonal_penalty(&mut hessian, penalty, lambda, n_outputs)?;
6832    let rhs = crate::pirls::dense_block_xtwy(design, fisher_w, y, Some(row_weights))?;
6833    let beta_vec =
6834        gam_linalg::utils::solve_dense_block_system(&hessian, &rhs, "dense Fisher Gaussian")
6835            .map_err(EstimationError::InvalidInput)?;
6836    let mut coefficients = Array2::<f64>::zeros((k, n_outputs));
6837    for output in 0..n_outputs {
6838        for col in 0..k {
6839            coefficients[[col, output]] = beta_vec[output * k + col];
6840        }
6841    }
6842    let fitted = design.dot(&coefficients);
6843    let mut sigma2 = Array1::<f64>::zeros(n_outputs);
6844    let mut objective = latent_prior_score;
6845    for row in 0..n_obs {
6846        for a in 0..n_outputs {
6847            let ra = y[[row, a]] - fitted[[row, a]];
6848            sigma2[a] += row_weights[row] * ra * ra;
6849            for b in 0..n_outputs {
6850                objective += 0.5
6851                    * row_weights[row]
6852                    * ra
6853                    * fisher_w[[row, a, b]]
6854                    * (y[[row, b]] - fitted[[row, b]]);
6855            }
6856        }
6857    }
6858    for output in 0..n_outputs {
6859        sigma2[output] /= (n_obs.saturating_sub(k).max(1)) as f64;
6860        let beta_col = coefficients.column(output);
6861        let s_beta = penalty.dot(&beta_col);
6862        objective += 0.5 * lambda * beta_col.dot(&s_beta);
6863    }
6864    Ok(DenseFisherGaussianFit {
6865        coefficients,
6866        fitted,
6867        sigma2,
6868        objective,
6869    })
6870}