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