Skip to main content

gam_terms/
construction.rs

1use crate::EstimationError;
2use crate::basis::analyze_penalty_block;
3use crate::smooth::PenaltyStructureHint;
4use faer::linalg::matmul::matmul;
5use faer::{Accum, Mat, MatRef, Par, Side};
6use gam_linalg::faer_ndarray::{FaerLinalgError, FaerSvd};
7use gam_linalg::matrix::symmetrize_in_place;
8use gam_linalg::utils::KahanSum;
9use ndarray::{Array1, Array2, ArrayView1, ArrayViewMut2, Axis, s};
10use rayon::iter::{
11    IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, ParallelIterator,
12};
13use std::collections::{BTreeMap, HashSet};
14use std::ops::Range;
15use std::sync::Arc;
16
17/// Relative "numerically-PSD" floor of the symmetric eigensolver: an eigenvalue
18/// smaller than this fraction of the spectrum scale is roundoff, not genuine
19/// curvature (~`sqrt(machine ε)`; #1619). This single floor governs BOTH which
20/// eigenvalues are snapped to zero (declared null) in `classify_eigenvalues_strict`
21/// AND how much per-mode relative energy the transformed penalty root may retain
22/// in the resulting null block (`subspace_split_is_consistent`). Keeping the two
23/// tied to one constant makes the null-space definition and the leakage-consistency
24/// guard provably mutually consistent, rather than the guard demanding a precision
25/// the classifier never promised.
26const REL_PSD_FLOOR: f64 = 1.0e-8;
27
28#[derive(Clone)]
29pub enum PenaltyRepresentation {
30    Dense(Array2<f64>),
31    Banded {
32        bands: Vec<Array1<f64>>,
33        offsets: Vec<i32>,
34    },
35    Kronecker {
36        /// Full penalty-block Kronecker product `left ⊗ right`: each entry of
37        /// `left` scales an entire copy of `right` in the dense expansion.
38        ///
39        /// This is distinct from chunked kernel design assembly, where center
40        /// rows are kernel-evaluation arguments rather than matrix factors.
41        left: Array2<f64>,
42        right: Array2<f64>,
43    },
44}
45
46impl PenaltyRepresentation {
47    /// Side length of the square penalty block this representation expands to.
48    pub fn block_dimension(&self) -> usize {
49        match self {
50            PenaltyRepresentation::Dense(matrix) => matrix.nrows(),
51            PenaltyRepresentation::Banded { bands, offsets } => {
52                let mut dim = 0usize;
53                for (band, &offset) in bands.iter().zip(offsets.iter()) {
54                    let len = band.len();
55                    let extent = if offset >= 0 {
56                        len + offset as usize
57                    } else {
58                        len + (-offset) as usize
59                    };
60                    dim = dim.max(extent);
61                }
62                dim
63            }
64            PenaltyRepresentation::Kronecker { left, right } => left.nrows() * right.nrows(),
65        }
66    }
67
68    /// Materialize this representation (Dense / Banded / Kronecker) into a
69    /// single dense symmetric penalty block.
70    pub fn to_block_dense(&self) -> Array2<f64> {
71        match self {
72            PenaltyRepresentation::Dense(matrix) => matrix.clone(),
73            PenaltyRepresentation::Banded { bands, offsets } => {
74                let dim = self.block_dimension();
75                let mut dense = Array2::zeros((dim, dim));
76                let positive_offsets: HashSet<usize> = offsets
77                    .iter()
78                    .filter_map(|&off| (off >= 0).then_some(off as usize))
79                    .collect();
80                for (band, &offset) in bands.iter().zip(offsets.iter()) {
81                    let off = offset.unsigned_abs() as usize;
82                    if offset < 0 && positive_offsets.contains(&off) {
83                        continue;
84                    }
85                    for (idx, &value) in band.iter().enumerate() {
86                        let (i, j) = if offset >= 0 {
87                            (idx, idx + off)
88                        } else {
89                            (idx + off, idx)
90                        };
91                        if i >= dim || j >= dim {
92                            continue;
93                        }
94                        dense[[i, j]] = value;
95                        dense[[j, i]] = value;
96                    }
97                }
98                dense
99            }
100            PenaltyRepresentation::Kronecker { left, right } => {
101                let (lrows, l_cols) = left.dim();
102                let (rrows, r_cols) = right.dim();
103                let mut result = Array2::zeros((lrows * rrows, l_cols * r_cols));
104                for i in 0..lrows {
105                    for j in 0..l_cols {
106                        let scale = left[(i, j)];
107                        if scale == 0.0 {
108                            continue;
109                        }
110                        let mut block = result.slice_mut(s![
111                            i * rrows..(i + 1) * rrows,
112                            j * r_cols..(j + 1) * r_cols
113                        ]);
114                        block.assign(&(right * scale));
115                    }
116                }
117                result
118            }
119        }
120    }
121}
122
123#[derive(Clone)]
124pub struct PenaltyMatrix {
125    pub col_range: Range<usize>,
126    pub representation: PenaltyRepresentation,
127}
128
129impl PenaltyMatrix {
130    fn accumulate_into(&self, mut dest: ArrayViewMut2<'_, f64>, weight: f64) {
131        if weight == 0.0 {
132            return;
133        }
134        match &self.representation {
135            PenaltyRepresentation::Dense(block) => {
136                dest.scaled_add(weight, block);
137            }
138            PenaltyRepresentation::Banded { bands, offsets } => {
139                let positive_offsets: HashSet<usize> = offsets
140                    .iter()
141                    .filter_map(|&off| (off >= 0).then_some(off as usize))
142                    .collect();
143                for (band, &offset) in bands.iter().zip(offsets.iter()) {
144                    let off = offset.unsigned_abs() as usize;
145                    if offset < 0 && positive_offsets.contains(&off) {
146                        continue;
147                    }
148                    for (idx, &value) in band.iter().enumerate() {
149                        let (i, j) = if offset >= 0 {
150                            (idx, idx + off)
151                        } else {
152                            (idx + off, idx)
153                        };
154                        let Some(entry_ij) = dest.get_mut((i, j)) else {
155                            continue;
156                        };
157                        *entry_ij += weight * value;
158                        if i != j
159                            && let Some(entry_ji) = dest.get_mut((j, i))
160                        {
161                            *entry_ji += weight * value;
162                        }
163                    }
164                }
165            }
166            PenaltyRepresentation::Kronecker { left, right } => {
167                let (lrows, l_cols) = left.dim();
168                let (rrows, r_cols) = right.dim();
169                for i in 0..lrows {
170                    for j in 0..l_cols {
171                        let scale = left[(i, j)] * weight;
172                        if scale == 0.0 {
173                            continue;
174                        }
175                        let mut block = dest.slice_mut(s![
176                            i * rrows..(i + 1) * rrows,
177                            j * r_cols..(j + 1) * r_cols
178                        ]);
179                        block.scaled_add(scale, right);
180                    }
181                }
182            }
183        }
184    }
185
186    pub fn to_dense(&self, total_dim: usize) -> Array2<f64> {
187        let mut dense = Array2::<f64>::zeros((total_dim, total_dim));
188        self.accumulate_into(
189            dense.slice_mut(s![self.col_range.clone(), self.col_range.clone()]),
190            1.0,
191        );
192        dense
193    }
194}
195
196pub(crate) fn array_to_faer(array: &Array2<f64>) -> Mat<f64> {
197    let (rows, cols) = array.dim();
198    Mat::from_fn(rows, cols, |i, j| array[[i, j]])
199}
200
201pub(crate) fn mat_to_array(mat: &Mat<f64>) -> Array2<f64> {
202    let mut out = Array2::<f64>::zeros((mat.nrows(), mat.ncols()));
203    for i in 0..mat.nrows() {
204        for j in 0..mat.ncols() {
205            out[[i, j]] = mat[(i, j)];
206        }
207    }
208    out
209}
210
211fn mat_max_abs_element(matrix: MatRef<'_, f64>) -> f64 {
212    let (rows, cols) = matrix.shape();
213    let mut maxval = 0.0_f64;
214    for i in 0..rows {
215        for j in 0..cols {
216            let val = matrix[(i, j)];
217            if val.is_finite() {
218                maxval = maxval.max(val.abs());
219            }
220        }
221    }
222    maxval
223}
224
225fn sanitize_symmetric_faer(matrix: &Mat<f64>) -> Mat<f64> {
226    let (rows, cols) = matrix.as_ref().shape();
227    assert_eq!(rows, cols, "Matrix must be square for sanitization");
228
229    let mut sanitized = matrix.clone();
230
231    for i in 0..rows {
232        let diag = sanitized[(i, i)];
233        if !diag.is_finite() {
234            sanitized[(i, i)] = 0.0;
235        }
236        for j in (i + 1)..cols {
237            let mut upper = sanitized[(i, j)];
238            let mut lower = sanitized[(j, i)];
239            if !upper.is_finite() {
240                upper = 0.0;
241            }
242            if !lower.is_finite() {
243                lower = 0.0;
244            }
245            let avg = 0.5 * (upper + lower);
246            sanitized[(i, j)] = avg;
247            sanitized[(j, i)] = avg;
248        }
249    }
250
251    let scale = mat_max_abs_element(sanitized.as_ref());
252    let tiny = (scale * 1e-14).max(1e-30);
253    for i in 0..rows {
254        for j in 0..cols {
255            let val = sanitized[(i, j)];
256            if !val.is_finite() {
257                sanitized[(i, j)] = 0.0;
258            } else if val.abs() < tiny {
259                sanitized[(i, j)] = 0.0;
260            }
261        }
262    }
263
264    sanitized
265}
266
267fn penalty_from_root_faer(root: &Mat<f64>) -> Mat<f64> {
268    let cols = root.ncols();
269    let mut full = Mat::<f64>::zeros(cols, cols);
270    let root_ref = root.as_ref();
271    let root_t = root_ref.transpose();
272    matmul(
273        full.as_mut(),
274        Accum::Replace,
275        root_t,
276        root_ref,
277        1.0,
278        Par::Seq,
279    );
280    sanitize_symmetric_faer(&full)
281}
282
283fn symmetrize_faer_matrix_in_place(matrix: &mut Mat<f64>) {
284    let n = matrix.nrows().min(matrix.ncols());
285    for i in 0..n {
286        for j in 0..i {
287            let avg = 0.5 * (matrix[(i, j)] + matrix[(j, i)]);
288            matrix[(i, j)] = avg;
289            matrix[(j, i)] = avg;
290        }
291    }
292}
293
294fn orthogonal_similarity_transform_faer(
295    matrix: &Mat<f64>,
296    block_dim: usize,
297    orthogonal: &Mat<f64>,
298) -> Mat<f64> {
299    let matrix_block = matrix.as_ref().submatrix(0, 0, block_dim, block_dim);
300    let cols = orthogonal.ncols();
301    let mut temp = Mat::<f64>::zeros(block_dim, cols);
302    matmul(
303        temp.as_mut(),
304        Accum::Replace,
305        matrix_block,
306        orthogonal.as_ref(),
307        1.0,
308        Par::Seq,
309    );
310    let mut rotated = Mat::<f64>::zeros(cols, cols);
311    matmul(
312        rotated.as_mut(),
313        Accum::Replace,
314        orthogonal.transpose(),
315        temp.as_ref(),
316        1.0,
317        Par::Seq,
318    );
319    symmetrize_faer_matrix_in_place(&mut rotated);
320    rotated
321}
322
323fn trace_penalty_in_orthogonal_basis(
324    matrix: &Mat<f64>,
325    block_dim: usize,
326    orthogonal: &Mat<f64>,
327    rotated_eigenvalues: &[f64],
328    delta: f64,
329) -> f64 {
330    let matrix_block = matrix.as_ref().submatrix(0, 0, block_dim, block_dim);
331    let cols = orthogonal.ncols();
332    assert!(rotated_eigenvalues.len() >= cols);
333    let mut projected = Mat::<f64>::zeros(block_dim, cols);
334    matmul(
335        projected.as_mut(),
336        Accum::Replace,
337        matrix_block,
338        orthogonal.as_ref(),
339        1.0,
340        Par::Seq,
341    );
342    let mut trace = KahanSum::default();
343    for l in 0..cols {
344        let mut diag_ll = KahanSum::default();
345        for i in 0..block_dim {
346            diag_ll.add(orthogonal[(i, l)] * projected[(i, l)]);
347        }
348        trace.add(diag_ll.sum() / (rotated_eigenvalues[l] + delta));
349    }
350    trace.sum()
351}
352
353pub fn trace_reduced_penalty_covariance(
354    reduced_penalty: &Array2<f64>,
355    covariance_basis: &Array2<f64>,
356) -> f64 {
357    assert_eq!(
358        reduced_penalty.dim(),
359        covariance_basis.dim(),
360        "trace_reduced_penalty_covariance dimension mismatch"
361    );
362    let r = covariance_basis.nrows();
363    let mut trace = KahanSum::default();
364    for i in 0..r {
365        for j in 0..r {
366            trace.add(covariance_basis[[i, j]] * reduced_penalty[[j, i]]);
367        }
368    }
369    trace.sum()
370}
371
372pub fn trace_penalty_covariance_in_orthogonal_basis(
373    matrix: &Array2<f64>,
374    orthogonal: &Array2<f64>,
375    covariance_basis: &Array2<f64>,
376) -> f64 {
377    let reduced = gam_linalg::faer_ndarray::fast_ab(
378        &gam_linalg::faer_ndarray::fast_atb(orthogonal, matrix),
379        orthogonal,
380    );
381    trace_reduced_penalty_covariance(&reduced, covariance_basis)
382}
383
384/// Strict spectral classifier used as a final guard on penalty eigendecompositions.
385///
386/// Penalty matrices fed to the GAM solver are required to be PSD by construction.
387/// This routine snaps roundoff-zero eigenvalues to exact zero, accepts strictly
388/// positive eigenvalues, and rejects materially-indefinite or non-finite spectra
389/// with a hard error rather than silently rewriting them. The previous behaviour
390/// (mass-zeroing negative or non-finite eigenvalues) hid construction bugs and
391/// changed the optimisation objective downstream.
392///
393/// The acceptance tolerance is the larger of a machine-ε floor
394/// (`C_EPS_P_FACTOR * eps_machine * p * scale`, with `C_EPS_P_FACTOR = 64`
395/// absorbing the rounding accumulated in a symmetric eigendecomposition of a
396/// moderate-dimension matrix) and a relative "numerically PSD" floor
397/// `REL_PSD_FLOOR * scale`. The latter dominates for large high-rank penalties
398/// assembled / reparameterized at extreme λ, where roundoff produces
399/// ~1e-11-relative negative eigenvalues that are PSD to any reasonable precision
400/// yet exceeded the bare ~12×ε machine floor and spuriously failed the inner
401/// P-IRLS solve (#1619). Genuine indefiniteness is O(1) relative and is still
402/// rejected far above either floor.
403fn classify_eigenvalues_strict(
404    eigenvalues: &mut [f64],
405    context: &str,
406) -> Result<(), EstimationError> {
407    const C_EPS_P_FACTOR: f64 = 64.0;
408    // `REL_PSD_FLOOR` (module-level): the relative threshold below which a
409    // (possibly slightly negative) eigenvalue is roundoff and is snapped to zero
410    // rather than rejected. Shared with the subspace-leakage guard so the null
411    // definition and the leakage tolerance stay mutually consistent.
412    let p = eigenvalues.len();
413
414    let mut scale = 0.0_f64;
415    for (idx, &val) in eigenvalues.iter().enumerate() {
416        if !val.is_finite() {
417            return Err(EstimationError::PenaltySpectrumNonFinite {
418                context: context.to_string(),
419                index: idx,
420                value: val,
421            });
422        }
423        scale = scale.max(val.abs());
424    }
425
426    // p * eps captures the rounding floor of a symmetric eigendecomposition of a
427    // p-dimensional matrix; multiplying by `scale` lifts the floor to the actual
428    // magnitude of the spectrum. For large high-rank penalties assembled at
429    // extreme λ this machine floor (~12×ε relative) is tighter than the roundoff
430    // actually produced, so we take the larger of it and a relative numerically-PSD
431    // floor `REL_PSD_FLOOR * scale` (#1619).
432    let machine_floor = C_EPS_P_FACTOR * f64::EPSILON * (p.max(1) as f64) * scale;
433    let tolerance = machine_floor
434        .max(REL_PSD_FLOOR * scale)
435        .max(f64::MIN_POSITIVE);
436
437    for (idx, val) in eigenvalues.iter_mut().enumerate() {
438        if val.abs() <= tolerance {
439            *val = 0.0;
440        } else if *val < 0.0 {
441            return Err(EstimationError::PenaltySpectrumIndefinite {
442                context: context.to_string(),
443                index: idx,
444                value: *val,
445                tolerance,
446                scale,
447            });
448        }
449    }
450    Ok(())
451}
452
453fn robust_eighwith_policy<M, V, E, Validate, Sanitize, EigCall, MapErr>(
454    matrix: &M,
455    context: &str,
456    validate_input: Validate,
457    sanitize: Sanitize,
458    mut eig_call: EigCall,
459    map_error: MapErr,
460) -> Result<(Vec<f64>, V), EstimationError>
461where
462    Validate: Fn(&M, &str) -> Result<(), EstimationError>,
463    Sanitize: Fn(&M) -> M,
464    EigCall: FnMut(&M) -> Result<(Vec<f64>, V), E>,
465    MapErr: Fn(E, &str) -> EstimationError,
466{
467    validate_input(matrix, context)?;
468
469    // The sanitize step only enforces exact symmetry by averaging M and M^T and
470    // zeros sub-eps noise; it never adds a diagonal ridge. Adding ridge changes
471    // the matrix being decomposed, which silently changes the optimisation
472    // objective downstream. If eigh genuinely fails on a finite symmetric input,
473    // surface the error instead of mutating the spectrum.
474    let candidate = sanitize(matrix);
475    match eig_call(&candidate) {
476        Ok((mut eigenvalues, eigenvectors)) => {
477            classify_eigenvalues_strict(&mut eigenvalues, context)?;
478            Ok((eigenvalues, eigenvectors))
479        }
480        Err(err) => Err(map_error(err, context)),
481    }
482}
483
484pub(crate) fn robust_eigh_faer(
485    matrix: &Mat<f64>,
486    side: Side,
487    context: &str,
488) -> Result<(Vec<f64>, Mat<f64>), EstimationError> {
489    robust_eighwith_policy(
490        matrix,
491        context,
492        |mat, ctx| {
493            let (rows, cols) = mat.as_ref().shape();
494            for i in 0..rows {
495                for j in 0..cols {
496                    let val = mat[(i, j)];
497                    if !val.is_finite() {
498                        let max_abs = mat_max_abs_element(mat.as_ref());
499                        crate::bail_invalid_estim!(
500                            "{} contains non-finite entries (max finite magnitude {:.3e})",
501                            ctx,
502                            max_abs
503                        );
504                    }
505                }
506            }
507            Ok(())
508        },
509        sanitize_symmetric_faer,
510        |candidate| {
511            let eig = candidate.as_ref().self_adjoint_eigen(side)?;
512            let diag = eig.S();
513            let mut eigenvalues = Vec::with_capacity(diag.dim());
514            for idx in 0..diag.dim() {
515                eigenvalues.push(diag[idx]);
516            }
517
518            let vectors_ref = eig.U();
519            let mut eigenvectors = Mat::<f64>::zeros(vectors_ref.nrows(), vectors_ref.ncols());
520            for i in 0..vectors_ref.nrows() {
521                for j in 0..vectors_ref.ncols() {
522                    eigenvectors[(i, j)] = vectors_ref[(i, j)];
523                }
524            }
525            Ok((eigenvalues, eigenvectors))
526        },
527        |err, _ctx| {
528            EstimationError::EigendecompositionFailed(FaerLinalgError::SelfAdjointEigen(err))
529        },
530    )
531}
532
533fn robust_eigh(
534    matrix: &Array2<f64>,
535    side: Side,
536    context: &str,
537) -> Result<(Array1<f64>, Array2<f64>), EstimationError> {
538    let matrix_faer = array_to_faer(matrix);
539    let (eigenvalues, eigenvectors) = robust_eigh_faer(&matrix_faer, side, context)?;
540    Ok((Array1::from_vec(eigenvalues), mat_to_array(&eigenvectors)))
541}
542
543pub(crate) fn kronecker_marginal_eigensystems(
544    marginal_penalties: &[Array2<f64>],
545    context: &str,
546) -> Result<Vec<(Array1<f64>, Array2<f64>)>, EstimationError> {
547    let mut eigensystems = Vec::with_capacity(marginal_penalties.len());
548    for (k, penalty) in marginal_penalties.iter().enumerate() {
549        eigensystems.push(robust_eigh(
550            penalty,
551            Side::Lower,
552            &format!("{context} marginal {k}"),
553        )?);
554    }
555    Ok(eigensystems)
556}
557
558#[derive(Debug, Clone, Copy)]
559struct SubspaceLeakageMetrics {
560    max_abs_sq: f64,
561    max_rel_sq: f64,
562    worst_penalty: usize,
563    max_cross_gram_abs: f64,
564}
565
566fn assess_subspace_leakage(
567    qs: &Mat<f64>,
568    rs_transformed: &[Mat<f64>],
569    structural_rank: usize,
570    p: usize,
571) -> SubspaceLeakageMetrics {
572    let mut max_abs_sq = 0.0_f64;
573    let mut max_rel_sq = 0.0_f64;
574    let mut worst_penalty = 0usize;
575
576    for (k, rs) in rs_transformed.iter().enumerate() {
577        let rows = rs.nrows();
578        let cols = rs.ncols().min(p);
579        let null_start = structural_rank.min(cols);
580        let mut abs_sq = 0.0_f64;
581        let mut total_sq = 0.0_f64;
582        for i in 0..rows {
583            for j in 0..cols {
584                let v = rs[(i, j)];
585                let vv = v * v;
586                total_sq += vv;
587                if j >= null_start {
588                    abs_sq += vv;
589                }
590            }
591        }
592        let rel_sq = if total_sq > 0.0 {
593            abs_sq / total_sq
594        } else {
595            0.0
596        };
597        if rel_sq > max_rel_sq {
598            max_rel_sq = rel_sq;
599            worst_penalty = k;
600        }
601        max_abs_sq = max_abs_sq.max(abs_sq);
602    }
603
604    let mut max_cross_gram_abs = 0.0_f64;
605    let null_count = p.saturating_sub(structural_rank);
606    if structural_rank > 0 && null_count > 0 {
607        for i in 0..structural_rank {
608            for j in 0..null_count {
609                let qn_col = structural_rank + j;
610                let mut dot = 0.0_f64;
611                for r in 0..p {
612                    dot += qs[(r, i)] * qs[(r, qn_col)];
613                }
614                max_cross_gram_abs = max_cross_gram_abs.max(dot.abs());
615            }
616        }
617    }
618
619    SubspaceLeakageMetrics {
620        max_abs_sq,
621        max_rel_sq,
622        worst_penalty,
623        max_cross_gram_abs,
624    }
625}
626
627/// True when the penalized/null subspace split is numerically self-consistent.
628///
629/// The split has two independent invariants:
630///
631/// 1. **Orthogonality** — `Qs = [Q_p | Q_n]` must be orthonormal, so the range
632///    and null blocks share no direction (`max |Qp'Qn| ≤ orth_tol`). This is the
633///    structural correctness guarantee and is checked at machine precision.
634///
635/// 2. **Bounded root leakage** — the transformed penalty root must have
636///    negligible energy on the null columns. The admissible relative-energy
637///    leakage is DERIVED from `REL_PSD_FLOOR`, the same numerically-PSD floor
638///    that `classify_eigenvalues_strict` uses to decide which modes are null:
639///    a mode the classifier is entitled to call null can, by the symmetric
640///    eigensolver's own eigenvector accuracy on a small-gap spectrum, retain up
641///    to `REL_PSD_FLOOR` of the penalty root's relative energy in that direction.
642///    Summed over the at-most-`p` near-threshold null modes the relative leakage
643///    cannot exceed `p · REL_PSD_FLOOR` without signalling a genuine
644///    (non-numerical) inconsistency, so that is the derived tolerance — matching
645///    the `p`-scaling `classify_eigenvalues_strict` already applies to its
646///    machine floor. Demanding a leakage tighter than the very floor that
647///    defined the null space is self-contradictory: it rejects well-posed smooth
648///    manifold / Duchon / sphere penalties whose Laplace-Beltrami spectrum decays
649///    through the classification threshold with no clean rank gap (#1802), even
650///    though the downstream penalty (`E'E`) is rebuilt with an EXACTLY clean null
651///    block regardless. The absolute floor keeps a vanishing-scale penalty from
652///    tripping the relative test on pure roundoff.
653fn subspace_split_is_consistent(leakage: &SubspaceLeakageMetrics, p: usize) -> bool {
654    let leakage_rel_tol = (p.max(1) as f64) * REL_PSD_FLOOR;
655    let leakage_abs_tol = 1e-12;
656    let orth_tol = 1e-10;
657    let root_leaks = leakage.max_rel_sq > leakage_rel_tol && leakage.max_abs_sq > leakage_abs_tol;
658    let split_nonorthogonal = leakage.max_cross_gram_abs > orth_tol;
659    !(root_leaks || split_nonorthogonal)
660}
661
662fn compose_qs_from_split(q_pen: &Mat<f64>, q_null: &Mat<f64>, p: usize) -> Mat<f64> {
663    let rank = q_pen.ncols();
664    let null_count = q_null.ncols();
665    let mut qs = Mat::<f64>::zeros(p, p);
666    for i in 0..p {
667        for j in 0..rank {
668            qs[(i, j)] = q_pen[(i, j)];
669        }
670        for j in 0..null_count {
671            qs[(i, rank + j)] = q_null[(i, j)];
672        }
673    }
674    qs
675}
676
677/// Computes the Kronecker product A ⊗ B for penalty matrix construction.
678/// This is used to create tensor product penalties that enforce smoothness
679/// in multiple dimensions for interaction terms.
680pub fn kronecker_product(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
681    let (arows, a_cols) = a.dim();
682    let (brows, b_cols) = b.dim();
683    if arows == 0 || a_cols == 0 || brows == 0 || b_cols == 0 {
684        return Array2::zeros((arows * brows, a_cols * b_cols));
685    }
686    let mut result = Array2::zeros((arows * brows, a_cols * b_cols));
687
688    result
689        .axis_chunks_iter_mut(Axis(0), brows)
690        .into_par_iter()
691        .enumerate()
692        .for_each(|(i, mut row_block)| {
693            let arow = a.row(i);
694            let col_chunks = row_block.axis_chunks_iter_mut(Axis(1), b_cols);
695            for (j, mut block) in col_chunks.into_iter().enumerate() {
696                let aval = arow[j];
697                if aval == 0.0 {
698                    continue;
699                }
700                for (dest, &src) in block.iter_mut().zip(b.iter()) {
701                    *dest = aval * src;
702                }
703            }
704        });
705
706    result
707}
708
709/// Result of the stable reparameterization algorithm from Wood (2011) Appendix B
710#[derive(Clone)]
711pub struct ReparamResult {
712    /// Penalty matrix in TRANSFORMED coefficient coordinates.
713    ///
714    /// This must be compatible with `beta_transformed` and `X_transformed = X * Qs`.
715    pub s_transformed: Array2<f64>,
716    /// Log-determinant of the penalty matrix (stable computation)
717    pub log_det: f64,
718    /// First derivatives of log-determinant w.r.t. log-smoothing parameters
719    pub det1: Array1<f64>,
720    /// Orthogonal transformation matrix Qs
721    pub qs: Array2<f64>,
722    /// Canonical penalties in the TRANSFORMED coordinate frame.
723    /// The single source of truth for penalty roots in the transformed frame.
724    /// Downstream consumers use these for block-local `PenaltyCoordinate`
725    /// construction, TK correction, and ext-coord paths.
726    pub canonical_transformed: Vec<CanonicalPenalty>,
727    /// Lambda-dependent penalty square root in TRANSFORMED coordinates (rank x p matrix).
728    /// This is used for applying the actual penalty in the least squares solve.
729    pub e_transformed: Array2<f64>,
730    /// Truncated eigenvectors (p × m where m = p - structural_rank).
731    ///
732    /// Coordinate frame note:
733    /// - This matrix is stored in the TRANSFORMED coefficient frame (post-`Qs`),
734    ///   i.e. it is compatible with `canonical_transformed`, `beta_transformed`,
735    ///   and transformed Hessians without additional coordinate mapping.
736    ///
737    /// These vectors span the structural null space used by positive-part
738    /// log-determinant conventions.
739    pub u_truncated: Array2<f64>,
740    /// The rho-independent shrinkage ridge magnitude that was added to each
741    /// eigenvalue of the penalized block. Zero means no shrinkage was applied.
742    pub penalty_shrinkage_ridge: f64,
743}
744
745// ---------------------------------------------------------------------------
746// Kronecker factor decomposition primitives
747// ---------------------------------------------------------------------------
748
749/// Per-factor decomposition result for Kronecker penalties.
750struct KroneckerFactorDecomp {
751    root: Array2<f64>,              // rank_j × q_j
752    positive_eigenvalues: Vec<f64>, // length = rank_j
753    rank: usize,
754    dim: usize,
755}
756
757/// Eigendecompose each Kronecker factor separately at O(Σ q_j³).
758/// Returns per-factor decompositions, or `None` if any factor is zero.
759fn decompose_kronecker_factors(
760    factors: &[Array2<f64>],
761    context: &str,
762) -> Result<Option<Vec<KroneckerFactorDecomp>>, EstimationError> {
763    let mut decomps = Vec::with_capacity(factors.len());
764    for (j, factor) in factors.iter().enumerate() {
765        let q_j = factor.nrows();
766        if q_j != factor.ncols() {
767            crate::bail_invalid_estim!(
768                "{context}: Kronecker factor {j} must be square, got {}x{}",
769                factor.nrows(),
770                factor.ncols()
771            );
772        }
773        let is_identity = {
774            let mut is_id = true;
775            'outer: for r in 0..q_j {
776                for c in 0..q_j {
777                    let expected = if r == c { 1.0 } else { 0.0 };
778                    if (factor[[r, c]] - expected).abs() > 1e-12 {
779                        is_id = false;
780                        break 'outer;
781                    }
782                }
783            }
784            is_id
785        };
786        if is_identity {
787            decomps.push(KroneckerFactorDecomp {
788                root: Array2::eye(q_j),
789                positive_eigenvalues: vec![1.0; q_j],
790                rank: q_j,
791                dim: q_j,
792            });
793            continue;
794        }
795        let analysis = analyze_penalty_block(factor).map_err(|err| {
796            EstimationError::InvalidInput(format!(
797                "{context}: Kronecker factor {j} eigendecomp failed: {err}"
798            ))
799        })?;
800        if analysis.rank == 0 {
801            return Ok(None);
802        }
803        // Build the factor root from ONLY the range (positive-curvature)
804        // directions via the canonical classifier — never the null or
805        // negative-curvature directions (#1425).
806        let factor_classes =
807            crate::basis::SpectralClassification::new(&analysis.eigenvalues, analysis.tol);
808        let mut root_j = Array2::zeros((analysis.rank, q_j));
809        let mut pos_eigs = Vec::with_capacity(analysis.rank);
810        for (row_idx, &i) in factor_classes.range_idx.iter().enumerate() {
811            let eigenval = analysis.eigenvalues[i];
812            let sqrt_ev = eigenval.sqrt();
813            let evec = analysis.eigenvectors.column(i);
814            for (col, &v) in evec.iter().enumerate() {
815                root_j[[row_idx, col]] = sqrt_ev * v;
816            }
817            pos_eigs.push(eigenval);
818        }
819        decomps.push(KroneckerFactorDecomp {
820            root: root_j,
821            positive_eigenvalues: pos_eigs,
822            rank: analysis.rank,
823            dim: q_j,
824        });
825    }
826    Ok(Some(decomps))
827}
828
829/// Build the block-local Kronecker root from pre-computed factor decompositions.
830fn assemble_kronecker_root_local(decomps: &[KroneckerFactorDecomp]) -> Array2<f64> {
831    let mut kron_root = decomps[0].root.clone();
832    for fr in &decomps[1..] {
833        let (r1, c1) = kron_root.dim();
834        let (r2, c2) = (fr.rank, fr.dim);
835        let mut new_root = Array2::zeros((r1 * r2, c1 * c2));
836        for i1 in 0..r1 {
837            for i2 in 0..r2 {
838                for j1 in 0..c1 {
839                    for j2 in 0..c2 {
840                        new_root[[i1 * r2 + i2, j1 * c2 + j2]] =
841                            kron_root[[i1, j1]] * fr.root[[i2, j2]];
842                    }
843                }
844            }
845        }
846        kron_root = new_root;
847    }
848    kron_root
849}
850
851/// Compute eigenvalues of the Kronecker product from per-factor eigenvalues.
852fn kronecker_eigenvalues(decomps: &[KroneckerFactorDecomp], block_dim: usize) -> (Vec<f64>, usize) {
853    let mut kron_eigs = decomps[0].positive_eigenvalues.clone();
854    for fd in &decomps[1..] {
855        let mut new_eigs = Vec::with_capacity(kron_eigs.len() * fd.positive_eigenvalues.len());
856        for &a in &kron_eigs {
857            for &b in &fd.positive_eigenvalues {
858                new_eigs.push(a * b);
859            }
860        }
861        kron_eigs = new_eigs;
862    }
863    let max_ev = kron_eigs.iter().copied().fold(0.0_f64, f64::max);
864    let tol = max_ev * 1e-10 * (block_dim as f64);
865    let positive: Vec<f64> = kron_eigs.into_iter().filter(|&ev| ev > tol).collect();
866    let nullity = block_dim - positive.len();
867    (positive, nullity)
868}
869
870// ---------------------------------------------------------------------------
871// CanonicalPenalty — block-local processed penalty for the solver
872// ---------------------------------------------------------------------------
873
874/// A canonicalized penalty with block-local root, ready for the solver.
875///
876/// Instead of storing a full `p x p` penalty matrix, this stores only the
877/// `rank x block_dim` root and the column range, enabling O(p_k^2) operations
878/// instead of O(p^2).
879#[derive(Clone)]
880pub struct CanonicalPenalty {
881    /// Square root matrix: S_k = root^T * root.
882    /// Shape: `rank x block_dim` for block-local, `rank x p` for dense.
883    pub root: Array2<f64>,
884    /// Column range in the global coefficient vector [start..end).
885    /// For dense penalties this is `0..p`.
886    pub col_range: std::ops::Range<usize>,
887    /// Full parameter dimension p.
888    pub total_dim: usize,
889    /// Structural nullity of the local penalty.
890    pub nullity: usize,
891    /// The symmetrized block-local penalty matrix (block_dim × block_dim).
892    /// Cached at construction time to avoid recomputing root^T * root
893    /// in hot paths (penalty assembly, trace products).
894    pub local: Array2<f64>,
895    /// Block-local prior mean used to center this penalty.
896    pub prior_mean: Array1<f64>,
897    /// Positive eigenvalues of the local penalty matrix (length = rank).
898    /// Cached at construction time for REML logdet block-factored paths.
899    pub positive_eigenvalues: Vec<f64>,
900    /// Optional operator-form handle bit-equivalent to `local`. Propagated
901    /// from `PenaltySpec::Block.op`. Downstream PIRLS and REML exact operator
902    /// algebra route through this for dense-Gram-free matvec when present.
903    pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
904}
905
906impl std::fmt::Debug for CanonicalPenalty {
907    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        f.debug_struct("CanonicalPenalty")
909            .field(
910                "root",
911                &format_args!("{}×{}", self.root.nrows(), self.root.ncols()),
912            )
913            .field("col_range", &self.col_range)
914            .field("total_dim", &self.total_dim)
915            .field("nullity", &self.nullity)
916            .field(
917                "local",
918                &format_args!("{}×{}", self.local.nrows(), self.local.ncols()),
919            )
920            .field("prior_mean_len", &self.prior_mean.len())
921            .field("positive_eigenvalues", &self.positive_eigenvalues)
922            .field("op", &self.op.as_ref().map(|o| o.dim()))
923            .finish()
924    }
925}
926
927impl CanonicalPenalty {
928    /// Construct a dense (full-width) canonical penalty from a `rank x p` root.
929    /// Used to wrap reparam-transformed roots for consumers that expect
930    /// `&[CanonicalPenalty]`.
931    pub fn from_dense_root(root: Array2<f64>, p: usize) -> Self {
932        Self::from_dense_root_with_mean(root, p, Array1::zeros(p))
933    }
934
935    pub fn from_dense_root_with_mean(root: Array2<f64>, p: usize, prior_mean: Array1<f64>) -> Self {
936        assert_eq!(prior_mean.len(), p);
937        let local = root.t().dot(&root);
938        let positive_eigenvalues = Vec::new(); // not needed for TK paths
939        Self {
940            root,
941            col_range: 0..p,
942            total_dim: p,
943            nullity: 0,
944            local,
945            prior_mean,
946            positive_eigenvalues,
947            op: None,
948        }
949    }
950
951    /// Embed the block-local root into a full-width `rank × total_dim` matrix.
952    /// For dense penalties (col_range = 0..p), returns the root unchanged.
953    pub fn full_width_root(&self) -> Array2<f64> {
954        if self.col_range.start == 0 && self.col_range.end == self.total_dim {
955            return self.root.clone();
956        }
957        let rank = self.root.nrows();
958        let mut full = Array2::<f64>::zeros((rank, self.total_dim));
959        full.slice_mut(ndarray::s![.., self.col_range.clone()])
960            .assign(&self.root);
961        full
962    }
963
964    /// Numerical rank of this penalty.
965    pub fn rank(&self) -> usize {
966        self.root.nrows()
967    }
968
969    /// Block dimension (number of columns this penalty covers).
970    pub fn block_dim(&self) -> usize {
971        self.col_range.len()
972    }
973
974    /// Whether this penalty is block-local (col_range != 0..total_dim).
975    pub const fn is_block_local(&self) -> bool {
976        self.col_range.start != 0 || self.col_range.end != self.total_dim
977    }
978
979    /// Return a reference to the cached local penalty matrix.
980    /// Shape: `block_dim x block_dim`.
981    pub fn local_ref(&self) -> &Array2<f64> {
982        &self.local
983    }
984
985    /// Return an owned copy of the local penalty matrix.
986    /// Prefer `local_ref()` when a reference suffices.
987    pub fn local_penalty(&self) -> Array2<f64> {
988        self.local.clone()
989    }
990
991    /// Accumulate lambda * S_k into a pre-allocated `p x p` target matrix.
992    /// Only touches the block [col_range × col_range].
993    pub fn accumulate_weighted(&self, target: &mut Array2<f64>, lambda: f64) {
994        if lambda == 0.0 || self.rank() == 0 {
995            return;
996        }
997        let r = &self.col_range;
998        target
999            .slice_mut(s![r.start..r.end, r.start..r.end])
1000            .scaled_add(lambda, &self.local);
1001    }
1002
1003    /// Compute `scale * tr(M · S_k)` where M is a `p × p` dense matrix.
1004    /// Only reads `M[start..end, start..end]` — O(block_dim²) not O(p²).
1005    pub fn trace_product(&self, m: &Array2<f64>, scale: f64) -> f64 {
1006        if self.rank() == 0 || scale == 0.0 {
1007            return 0.0;
1008        }
1009        let r = &self.col_range;
1010        let m_block = m.slice(s![r.start..r.end, r.start..r.end]);
1011        let rm = self.root.dot(&m_block);
1012        scale
1013            * rm.iter()
1014                .zip(self.root.iter())
1015                .map(|(&a, &b)| a * b)
1016                .sum::<f64>()
1017    }
1018
1019    /// Compute `scale * v^T S_k v` (quadratic form).
1020    /// Only reads `v[start..end]` — O(rank × block_dim) not O(rank × p).
1021    pub fn quadratic(&self, v: &Array1<f64>, scale: f64) -> f64 {
1022        if self.rank() == 0 || scale == 0.0 {
1023            return 0.0;
1024        }
1025        let v_block = v.slice(s![self.col_range.start..self.col_range.end]);
1026        let rv = self.root.dot(&v_block);
1027        scale * rv.dot(&rv)
1028    }
1029
1030    /// Compute `scale * S_k * prior_mean` embedded into the global basis.
1031    pub fn prior_linear_shift(&self, scale: f64) -> Array1<f64> {
1032        let mut out = Array1::<f64>::zeros(self.total_dim);
1033        if self.rank() == 0 || scale == 0.0 || self.prior_mean.iter().all(|&v| v == 0.0) {
1034            return out;
1035        }
1036        let block = self.local.dot(&self.prior_mean) * scale;
1037        out.slice_mut(s![self.col_range.start..self.col_range.end])
1038            .assign(&block);
1039        out
1040    }
1041
1042    /// Compute `scale * prior_mean' S_k prior_mean`.
1043    pub fn prior_constant_shift(&self, scale: f64) -> f64 {
1044        if self.rank() == 0 || scale == 0.0 || self.prior_mean.iter().all(|&v| v == 0.0) {
1045            return 0.0;
1046        }
1047        scale * self.prior_mean.dot(&self.local.dot(&self.prior_mean))
1048    }
1049
1050    /// Embed this block's prior mean into the global coefficient basis.
1051    pub fn full_width_prior_mean(&self) -> Array1<f64> {
1052        if self.col_range.start == 0 && self.col_range.end == self.total_dim {
1053            return self.prior_mean.clone();
1054        }
1055        let mut out = Array1::<f64>::zeros(self.total_dim);
1056        out.slice_mut(s![self.col_range.start..self.col_range.end])
1057            .assign(&self.prior_mean);
1058        out
1059    }
1060
1061    /// Convert to a PenaltyCoordinate for the unified REML evaluator.
1062    pub fn to_penalty_coordinate(&self) -> gam_problem::PenaltyCoordinate {
1063        use gam_problem::PenaltyCoordinate;
1064        if self.is_block_local() {
1065            PenaltyCoordinate::from_block_root_with_mean(
1066                self.root.clone(),
1067                self.col_range.start,
1068                self.col_range.end,
1069                self.total_dim,
1070                self.prior_mean.clone(),
1071            )
1072        } else {
1073            PenaltyCoordinate::from_dense_root_with_mean(self.root.clone(), self.prior_mean.clone())
1074        }
1075    }
1076}
1077
1078/// Detect and report structurally identical (or near-identical) penalty pairs
1079/// in the canonical bundle.
1080///
1081/// Two penalties `S_i`, `S_j` with the same `col_range` are compared by their
1082/// matrix cosine:
1083///
1084///     cos(S_i, S_j) = tr(S_i S_j) / sqrt(tr(S_i^2) * tr(S_j^2))
1085///
1086/// Because `local` is symmetric, `tr(A·B) = sum_{r,c} A[r,c] * B[r,c]` — i.e.,
1087/// the Frobenius inner product. Pairs with different `col_range` cannot be
1088/// functionally identical and are skipped.
1089///
1090/// Logging policy:
1091/// - `cos > 1 - 1e-8` → `log::warn!` with `[PENALTY-REDUNDANCY]`. Every such
1092///   pair is emitted because it represents a structural model error (the LAML
1093///   cost has a Z₂-symmetric saddle that ARC's cubic regularization will
1094///   happily converge to under first-order stationarity).
1095/// - `0.99 < cos ≤ 1 - 1e-8` → `log::info!` with `[PENALTY-SIMILARITY]`.
1096///   At large scale (`k > 64`) only the top-3 highest-cosine such pairs are
1097///   logged to bound log volume.
1098///
1099/// Returns `Vec<(i, j, cos)>` for the **redundant** pairs (cos > 1 - 1e-8),
1100/// primarily to make this function unit-testable without a log capture.
1101///
1102/// Performance: this is O(k² · block_dim²); intended to be called exactly
1103/// once per fit (e.g. from `RemlState::newwith_offset_shared`).
1104pub fn report_penalty_pair_redundancy(canonical: &[CanonicalPenalty]) -> Vec<(usize, usize, f64)> {
1105    const REDUNDANCY_THRESHOLD: f64 = 1.0 - 1e-8;
1106    const SIMILARITY_THRESHOLD: f64 = 0.99;
1107    const LARGE_SCALE_K_THRESHOLD: usize = 64;
1108    const TOP_SIMILARITY_PAIRS: usize = 3;
1109
1110    let k = canonical.len();
1111    let mut redundant: Vec<(usize, usize, f64)> = Vec::new();
1112    let mut similar: Vec<(usize, usize, f64)> = Vec::new();
1113
1114    // Pre-compute tr(S_i^2) = sum of squares of S_i entries (Frobenius norm
1115    // squared). `local` is symmetric, so this equals tr(S_i^T S_i) = tr(S_i^2).
1116    let trace_sq: Vec<f64> = canonical
1117        .iter()
1118        .map(|p| p.local.iter().map(|&v| v * v).sum::<f64>())
1119        .collect();
1120
1121    for i in 0..k {
1122        if trace_sq[i] == 0.0 {
1123            continue;
1124        }
1125        for j in (i + 1)..k {
1126            if trace_sq[j] == 0.0 {
1127                continue;
1128            }
1129            // Different col_range → cannot be functionally identical by
1130            // construction (the block-local matrices live in disjoint or
1131            // mismatched parameter subspaces).
1132            if canonical[i].col_range != canonical[j].col_range {
1133                continue;
1134            }
1135            // Shapes must match — they do when col_range matches because
1136            // `local` is `block_dim × block_dim` and `block_dim = col_range.len()`.
1137            assert_eq!(canonical[i].local.dim(), canonical[j].local.dim());
1138
1139            let inner: f64 = canonical[i]
1140                .local
1141                .iter()
1142                .zip(canonical[j].local.iter())
1143                .map(|(&a, &b)| a * b)
1144                .sum();
1145            let denom = (trace_sq[i] * trace_sq[j]).sqrt();
1146            if denom == 0.0 {
1147                continue;
1148            }
1149            let cos = inner / denom;
1150
1151            if cos > REDUNDANCY_THRESHOLD {
1152                redundant.push((i, j, cos));
1153            } else if cos > SIMILARITY_THRESHOLD {
1154                similar.push((i, j, cos));
1155            }
1156        }
1157    }
1158
1159    // Always emit every redundancy — these are structural model errors.
1160    for &(i, j, cos) in &redundant {
1161        log::warn!(
1162            "[PENALTY-REDUNDANCY] penalties i={i} j={j} are structurally identical \
1163             (cos={cos:.6}) — model is over-parameterized along their antisymmetric \
1164             direction; expect a Z₂-symmetric saddle in the LAML cost. Consider \
1165             re-specifying (e.g. anisotropic→isotropic for spatial smoothers with \
1166             weak axis signal)."
1167        );
1168    }
1169
1170    // Cap similarity log volume at large scale.
1171    if k > LARGE_SCALE_K_THRESHOLD && similar.len() > TOP_SIMILARITY_PAIRS {
1172        similar.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
1173        similar.truncate(TOP_SIMILARITY_PAIRS);
1174    }
1175    for (i, j, cos) in similar {
1176        log::info!(
1177            "[PENALTY-SIMILARITY] penalties i={i} j={j} are near-identical \
1178             (cos={cos:.6}) — outer Hessian may be ill-conditioned along their \
1179             antisymmetric direction."
1180        );
1181    }
1182
1183    redundant
1184}
1185
1186/// Canonicalize a single `PenaltySpec` into a `CanonicalPenalty` by computing
1187/// the block-local eigendecomposition and extracting the root.
1188///
1189/// This is O(block_dim^3) instead of O(p^3) for block-local penalties.
1190/// Returns `None` if the penalty has rank zero (should be dropped).
1191pub fn canonicalize_penalty_spec(
1192    spec: &crate::PenaltySpec,
1193    p: usize,
1194    idx: usize,
1195    context: &str,
1196) -> Result<Option<CanonicalPenalty>, EstimationError> {
1197    use crate::PenaltySpec;
1198
1199    crate::validate_penalty_spec_shape(idx, spec, p, context)?;
1200
1201    let (local_matrix, col_range, prior_mean_spec, hint, op) = match spec {
1202        PenaltySpec::Block {
1203            local,
1204            col_range,
1205            prior_mean,
1206            structure_hint,
1207            op,
1208        } => (
1209            local.view(),
1210            col_range.clone(),
1211            prior_mean,
1212            structure_hint.as_ref(),
1213            op.clone(),
1214        ),
1215        PenaltySpec::Dense(m) => (
1216            m.view(),
1217            0..p,
1218            &gam_problem::CoefficientPriorMean::Zero,
1219            None,
1220            None,
1221        ),
1222        PenaltySpec::DenseWithMean { matrix, prior_mean } => {
1223            (matrix.view(), 0..p, prior_mean, None, None)
1224        }
1225    };
1226
1227    let block_dim = col_range.len();
1228    let prior_mean = prior_mean_spec
1229        .evaluate(block_dim, &format!("{context}: penalty {idx}"))
1230        .map_err(|e| EstimationError::InvalidInput(e.0))?;
1231
1232    // ── Ridge fast path: closed-form, no eigendecomposition ──
1233    if let Some(PenaltyStructureHint::Ridge(scale)) = hint {
1234        if *scale <= 0.0 {
1235            return Ok(None);
1236        }
1237        let sqrt_scale = scale.sqrt();
1238        let mut root = Array2::zeros((block_dim, block_dim));
1239        for i in 0..block_dim {
1240            root[[i, i]] = sqrt_scale;
1241        }
1242        // Ridge penalties are diagonal by construction, but still route through
1243        // the crate-wide ndarray symmetrizer so every construction variant uses
1244        // the same "average the transpose" cleanup instead of a local copy.
1245        let mut local_sym = local_matrix.to_owned();
1246        symmetrize_in_place(&mut local_sym);
1247        return Ok(Some(CanonicalPenalty {
1248            root,
1249            col_range,
1250            total_dim: p,
1251            nullity: 0,
1252            local: local_sym,
1253            prior_mean,
1254            positive_eigenvalues: vec![*scale; block_dim],
1255            op,
1256        }));
1257    }
1258
1259    // ── Kronecker fast path: single per-factor eigendecomposition ──
1260    if let Some(PenaltyStructureHint::Kronecker(factors)) = hint {
1261        let decomps =
1262            match decompose_kronecker_factors(factors, &format!("{context} penalty {idx}"))? {
1263                None => return Ok(None),
1264                Some(d) => d,
1265            };
1266        let (positive_eigenvalues, nullity) = kronecker_eigenvalues(&decomps, block_dim);
1267        if positive_eigenvalues.is_empty() {
1268            return Ok(None);
1269        }
1270        let root = assemble_kronecker_root_local(&decomps);
1271        let mut local_sym = local_matrix.to_owned();
1272        symmetrize_in_place(&mut local_sym);
1273        return Ok(Some(CanonicalPenalty {
1274            root,
1275            col_range,
1276            total_dim: p,
1277            nullity,
1278            local: local_sym,
1279            prior_mean,
1280            positive_eigenvalues,
1281            op,
1282        }));
1283    }
1284
1285    // ── Generic block-local path: eigendecompose at O(block_dim³) ──
1286    let local_owned = local_matrix.to_owned();
1287    let analysis = analyze_penalty_block(&local_owned).map_err(|err| {
1288        EstimationError::InvalidInput(format!(
1289            "{context}: penalty canonicalization failed at index {idx}: {err}"
1290        ))
1291    })?;
1292
1293    if analysis.rank == 0 {
1294        log::debug!(
1295            "Dropped inactive penalty block idx={idx} reason={}",
1296            if analysis.iszero {
1297                "ZeroMatrix"
1298            } else {
1299                "NumericalRankZero"
1300            }
1301        );
1302        return Ok(None);
1303    }
1304
1305    // Reuse the eigendecomposition from analyze_penalty_block and route the
1306    // range / null / negative-curvature split through the one canonical
1307    // classifier, so this root construction cannot disagree with the block's
1308    // own `rank` / `nullity` / `negative_dim` about which directions are
1309    // penalized, unpenalized, or non-PSD (#1425).
1310    let tolerance = analysis.tol;
1311    let classes = crate::basis::SpectralClassification::new(&analysis.eigenvalues, tolerance);
1312    let rank_k = classes.rank();
1313    assert_eq!(
1314        rank_k, analysis.rank,
1315        "penalty-root rank disagreement: SpectralClassification rank={rank_k} vs analyze_penalty_block rank={} (#1425 canonical-classifier invariant)",
1316        analysis.rank
1317    );
1318
1319    // Build the penalty root R from ONLY the range directions (positive
1320    // curvature): R has one row per range eigenpair, scaled by sqrt(ev), so
1321    // RᵀR reconstructs S on range(S). Null directions contribute nothing
1322    // (their eigenvalue is zero); negative-curvature directions are NEVER
1323    // square-rooted into R (their sqrt is imaginary) and are NOT null — they
1324    // are simply dropped from R, exactly as the closed-form Duchon kernels at
1325    // high d require to preserve the q_pen / q_null invariant downstream.
1326    let mut root = Array2::zeros((rank_k, block_dim));
1327    let mut positive_eigenvalues = Vec::with_capacity(rank_k);
1328    for (row_idx, &i) in classes.range_idx.iter().enumerate() {
1329        let eigenval = analysis.eigenvalues[i];
1330        let eigenvec = analysis.eigenvectors.column(i);
1331        root.row_mut(row_idx).assign(&(&eigenvec * eigenval.sqrt()));
1332        positive_eigenvalues.push(eigenval);
1333    }
1334
1335    // Surface any genuine negative curvature honestly: it is neither range
1336    // (dropped from R) nor null (excluded from `nullity`), so it would
1337    // otherwise vanish without a trace. A non-PSD penalty reaching this path
1338    // is a real geometric fact (e.g. high-d Duchon kernels) the operator
1339    // should be able to see.
1340    if classes.is_indefinite() {
1341        log::debug!(
1342            "{context}: penalty block idx={idx} carries {} negative-curvature \
1343             eigendirection(s) below -tol={tolerance:e}; dropped from the canonical \
1344             root and NOT counted as null space (rank={rank_k}, nullity={})",
1345            classes.negative_dim(),
1346            classes.nullity()
1347        );
1348    }
1349
1350    // Store the PSD reconstruction RᵀR rather than the raw symmetrised input so
1351    // the cached `local` matches the rank truncation embedded in `root`
1352    // (negative-curvature directions are excluded from both, as above).
1353    let local = root.t().dot(&root);
1354    Ok(Some(CanonicalPenalty {
1355        root,
1356        col_range,
1357        total_dim: p,
1358        nullity: classes.nullity(),
1359        local,
1360        prior_mean,
1361        positive_eigenvalues,
1362        op,
1363    }))
1364}
1365
1366/// Canonicalize a batch of penalty specs, dropping zero-rank penalties.
1367/// Returns (active_penalties, active_nullspace_dims).
1368pub fn canonicalize_penalty_specs(
1369    specs: &[crate::PenaltySpec],
1370    nullspace_dims: &[usize],
1371    p: usize,
1372    context: &str,
1373) -> Result<(Vec<CanonicalPenalty>, Vec<usize>), EstimationError> {
1374    if specs.len() != nullspace_dims.len() {
1375        crate::bail_invalid_estim!(
1376            "{context}: nullspace_dims length mismatch: penalties={}, nullspace_dims={}",
1377            specs.len(),
1378            nullspace_dims.len()
1379        );
1380    }
1381
1382    let mut active = Vec::with_capacity(specs.len());
1383    let mut active_nullspace = Vec::with_capacity(specs.len());
1384    for (idx, spec) in specs.iter().enumerate() {
1385        if let Some(canonical) = canonicalize_penalty_spec(spec, p, idx, context)? {
1386            active_nullspace.push(nullspace_dims[idx]);
1387            active.push(canonical);
1388        }
1389    }
1390    Ok((active, active_nullspace))
1391}
1392
1393/// Hard cap on the dimension `p` allowed to fall back to a dense p × p
1394/// eigendecomposition of the overlapping balanced penalty.
1395///
1396/// Beyond this cap the overlapping-penalty path errors out instead of
1397/// allocating an O(p²) workspace whose eigendecomposition would dominate the
1398/// solve. ResourcePolicy threading is the long-term home for this cap (the
1399/// resource_serialize agent is widening ResourcePolicy coverage); until that
1400/// lands, both overlapping branches share this single constant so they can't
1401/// drift apart.
1402pub(crate) const OVERLAPPING_PENALTY_DENSE_FALLBACK_MAX_P: usize = 4096;
1403
1404/// Creates a balanced penalty root from canonical penalties.
1405///
1406/// When all penalties have non-overlapping col_ranges, the balanced sum is
1407/// block-diagonal and eigendecomposition is done per-block at O(Σ p_k³)
1408/// instead of the global O(p³). Falls back to the global path when penalties
1409/// overlap.
1410pub fn create_balanced_penalty_root_from_canonical(
1411    penalties: &[CanonicalPenalty],
1412    p: usize,
1413) -> Result<Array2<f64>, EstimationError> {
1414    if penalties.is_empty() {
1415        return Ok(Array2::zeros((0, p)));
1416    }
1417
1418    // Group penalties by col_range.
1419    let mut block_groups: BTreeMap<(usize, usize), Vec<&CanonicalPenalty>> = BTreeMap::new();
1420    for cp in penalties {
1421        if cp.rank() == 0 {
1422            continue;
1423        }
1424        let key = (cp.col_range.start, cp.col_range.end);
1425        block_groups.entry(key).or_default().push(cp);
1426    }
1427
1428    if block_groups.is_empty() {
1429        return Ok(Array2::zeros((0, p)));
1430    }
1431
1432    // Check for overlapping ranges.
1433    let ranges: Vec<(usize, usize)> = block_groups.keys().copied().collect();
1434    let mut overlapping = false;
1435    for i in 1..ranges.len() {
1436        if ranges[i].0 < ranges[i - 1].1 {
1437            overlapping = true;
1438            break;
1439        }
1440    }
1441
1442    if overlapping {
1443        if p > OVERLAPPING_PENALTY_DENSE_FALLBACK_MAX_P {
1444            return Err(EstimationError::LayoutError(format!(
1445                "overlapping penalty root would require dense {}x{} eigendecomposition; \
1446                 large-model dense fallback is disabled. Keep penalties structured or \
1447                 extend the overlapping-penalty solver path",
1448                p, p
1449            )));
1450        }
1451        // Fallback: accumulate into p × p and eigendecompose globally.
1452        let mut s_balanced = Array2::zeros((p, p));
1453        for cp in penalties {
1454            if cp.rank() == 0 {
1455                continue;
1456            }
1457            let local = cp.local_ref();
1458            let frob_norm = local.iter().map(|&x| x * x).sum::<f64>().sqrt();
1459            if frob_norm > 1e-12 {
1460                let r = &cp.col_range;
1461                s_balanced
1462                    .slice_mut(s![r.start..r.end, r.start..r.end])
1463                    .scaled_add(1.0 / frob_norm, local);
1464            }
1465        }
1466        let (eigenvalues, eigenvectors) =
1467            robust_eigh(&s_balanced, Side::Lower, "balanced penalty matrix")?;
1468        let max_eig = eigenvalues.iter().fold(0.0f64, |max, &val| max.max(val));
1469        let tolerance = if max_eig > 0.0 {
1470            max_eig * 1e-12
1471        } else {
1472            1e-12
1473        };
1474        let penalty_rank = eigenvalues.iter().filter(|&&ev| ev > tolerance).count();
1475        if penalty_rank == 0 {
1476            return Ok(Array2::zeros((0, p)));
1477        }
1478        let mut eb = Array2::zeros((p, penalty_rank));
1479        let mut col_idx = 0;
1480        for (i, &eigenval) in eigenvalues.iter().enumerate() {
1481            if eigenval > tolerance {
1482                let sqrt_ev = eigenval.sqrt();
1483                let evec = eigenvectors.column(i);
1484                eb.column_mut(col_idx).assign(&(&evec * sqrt_ev));
1485                col_idx += 1;
1486            }
1487        }
1488        return Ok(eb.t().to_owned());
1489    }
1490
1491    // Non-overlapping: eigendecompose per block at O(Σ p_k³).
1492    struct BlockRoot {
1493        col_range: Range<usize>,
1494        root: Array2<f64>, // rank_b × block_dim
1495    }
1496    // Materialize the BTreeMap order first. Rayon preserves Vec collection
1497    // order for indexed parallel iterators, so assembly below remains stable by
1498    // ascending column range while independent block eigendecompositions run in
1499    // parallel.
1500    let ordered_blocks: Vec<((usize, usize), Vec<&CanonicalPenalty>)> =
1501        block_groups.into_iter().collect();
1502    let block_roots: Vec<BlockRoot> = ordered_blocks
1503        .into_par_iter()
1504        .map(
1505            |((start, end), cps)| -> Result<Option<BlockRoot>, EstimationError> {
1506                let block_dim = end - start;
1507                let mut s_balanced_local = Array2::zeros((block_dim, block_dim));
1508
1509                for cp in cps {
1510                    let local = cp.local_ref();
1511                    let frob_norm = local.iter().map(|&x| x * x).sum::<f64>().sqrt();
1512                    if frob_norm > 1e-12 {
1513                        s_balanced_local.scaled_add(1.0 / frob_norm, local);
1514                    }
1515                }
1516
1517                let (eigenvalues, eigenvectors) =
1518                    robust_eigh(&s_balanced_local, Side::Lower, "balanced penalty block")?;
1519                let max_eig = eigenvalues.iter().fold(0.0f64, |max, &val| max.max(val));
1520                let tolerance = if max_eig > 0.0 {
1521                    max_eig * 1e-12
1522                } else {
1523                    1e-12
1524                };
1525                let block_rank = eigenvalues.iter().filter(|&&ev| ev > tolerance).count();
1526
1527                if block_rank == 0 {
1528                    return Ok(None);
1529                }
1530
1531                let mut root = Array2::zeros((block_rank, block_dim));
1532                let mut row_idx = 0;
1533                for (i, &eigenval) in eigenvalues.iter().enumerate() {
1534                    if eigenval > tolerance {
1535                        let sqrt_ev = eigenval.sqrt();
1536                        let evec = eigenvectors.column(i);
1537                        root.row_mut(row_idx).assign(&(&evec * sqrt_ev));
1538                        row_idx += 1;
1539                    }
1540                }
1541
1542                Ok(Some(BlockRoot {
1543                    col_range: start..end,
1544                    root,
1545                }))
1546            },
1547        )
1548        .collect::<Result<Vec<_>, _>>()?
1549        .into_iter()
1550        .flatten()
1551        .collect();
1552    let total_rank: usize = block_roots.iter().map(|br| br.root.nrows()).sum();
1553
1554    if total_rank == 0 {
1555        return Ok(Array2::zeros((0, p)));
1556    }
1557
1558    // Assemble global balanced root: total_rank × p
1559    let mut eb = Array2::zeros((total_rank, p));
1560    let mut row_offset = 0;
1561    for br in &block_roots {
1562        let rank_b = br.root.nrows();
1563        eb.slice_mut(s![
1564            row_offset..(row_offset + rank_b),
1565            br.col_range.start..br.col_range.end
1566        ])
1567        .assign(&br.root);
1568        row_offset += rank_b;
1569    }
1570
1571    Ok(eb)
1572}
1573
1574/// Lambda-independent reparameterization invariants derived from penalty structure.
1575#[derive(Clone)]
1576struct SubspaceSplit {
1577    q_pen: Array2<f64>,
1578    q_null: Array2<f64>,
1579}
1580
1581impl SubspaceSplit {
1582    fn identity(p: usize) -> Self {
1583        Self {
1584            q_pen: Array2::zeros((p, 0)),
1585            q_null: Array2::eye(p),
1586        }
1587    }
1588
1589    fn from_ordered_qs(
1590        qs: &Mat<f64>,
1591        penalized_rank: usize,
1592        p: usize,
1593    ) -> Result<Self, EstimationError> {
1594        if qs.nrows() != p || qs.ncols() != p {
1595            return Err(EstimationError::LayoutError(format!(
1596                "Invalid Q basis dimensions: expected {p}x{p}, got {}x{}",
1597                qs.nrows(),
1598                qs.ncols()
1599            )));
1600        }
1601        if penalized_rank > p {
1602            return Err(EstimationError::LayoutError(format!(
1603                "Invalid penalized rank {penalized_rank} for p={p}"
1604            )));
1605        }
1606
1607        let null_count = p - penalized_rank;
1608        let mut q_pen = Array2::<f64>::zeros((p, penalized_rank));
1609        let mut q_null = Array2::<f64>::zeros((p, null_count));
1610        for i in 0..p {
1611            for j in 0..penalized_rank {
1612                q_pen[(i, j)] = qs[(i, j)];
1613            }
1614            for j in 0..null_count {
1615                q_null[(i, j)] = qs[(i, penalized_rank + j)];
1616            }
1617        }
1618
1619        Ok(Self { q_pen, q_null })
1620    }
1621
1622    fn rank(&self) -> usize {
1623        self.q_pen.ncols()
1624    }
1625
1626    fn p(&self) -> usize {
1627        self.q_pen.nrows()
1628    }
1629
1630    fn compose_qs(&self) -> Array2<f64> {
1631        let p = self.p();
1632        let rank = self.rank();
1633        let null_count = self.q_null.ncols();
1634        let mut qs = Array2::<f64>::zeros((p, p));
1635        for i in 0..p {
1636            for j in 0..rank {
1637                qs[(i, j)] = self.q_pen[(i, j)];
1638            }
1639            for j in 0..null_count {
1640                qs[(i, rank + j)] = self.q_null[(i, j)];
1641            }
1642        }
1643        qs
1644    }
1645}
1646
1647/// Lambda-independent reparameterization invariants derived from penalty structure.
1648#[derive(Clone)]
1649pub struct ReparamInvariant {
1650    split: SubspaceSplit,
1651    /// The balanced eigenvector matrix Q (p x p). Block-local roots are
1652    /// transformed on-the-fly as `R_block @ Q[start..end, :]` instead of
1653    /// storing pre-multiplied full-width roots.
1654    qs_base: Array2<f64>,
1655    has_nonzero: bool,
1656    /// Largest eigenvalue of the balanced (unit-Frobenius) penalty matrix.
1657    /// Used as the scale reference for the shrinkage floor.
1658    max_balanced_eigenvalue: f64,
1659}
1660
1661impl ReparamInvariant {
1662    /// Returns the largest eigenvalue of the balanced penalty matrix.
1663    /// This is lambda-independent and provides a natural scale for shrinkage.
1664    pub const fn max_balanced_eigenvalue(&self) -> f64 {
1665        self.max_balanced_eigenvalue
1666    }
1667}
1668
1669/// Precompute the lambda-invariant reparameterization structure from canonical penalties.
1670///
1671/// Uses block-local roots directly instead of requiring rank x p global roots.
1672/// Each `CanonicalPenalty` carries its own block-local root and column range,
1673/// so the balanced sum can be assembled without ever materializing full-size
1674/// penalty matrices.
1675pub fn precompute_reparam_invariant_from_canonical(
1676    penalties: &[CanonicalPenalty],
1677    p_total: usize,
1678) -> Result<ReparamInvariant, EstimationError> {
1679    use std::cmp::Ordering;
1680
1681    let m = penalties.len();
1682
1683    if m == 0 {
1684        return Ok(ReparamInvariant {
1685            split: SubspaceSplit::identity(p_total),
1686            qs_base: Array2::eye(p_total),
1687            has_nonzero: false,
1688            max_balanced_eigenvalue: 0.0,
1689        });
1690    }
1691
1692    // Group penalties by col_range to detect block-diagonal structure.
1693    struct PenRef {
1694        penalty_index: usize,
1695    }
1696    let mut block_groups: BTreeMap<(usize, usize), Vec<PenRef>> = BTreeMap::new();
1697    let mut has_nonzero = false;
1698    for (i, cp) in penalties.iter().enumerate() {
1699        if cp.rank() == 0 {
1700            continue;
1701        }
1702        let local = cp.local_ref();
1703        let frob_norm = local.iter().map(|&x| x * x).sum::<f64>().sqrt();
1704        if frob_norm > 1e-12 {
1705            has_nonzero = true;
1706        }
1707        let key = (cp.col_range.start, cp.col_range.end);
1708        block_groups
1709            .entry(key)
1710            .or_default()
1711            .push(PenRef { penalty_index: i });
1712    }
1713
1714    if !has_nonzero {
1715        return Ok(ReparamInvariant {
1716            split: SubspaceSplit::identity(p_total),
1717            qs_base: Array2::eye(p_total),
1718            has_nonzero: false,
1719            max_balanced_eigenvalue: 0.0,
1720        });
1721    }
1722
1723    // Check for overlapping ranges.
1724    let ranges: Vec<(usize, usize)> = block_groups.keys().copied().collect();
1725    let mut overlapping = false;
1726    for i in 1..ranges.len() {
1727        if ranges[i].0 < ranges[i - 1].1 {
1728            overlapping = true;
1729            break;
1730        }
1731    }
1732
1733    if overlapping {
1734        // Mirror the dense-fallback guard from
1735        // `create_balanced_penalty_root_from_canonical`. Without this, large-scale-
1736        // scale models with overlapping penalties allocated a full
1737        // p_total × p_total workspace and ran an O(p³) eigendecomposition
1738        // before any solver code saw the problem size.
1739        if p_total > OVERLAPPING_PENALTY_DENSE_FALLBACK_MAX_P {
1740            return Err(EstimationError::LayoutError(format!(
1741                "overlapping penalty reparameterization would require dense {}x{} eigendecomposition; \
1742                 large-model dense fallback is disabled. Keep penalties structured or \
1743                 extend the overlapping-penalty solver path",
1744                p_total, p_total
1745            )));
1746        }
1747        // Fallback: global p×p eigendecomposition.
1748        let mut s_balanced = Mat::<f64>::zeros(p_total, p_total);
1749        for cp in penalties {
1750            if cp.rank() == 0 {
1751                continue;
1752            }
1753            let local = cp.local_ref();
1754            let frob_norm = local.iter().map(|&x| x * x).sum::<f64>().sqrt();
1755            if frob_norm > 1e-12 {
1756                let scale = 1.0 / frob_norm;
1757                let r = &cp.col_range;
1758                for i in 0..local.nrows() {
1759                    for j in 0..local.ncols() {
1760                        s_balanced[(r.start + i, r.start + j)] += scale * local[[i, j]];
1761                    }
1762                }
1763            }
1764        }
1765
1766        let (bal_eigenvalues, bal_eigenvectors) =
1767            robust_eigh_faer(&s_balanced, Side::Lower, "balanced penalty matrix")?;
1768
1769        let mut order: Vec<usize> = (0..p_total).collect();
1770        order.sort_by(|&i, &j| {
1771            bal_eigenvalues[j]
1772                .partial_cmp(&bal_eigenvalues[i])
1773                .unwrap_or(Ordering::Equal)
1774                .then(i.cmp(&j))
1775        });
1776
1777        let mut qs = Mat::<f64>::zeros(p_total, p_total);
1778        for (col_idx, &idx) in order.iter().enumerate() {
1779            for row in 0..p_total {
1780                qs[(row, col_idx)] = bal_eigenvectors[(row, idx)];
1781            }
1782        }
1783
1784        let max_bal = order
1785            .iter()
1786            .map(|&idx| bal_eigenvalues[idx].abs())
1787            .fold(0.0_f64, f64::max);
1788        let rank_tol = if max_bal > 0.0 {
1789            max_bal * 1e-12
1790        } else {
1791            1e-12
1792        };
1793        let penalized_rank = order
1794            .iter()
1795            .take_while(|&&idx| bal_eigenvalues[idx] > rank_tol)
1796            .count();
1797        let split = SubspaceSplit::from_ordered_qs(&qs, penalized_rank, p_total)?;
1798
1799        return Ok(ReparamInvariant {
1800            split,
1801            qs_base: mat_to_array(&qs),
1802            has_nonzero,
1803            max_balanced_eigenvalue: max_bal,
1804        });
1805    }
1806
1807    // -----------------------------------------------------------------------
1808    // Non-overlapping: block-diagonal eigendecomposition at O(Σ p_k³).
1809    // -----------------------------------------------------------------------
1810    // The balanced sum is block-diagonal ⟹ its eigenvectors are block-local.
1811    // Q_pen and Q_null are assembled by embedding block-local eigenvectors.
1812
1813    // Track which columns are covered by any penalty.
1814    let mut covered = vec![false; p_total];
1815    for cp in penalties {
1816        for j in cp.col_range.clone() {
1817            covered[j] = true;
1818        }
1819    }
1820    let uncovered_cols: Vec<usize> = (0..p_total).filter(|j| !covered[*j]).collect();
1821
1822    struct BlockResult {
1823        col_range: Range<usize>,
1824        q_pen_local: Array2<f64>,  // block_dim × pen_rank
1825        q_null_local: Array2<f64>, // block_dim × null_rank
1826        /// Largest balanced eigenvalue contributed by this block.
1827        max_balanced_eigenvalue: f64,
1828        /// Column offset of this block's penalized directions within global Q_pen.
1829        pen_col_offset: usize,
1830        /// Column offset of this block's null directions within global Q_null.
1831        null_col_offset: usize,
1832    }
1833
1834    // BTreeMap iteration defines the deterministic block order; collecting the
1835    // indexed parallel iterator preserves that order while eigendecomposing each
1836    // independent canonical penalty block concurrently.
1837    let block_specs: Vec<_> = block_groups.iter().collect();
1838    let mut block_results: Vec<BlockResult> = block_specs
1839        .into_par_iter()
1840        .map(
1841            |(&(start, end), refs)| -> Result<BlockResult, EstimationError> {
1842                let block_dim = end - start;
1843
1844                // Build local balanced sum.
1845                let mut s_balanced_local = Array2::zeros((block_dim, block_dim));
1846                let mut block_has_nonzero = false;
1847                for pref in refs {
1848                    let cp = &penalties[pref.penalty_index];
1849                    let local = cp.local_ref();
1850                    let frob_norm = local.iter().map(|&x| x * x).sum::<f64>().sqrt();
1851                    if frob_norm > 1e-12 {
1852                        s_balanced_local.scaled_add(1.0 / frob_norm, local);
1853                        block_has_nonzero = true;
1854                    }
1855                }
1856
1857                if !block_has_nonzero {
1858                    return Ok(BlockResult {
1859                        col_range: start..end,
1860                        q_pen_local: Array2::zeros((block_dim, 0)),
1861                        q_null_local: Array2::eye(block_dim),
1862                        max_balanced_eigenvalue: 0.0,
1863                        pen_col_offset: 0,  // set later
1864                        null_col_offset: 0, // set later
1865                    });
1866                }
1867
1868                // Eigendecompose the local balanced penalty.
1869                let (bal_eigenvalues, bal_eigenvectors) =
1870                    robust_eigh(&s_balanced_local, Side::Lower, "balanced penalty block")?;
1871
1872                let mut order: Vec<usize> = (0..block_dim).collect();
1873                order.sort_by(|&i, &j| {
1874                    bal_eigenvalues[j]
1875                        .partial_cmp(&bal_eigenvalues[i])
1876                        .unwrap_or(Ordering::Equal)
1877                        .then(i.cmp(&j))
1878                });
1879
1880                let max_bal = order
1881                    .iter()
1882                    .map(|&idx| bal_eigenvalues[idx].abs())
1883                    .fold(0.0_f64, f64::max);
1884                let rank_tol = if max_bal > 0.0 {
1885                    max_bal * 1e-12
1886                } else {
1887                    1e-12
1888                };
1889                let penalized_rank = order
1890                    .iter()
1891                    .take_while(|&&idx| bal_eigenvalues[idx] > rank_tol)
1892                    .count();
1893                let null_count = block_dim - penalized_rank;
1894
1895                let mut q_pen_local = Array2::zeros((block_dim, penalized_rank));
1896                let mut q_null_local = Array2::zeros((block_dim, null_count));
1897                for (col_idx, &idx) in order.iter().enumerate() {
1898                    if col_idx < penalized_rank {
1899                        for row in 0..block_dim {
1900                            q_pen_local[[row, col_idx]] = bal_eigenvectors[[row, idx]];
1901                        }
1902                    } else {
1903                        let null_col = col_idx - penalized_rank;
1904                        for row in 0..block_dim {
1905                            q_null_local[[row, null_col]] = bal_eigenvectors[[row, idx]];
1906                        }
1907                    }
1908                }
1909
1910                Ok(BlockResult {
1911                    col_range: start..end,
1912                    q_pen_local,
1913                    q_null_local,
1914                    max_balanced_eigenvalue: max_bal,
1915                    pen_col_offset: 0,  // set later
1916                    null_col_offset: 0, // set later
1917                })
1918            },
1919        )
1920        .collect::<Result<_, _>>()?;
1921    let global_max_bal = block_results
1922        .iter()
1923        .map(|br| br.max_balanced_eigenvalue)
1924        .fold(0.0_f64, f64::max);
1925
1926    // Compute column offsets for each block in the global Q_pen / Q_null layout.
1927    let total_pen_rank: usize = block_results.iter().map(|br| br.q_pen_local.ncols()).sum();
1928    let total_null: usize = block_results
1929        .iter()
1930        .map(|br| br.q_null_local.ncols())
1931        .sum::<usize>()
1932        + uncovered_cols.len();
1933    {
1934        let mut pen_off = 0usize;
1935        let mut null_off = 0usize;
1936        for br in &mut block_results {
1937            br.pen_col_offset = pen_off;
1938            br.null_col_offset = null_off;
1939            pen_off += br.q_pen_local.ncols();
1940            null_off += br.q_null_local.ncols();
1941        }
1942    }
1943
1944    let mut q_pen = Array2::zeros((p_total, total_pen_rank));
1945    let mut q_null = Array2::zeros((p_total, total_null));
1946
1947    for br in &block_results {
1948        let start = br.col_range.start;
1949        let bd = br.q_pen_local.nrows();
1950        let pen_r = br.q_pen_local.ncols();
1951        let null_r = br.q_null_local.ncols();
1952        if pen_r > 0 {
1953            q_pen
1954                .slice_mut(s![
1955                    start..(start + bd),
1956                    br.pen_col_offset..(br.pen_col_offset + pen_r)
1957                ])
1958                .assign(&br.q_pen_local);
1959        }
1960        if null_r > 0 {
1961            q_null
1962                .slice_mut(s![
1963                    start..(start + bd),
1964                    br.null_col_offset..(br.null_col_offset + null_r)
1965                ])
1966                .assign(&br.q_null_local);
1967        }
1968    }
1969    let mut null_col = block_results
1970        .iter()
1971        .map(|br| br.q_null_local.ncols())
1972        .sum::<usize>();
1973    for &j in &uncovered_cols {
1974        q_null[[j, null_col]] = 1.0;
1975        null_col += 1;
1976    }
1977
1978    let split = SubspaceSplit { q_pen, q_null };
1979
1980    // Store the global Q_s = [Q_pen | Q_null] from the split.
1981    // Block-local roots are transformed on-the-fly as R_block @ Q[start..end, :]
1982    // inside the reparam engine, avoiding O(k * rank * p) storage.
1983    let qs_global = split.compose_qs();
1984
1985    Ok(ReparamInvariant {
1986        split,
1987        qs_base: qs_global,
1988        has_nonzero,
1989        max_balanced_eigenvalue: global_max_bal,
1990    })
1991}
1992
1993fn structurally_penalized_columns(penalties: &[CanonicalPenalty], p: usize) -> Vec<bool> {
1994    let mut active = vec![false; p];
1995    for cp in penalties {
1996        let local = cp.local_ref();
1997        let scale = local.iter().map(|&v| v.abs()).fold(0.0_f64, f64::max);
1998        if scale <= 0.0 {
1999            continue;
2000        }
2001        let tol = scale * 1e-12;
2002        for local_col in 0..cp.block_dim() {
2003            let mut column_active = false;
2004            for row in 0..cp.block_dim() {
2005                if local[[row, local_col]].abs() > tol || local[[local_col, row]].abs() > tol {
2006                    column_active = true;
2007                    break;
2008                }
2009            }
2010            if column_active {
2011                active[cp.col_range.start + local_col] = true;
2012            }
2013        }
2014    }
2015    active
2016}
2017
2018/// Apply stable reparameterization using precomputed lambda-invariant structures.
2019///
2020/// `penalty_shrinkage_floor`: optional relative shrinkage floor for eigenvalues
2021/// of the penalized block. If `Some(epsilon)`, a rho-independent ridge of
2022/// magnitude `epsilon * max_balanced_eigenvalue` is added to each eigenvalue
2023/// of the combined penalty on the penalized block. This prevents barely-penalized
2024/// directions from causing pathological non-Gaussianity in the posterior (e.g.,
2025/// extreme skewness under logit link with high-dimensional spatial smooths).
2026/// A typical value is `1e-6`. Set to `None` or `Some(0.0)` to disable.
2027pub fn stable_reparameterizationwith_invariant(
2028    penalties: &[CanonicalPenalty],
2029    lambdas: &[f64],
2030    p: usize,
2031    invariant: &ReparamInvariant,
2032    penalty_shrinkage_floor: Option<f64>,
2033) -> Result<ReparamResult, EstimationError> {
2034    let m = penalties.len();
2035
2036    if lambdas.len() != m {
2037        return Err(EstimationError::ParameterConstraintViolation(format!(
2038            "Lambda count mismatch: expected {} lambdas for {} penalties, got {}",
2039            m,
2040            m,
2041            lambdas.len()
2042        )));
2043    }
2044
2045    // No separate length check needed — penalties are matched against lambdas above,
2046    // and the invariant's qs_base is p x p (dimension-checked by the split).
2047
2048    // #1074: the gam#1379 finite-ceiling on λ_k = exp(ρ_k) (clamp to 1e300 to
2049    // avoid `∞·0 = NaN` when the outer optimizer drives a redundant penalty
2050    // direction's log-λ past ~709) was DELETED. It masked the real defect: the
2051    // optimizer drives a redundant/unidentified penalty direction off to ∞
2052    // instead of that direction being detected and dropped from the model.
2053    // The root fix (detect+drop the redundant penalty direction at construction)
2054    // is tracked separately; λ now passes through raw.
2055
2056    if m == 0 {
2057        return Ok(ReparamResult {
2058            s_transformed: Array2::zeros((p, p)),
2059            log_det: 0.0,
2060            det1: Array1::zeros(0),
2061            qs: Array2::eye(p),
2062            canonical_transformed: vec![],
2063            e_transformed: Array2::zeros((0, p)),
2064            // All modes truncated when no penalties; already in transformed frame.
2065            u_truncated: Array2::eye(p),
2066            penalty_shrinkage_ridge: 0.0,
2067        });
2068    }
2069
2070    if !invariant.has_nonzero {
2071        let qs = invariant.split.compose_qs();
2072        let u_truncated = qs.t().dot(&invariant.split.q_null);
2073        // All penalties are zero — canonical_transformed = originals (no rotation needed).
2074        let canonical_transformed: Vec<CanonicalPenalty> = penalties.to_vec();
2075        return Ok(ReparamResult {
2076            s_transformed: Array2::zeros((p, p)),
2077            log_det: 0.0,
2078            det1: Array1::zeros(m),
2079            qs,
2080            canonical_transformed,
2081            e_transformed: Array2::zeros((0, p)),
2082            u_truncated,
2083            penalty_shrinkage_ridge: 0.0,
2084        });
2085    }
2086
2087    let q_pen = array_to_faer(&invariant.split.q_pen);
2088    let q_null = array_to_faer(&invariant.split.q_null);
2089    let qs_base = array_to_faer(&invariant.qs_base);
2090    // Each penalty root transform is independent: R_k_block @ Q[start..end, :].
2091    // Run those per-penalty products (and their S_k = R_k'R_k caches) in
2092    // parallel, then collect in slice order so all downstream accumulation stays
2093    // deterministic and bit-for-bit stable with respect to penalty ordering.
2094    let penalty_transforms: Vec<(Mat<f64>, Mat<f64>)> = penalties
2095        .par_iter()
2096        .map(|cp| {
2097            let r = &cp.col_range;
2098            let root_faer = array_to_faer(&cp.root);
2099            let q_block = qs_base.submatrix(r.start, 0, cp.block_dim(), p);
2100            let mut product = Mat::<f64>::zeros(cp.rank(), p);
2101            matmul(
2102                product.as_mut(),
2103                Accum::Replace,
2104                root_faer.as_ref(),
2105                q_block,
2106                1.0,
2107                Par::Seq,
2108            );
2109            let s_k = penalty_from_root_faer(&product);
2110            (product, s_k)
2111        })
2112        .collect();
2113    let (rs_transformed, s_k_penalized_cache): (Vec<Mat<f64>>, Vec<Mat<f64>>) =
2114        penalty_transforms.into_iter().unzip();
2115
2116    let penalized_rank = invariant.split.rank();
2117
2118    let mut range_eigenvalues_sorted: Vec<f64> = Vec::new();
2119    let mut range_rotation = Mat::<f64>::zeros(penalized_rank, penalized_rank);
2120    if penalized_rank > 0 {
2121        // Penalized-block spectrum `Σ_k λ_k S_k = U diag(d) Uᵀ` (restricted to the
2122        // λ-invariant penalized subspace).  Compute it from the SVD of the STACKED
2123        // SCALED ROOTS `E = [√λ_k R_k]_k` (root rows stacked), NOT from an
2124        // eigendecomposition of the assembled Gram `range_block = EᵀE`.
2125        //
2126        // Why (#2123): `S_k = R_kᵀ R_k`, so `Σ_k λ_k S_k = EᵀE` with
2127        // `E = vstack_k(√λ_k R_k)`.  Assembling the Gram and eigendecomposing it
2128        // SQUARES the condition number (`κ(EᵀE) = κ(E)²`).  When the outer optimizer
2129        // drives one margin toward its null space the λ dynamic range is enormous
2130        // (here `te(x,z)` with the near-linear z axis reaches `λ_ratio ≳ 1e8`), so a
2131        // recessive-penalty eigenvalue `d_min ≈ λ_min·σ²` is swamped by the
2132        // eigensolver's `O(ε·d_max) = O(ε·λ_max)` absolute floor — its eigenVECTOR
2133        // rotates into numerical noise, the genuinely-penalized direction is lost
2134        // from `e_transformed`, and the inner P-IRLS solve then fits that direction
2135        // to the data (a WIGGLY β̂ with `βᵀSβ̂ ≈ 0` despite `λ → ∞`).  That silent
2136        // loss-of-penalty is a discontinuous function of ρ (it flips as the noise
2137        // floor crosses `d_min`), so it injects spurious cliffs into the REML/LAML
2138        // objective and a false low-cost basin in the high-λ corner — which the
2139        // outer optimizer then lands in for some training-row orders but not others
2140        // (the row-order-dependent EDF/SE of #2123).
2141        //
2142        // The SVD operates on `E` directly, so `σ_min(E) = √d_min` is resolved
2143        // whenever `√λ_min·σ ≳ ε·√λ_max·σ` — a λ dynamic range up to ~1e32 instead
2144        // of ~1e16 — keeping the reparameterized penalty faithful across the whole ρ
2145        // box and the outer objective smooth and permutation-invariant.  `EᵀE`
2146        // equals the previous `range_block` bit-for-bit, so well-conditioned fits
2147        // are unchanged; only the ill-conditioned corner is corrected.
2148        //
2149        // As before, the right singular vectors `V` (= the penalized-block rotation)
2150        // are used ONLY to build `E`/`S⁺`/traces below; they are NOT applied to
2151        // `q_pen` or `rs_transformed`, so `Q_s` stays λ-independent and the
2152        // quasi-Newton coordinate system does not drift at eigenvalue crossings.
2153        let total_root_rows: usize = rs_transformed.iter().map(Mat::nrows).sum();
2154        // Thin SVD yields a COMPLETE orthonormal `V` (all `penalized_rank`
2155        // directions, including exactly-zero σ) only when `E` is tall or square
2156        // (`total_root_rows ≥ penalized_rank`).  That always holds structurally —
2157        // the union of the penalty root ranges spans the penalized subspace — but a
2158        // pathological degenerate layout is handled by falling back to the Gram
2159        // eigendecomposition so `range_rotation` is never left rank-deficient.
2160        if total_root_rows >= penalized_rank {
2161            let mut e_stacked = Array2::<f64>::zeros((total_root_rows, penalized_rank));
2162            let mut row_off = 0usize;
2163            for (lambda, root) in lambdas.iter().zip(rs_transformed.iter()) {
2164                let sqrt_lambda = lambda.max(0.0).sqrt();
2165                let rk = root.nrows();
2166                for r in 0..rk {
2167                    for c in 0..penalized_rank {
2168                        e_stacked[[row_off + r, c]] = sqrt_lambda * root[(r, c)];
2169                    }
2170                }
2171                row_off += rk;
2172            }
2173            let (_, singular_values, vt_opt) = e_stacked.svd(false, true).map_err(|e| {
2174                EstimationError::LayoutError(format!("penalized-block root SVD failed: {e:?}"))
2175            })?;
2176            let vt = vt_opt.ok_or_else(|| {
2177                EstimationError::LayoutError(
2178                    "penalized-block root SVD did not return right singular vectors".to_string(),
2179                )
2180            })?;
2181            // `singular_values` descending → eigenvalues `d_i = σ_i²` descending;
2182            // `vt` is `penalized_rank × penalized_rank` with row i = vᵢᵀ.
2183            let n_sv = singular_values.len().min(penalized_rank).min(vt.nrows());
2184            range_eigenvalues_sorted = (0..penalized_rank)
2185                .map(|i| {
2186                    if i < n_sv {
2187                        let s = singular_values[i];
2188                        s * s
2189                    } else {
2190                        0.0
2191                    }
2192                })
2193                .collect();
2194            for col_idx in 0..n_sv {
2195                for row in 0..penalized_rank {
2196                    range_rotation[(row, col_idx)] = vt[[col_idx, row]];
2197                }
2198            }
2199        } else {
2200            // Defensive fallback (structurally unreachable): assemble the Gram and
2201            // eigendecompose it. Loses the SVD's small-eigenvalue accuracy, but only
2202            // reached if the penalty roots cannot span the penalized subspace.
2203            let mut range_block = Mat::<f64>::zeros(penalized_rank, penalized_rank);
2204            for (lambda, s_k) in lambdas.iter().zip(s_k_penalized_cache.iter()) {
2205                for i in 0..penalized_rank {
2206                    for j in 0..penalized_rank {
2207                        range_block[(i, j)] += *lambda * s_k[(i, j)];
2208                    }
2209                }
2210            }
2211            let (range_eigenvalues, range_eigenvectors) =
2212                robust_eigh_faer(&range_block, Side::Lower, "range penalty block")?;
2213            let mut range_order: Vec<usize> = (0..penalized_rank).collect();
2214            range_order.sort_by(|&i, &j| {
2215                range_eigenvalues[j]
2216                    .partial_cmp(&range_eigenvalues[i])
2217                    .unwrap_or(std::cmp::Ordering::Equal)
2218                    .then(i.cmp(&j))
2219            });
2220            range_eigenvalues_sorted = range_order
2221                .iter()
2222                .map(|&idx| range_eigenvalues[idx])
2223                .collect();
2224            for (col_idx, &idx) in range_order.iter().enumerate() {
2225                for row in 0..penalized_rank {
2226                    range_rotation[(row, col_idx)] = range_eigenvectors[(row, idx)];
2227                }
2228            }
2229        }
2230    }
2231
2232    // Subspace-invariant penalty spectral calculus:
2233    // - Penalized and null spaces are fixed by the lambda-invariant basis `qs_base`.
2234    // - Runtime lambda dependence only appears in the penalized block eigenvalues.
2235    // This avoids basis mixing inside the degenerate zero-eigenspace.
2236    let structural_rank = penalized_rank;
2237    let mut range_eigs_sorted: Vec<f64> = range_eigenvalues_sorted;
2238    let structurally_penalized_cols = structurally_penalized_columns(penalties, p);
2239
2240    // Shrinkage floor: add a rho-independent ridge to the penalized block eigenvalues.
2241    // This prevents barely-penalized directions from causing pathological non-Gaussianity
2242    // in the posterior (extreme skewness under non-canonical links like logit with
2243    // high-dimensional spatial smooths). The ridge magnitude is proportional to the
2244    // balanced penalty's max eigenvalue (lambda-independent scale), so LAML gradients
2245    // w.r.t. rho remain correct: d(epsilon * I)/d(rho_k) = 0.
2246    //
2247    // The shrinkage ridge is a real prior contribution: it changes the quadratic
2248    // form, the penalty pseudo-logdet, and downstream Hessians. It is currently
2249    // surfaced through `ReparamResult::penalty_shrinkage_ridge` and consumed by
2250    // PIRLS/REML via that per-call channel. The longer-term home is a
2251    // `RidgePassport` with `RidgePolicy::explicit_stabilization_full` scoped to
2252    // the penalized block so the same delta is reflected in serialization, the
2253    // Laplace Hessian, and the prior logdet without callers having to thread an
2254    // additional scalar — once the ridge-ledger plumbing covers per-block
2255    // ScaledIdentity passports.
2256    let shrinkage_ridge = penalty_shrinkage_floor
2257        .filter(|&eps| eps > 0.0)
2258        .map(|eps| eps * invariant.max_balanced_eigenvalue)
2259        .unwrap_or(0.0);
2260    if shrinkage_ridge > 0.0 {
2261        let min_eig_before = range_eigs_sorted
2262            .iter()
2263            .copied()
2264            .fold(f64::INFINITY, f64::min);
2265        let mut shrinkage_floor_applied = 0usize;
2266        for eig_idx in 0..range_eigs_sorted.len() {
2267            let mut penalized_energy = 0.0;
2268            for original_col in 0..p {
2269                if structurally_penalized_cols[original_col] {
2270                    let mut coordinate = 0.0;
2271                    for pen_col in 0..penalized_rank {
2272                        coordinate +=
2273                            q_pen[(original_col, pen_col)] * range_rotation[(pen_col, eig_idx)];
2274                    }
2275                    penalized_energy += coordinate * coordinate;
2276                }
2277            }
2278            if penalized_energy > 1e-8 {
2279                range_eigs_sorted[eig_idx] += shrinkage_ridge;
2280                shrinkage_floor_applied += 1;
2281            }
2282        }
2283        // Log when the floor materially changes the smallest eigenvalue (>1% relative shift).
2284        if min_eig_before > 0.0 && shrinkage_ridge / min_eig_before > 0.01 {
2285            log::debug!(
2286                "Penalty shrinkage floor active: ridge={:.3e} (min_eig_before={:.3e}, ratio={:.1e}, max_bal_eig={:.3e}, applied_dirs={})",
2287                shrinkage_ridge,
2288                min_eig_before,
2289                shrinkage_ridge / min_eig_before,
2290                invariant.max_balanced_eigenvalue,
2291                shrinkage_floor_applied,
2292            );
2293        }
2294    }
2295
2296    let eigenvalue_floor = invariant.max_balanced_eigenvalue.max(1.0) * 1e-12;
2297    let qs = compose_qs_from_split(&q_pen, &q_null, p);
2298
2299    // Guard against any accidental penalized/null mixing. The transformed penalty
2300    // roots must have negligible support on null columns by construction.
2301    let leakage = assess_subspace_leakage(&qs, &rs_transformed, structural_rank, p);
2302    if !subspace_split_is_consistent(&leakage, p) {
2303        return Err(EstimationError::LayoutError(format!(
2304            "Reparameterization subspace split is inconsistent: max null leakage {:.3e} (rel {:.3e}, worst penalty {}), max |Qp'Qn| {:.3e}",
2305            leakage.max_abs_sq.sqrt(),
2306            leakage.max_rel_sq.sqrt(),
2307            leakage.worst_penalty,
2308            leakage.max_cross_gram_abs,
2309        )));
2310    }
2311
2312    // Truncated basis in transformed coordinates:
2313    //   U_⊥^(t) = Qs^T U_⊥^(orig) = Qs^T Q_n.
2314    let mut u_truncated_mat = Mat::<f64>::zeros(p, q_null.ncols());
2315    matmul(
2316        u_truncated_mat.as_mut(),
2317        Accum::Replace,
2318        qs.transpose(),
2319        q_null.as_ref(),
2320        1.0,
2321        Par::Seq,
2322    );
2323
2324    // E is represented in TRANSFORMED coordinates (beta_t).  Because the
2325    // penalized subspace is NOT rotated by the lambda-dependent eigenvectors
2326    // (to keep Q_s stable across BFGS iterations), E is no longer diagonal.
2327    // Instead E = diag(√d) · U' embedded in structural_rank × p, so that
2328    // E'E = U diag(d) U' = Σ λ_k S_k in the invariant penalized basis.
2329    let mut e_transformed_mat = Mat::<f64>::zeros(structural_rank, p);
2330    for row_idx in 0..structural_rank {
2331        let safe_eigenval = range_eigs_sorted[row_idx].max(eigenvalue_floor);
2332        let sqrt_eigenval = safe_eigenval.sqrt();
2333        // E[row, j] = sqrt(d_row) * U'[row, j] = sqrt(d_row) * U[j, row]
2334        for j in 0..penalized_rank {
2335            e_transformed_mat[(row_idx, j)] = sqrt_eigenval * range_rotation[(j, row_idx)];
2336        }
2337    }
2338
2339    // Pseudo-logdet on the structural penalized block.  The null block is split
2340    // out above, so there is no nullspace normalization here.  Spectrally-noisy
2341    // directions (eigenvalues snapped to 0 by `classify_eigenvalues_strict`
2342    // because they fall below the `c * eps_machine * p * scale` tolerance in
2343    // the lambda-weighted sum) are floored to `eigenvalue_floor` to keep the
2344    // log-det finite and consistent with the floored values used to construct
2345    // `e_transformed_mat` above.  This avoids spurious P-IRLS failures when the
2346    // lambda dynamic range is wide (e.g. during BFGS line search probing extreme
2347    // rho candidates).  Materially negative or non-finite spectra are already
2348    // rejected by the strict classifier upstream; this loop re-checks the
2349    // *post-shrinkage* range eigenvalues against the same floor.
2350    //
2351    // The same floored spectrum is used in the trace formula tr(S⁺ S_k) below,
2352    // matching the rank structure embedded in `e_transformed_mat` and avoiding
2353    // a 1/0 in the trace contraction when an eigenvalue was floored to 0.
2354    let mut floored_eigs: Vec<f64> = Vec::with_capacity(range_eigs_sorted.len());
2355    let mut log_det_sum = KahanSum::default();
2356    for (idx, &ev) in range_eigs_sorted.iter().enumerate() {
2357        if !ev.is_finite() || ev < -eigenvalue_floor {
2358            return Err(EstimationError::LayoutError(format!(
2359                "Penalty pseudo-logdet has a non-finite or large-negative structural eigenvalue at index {idx}: {ev:.3e}"
2360            )));
2361        }
2362        let safe_ev = ev.max(eigenvalue_floor);
2363        floored_eigs.push(safe_ev);
2364        if idx < penalized_rank {
2365            log_det_sum.add(safe_ev.ln());
2366        }
2367    }
2368    let log_det = log_det_sum.sum();
2369    let delta = 0.0;
2370
2371    // The det1 contractions are independent once the eigensystem is fixed.  Use
2372    // indexed parallel collection so the output vector preserves lambda order.
2373    let det1vec: Vec<f64> = (0..lambdas.len())
2374        .into_par_iter()
2375        .map(|k| {
2376            let s_k = &s_k_penalized_cache[k];
2377            // Compute tr((S+δI)⁻¹ S_k) in the range eigenbasis without ever
2378            // materializing (S+δI)⁻¹. Using faer's matmul keeps this contraction
2379            // aligned with the orthogonal-similarity debug reference path.
2380            let trace = trace_penalty_in_orthogonal_basis(
2381                s_k,
2382                penalized_rank,
2383                &range_rotation,
2384                &floored_eigs,
2385                delta,
2386            );
2387            lambdas[k] * trace
2388        })
2389        .collect();
2390
2391    {
2392        // Guardrail: cross-check the primary Rayleigh-quotient contraction
2393        // against a full orthogonal similarity transform, while staying in
2394        // the same numerically stable eigenbasis coordinates.
2395        let mut maxdet1_mismatch = 0.0_f64;
2396        let mut det1_scale = 0.0_f64;
2397        for (k, lambda) in lambdas.iter().enumerate() {
2398            let s_k_penalized = &s_k_penalized_cache[k];
2399            let s_k_eigenbasis = orthogonal_similarity_transform_faer(
2400                s_k_penalized,
2401                penalized_rank,
2402                &range_rotation,
2403            );
2404            let mut trace = KahanSum::default();
2405            for l in 0..penalized_rank {
2406                trace.add(s_k_eigenbasis[(l, l)] / (floored_eigs[l] + delta));
2407            }
2408            let reference = *lambda * trace.sum();
2409            maxdet1_mismatch = maxdet1_mismatch.max((reference - det1vec[k]).abs());
2410            det1_scale = det1_scale.max(reference.abs()).max(det1vec[k].abs());
2411        }
2412        let det1_tolerance = 1e-7 * det1_scale.max(1.0);
2413        assert!(
2414            maxdet1_mismatch <= det1_tolerance,
2415            "det1 mismatch between optimized and reference formulas: max_abs={maxdet1_mismatch:.3e}, tol={det1_tolerance:.3e}"
2416        );
2417    }
2418
2419    // Rebuild s_transformed from e_transformed to ensure rank consistency.
2420    //
2421    // The sum of λ*S_k may contain numerical noise modes (eigenvalues ~1e-15) that
2422    // become significant when λ is large (e.g., 10^12). These modes would appear in H
2423    // but are truncated from log|S|_+, creating a "phantom penalty" in the objective.
2424    //
2425    // By reconstructing s_transformed = E^T * E, we force the penalty matrix used
2426    // in H to have the EXACT same rank structure as the one used for log|S|_+.
2427    // Any mode truncated from the prior is now strictly zero in the Hessian
2428    // calculation, ensuring mathematical consistency of the gradients.
2429    let mut s_truncated = Mat::<f64>::zeros(p, p);
2430    matmul(
2431        s_truncated.as_mut(),
2432        Accum::Replace,
2433        e_transformed_mat.transpose(),
2434        e_transformed_mat.as_ref(),
2435        1.0,
2436        Par::Seq,
2437    );
2438
2439    {
2440        // Structural check: transformed S must not leak into declared null coordinates.
2441        let mut max_null_diag = 0.0_f64;
2442        let mut max_null_offdiag = 0.0_f64;
2443        for i in structural_rank..p {
2444            max_null_diag = max_null_diag.max(s_truncated[(i, i)].abs());
2445            for j in 0..p {
2446                if i != j {
2447                    max_null_offdiag = max_null_offdiag.max(s_truncated[(i, j)].abs());
2448                }
2449            }
2450        }
2451        assert!(
2452            max_null_diag <= 1e-10 && max_null_offdiag <= 1e-10,
2453            "null-space leakage in transformed penalty: max_null_diag={max_null_diag:.3e}, max_null_offdiag={max_null_offdiag:.3e}"
2454        );
2455    }
2456
2457    let qs_array = mat_to_array(&qs);
2458    let canonical_transformed: Vec<CanonicalPenalty> = rs_transformed
2459        .par_iter()
2460        .zip(penalties.par_iter())
2461        .map(|(r, cp)| {
2462            let mean_transformed = qs_array.t().dot(&cp.full_width_prior_mean());
2463            CanonicalPenalty::from_dense_root_with_mean(mat_to_array(r), p, mean_transformed)
2464        })
2465        .collect();
2466    Ok(ReparamResult {
2467        s_transformed: mat_to_array(&s_truncated),
2468        log_det,
2469        det1: Array1::from(det1vec),
2470        qs: qs_array,
2471        canonical_transformed,
2472        e_transformed: mat_to_array(&e_transformed_mat),
2473        u_truncated: mat_to_array(&u_truncated_mat),
2474        penalty_shrinkage_ridge: shrinkage_ridge,
2475    })
2476}
2477
2478/// Minimal engine layout descriptor that avoids domain-specific layout coupling.
2479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2480pub struct EngineDims {
2481    pub p: usize,
2482    pub k: usize,
2483}
2484
2485impl EngineDims {
2486    pub fn new(p: usize, k: usize) -> Self {
2487        Self { p, k }
2488    }
2489}
2490
2491/// Engine-facing stable reparameterization API using only `(p, k)`.
2492///
2493/// When `cached_invariant` is `Some`, reuses the precomputed eigendecomposition
2494/// (the hot path inside the REML loop). When `None`, computes the invariant on
2495/// the fly (the post-REML refit path). Merging both cases into a single entry
2496/// point ensures `penalty_shrinkage_floor` is always applied regardless of
2497/// whether a cached invariant is available.
2498/// Stable reparameterization from block-local canonical penalties.
2499pub fn stable_reparameterization_engine_canonical(
2500    penalties: &[CanonicalPenalty],
2501    lambdas: &[f64],
2502    dims: EngineDims,
2503    cached_invariant: Option<&ReparamInvariant>,
2504    penalty_shrinkage_floor: Option<f64>,
2505) -> Result<ReparamResult, EstimationError> {
2506    let owned;
2507    let invariant = match cached_invariant {
2508        Some(inv) => inv,
2509        None => {
2510            owned = precompute_reparam_invariant_from_canonical(penalties, dims.p)?;
2511            &owned
2512        }
2513    };
2514    stable_reparameterizationwith_invariant(
2515        penalties,
2516        lambdas,
2517        dims.p,
2518        invariant,
2519        penalty_shrinkage_floor,
2520    )
2521}
2522
2523// ---------------------------------------------------------------------------
2524// Kronecker-factored reparameterization for tensor-product smooths
2525// ---------------------------------------------------------------------------
2526
2527/// Result of Kronecker-factored reparameterization.
2528///
2529/// Exploits the fact that for Kronecker-structured penalties, the joint
2530/// eigenvector matrix is `U_1 ⊗ ... ⊗ U_d` and the reparameterized design
2531/// is a rowwise Kronecker of `(B_k U_k)` — all remaining factored.
2532#[derive(Clone)]
2533pub struct KroneckerReparamResult {
2534    /// Reparameterized marginal designs: `B_k · U_k` for each marginal k.
2535    ///
2536    /// `Arc`-shared with the λ-invariant cache so the per-outer-iterate
2537    /// memoized engine bumps a refcount instead of deep-copying the
2538    /// (n × q) reparameterized marginals every call.
2539    pub reparameterized_marginals: Arc<Vec<Array2<f64>>>,
2540    /// Marginal eigenvalues from each marginal penalty eigendecomposition.
2541    pub marginal_eigenvalues: Arc<Vec<Array1<f64>>>,
2542    /// Marginal eigenvector matrices U_k.
2543    pub marginal_qs: Arc<Vec<Array2<f64>>>,
2544    /// log|S|₊ computed from marginal eigenvalue grid.
2545    pub log_det: f64,
2546    /// First derivatives of log|S|₊ w.r.t. ρ_k = log(λ_k).
2547    pub det1: Array1<f64>,
2548    /// Second derivatives of log|S|₊ w.r.t. ρ.
2549    pub det2: Array2<f64>,
2550    /// Shrinkage ridge added to eigenvalues (if any).
2551    pub penalty_shrinkage_ridge: f64,
2552    /// Whether a double penalty (global ridge) is present.
2553    pub has_double_penalty: bool,
2554    /// Marginal basis dimensions.
2555    pub marginal_dims: Vec<usize>,
2556}
2557
2558impl KroneckerReparamResult {
2559    /// Materialize the joint Qs matrix (U_1 ⊗ ... ⊗ U_d) as dense p×p.
2560    /// Only for fallback paths — avoid in hot loops.
2561    pub fn materialize_qs(&self) -> Array2<f64> {
2562        let mut qs = Array2::<f64>::eye(1);
2563        for u_k in self.marginal_qs.iter() {
2564            qs = kronecker_product(&qs, u_k);
2565        }
2566        qs
2567    }
2568
2569    /// Materialize s_transformed (the penalty in the reparameterized basis).
2570    /// In the eigenbasis, this is diagonal with entries Σ_k λ_k μ_{k,j_k}.
2571    pub fn materialize_s_transformed(&self, lambdas: &[f64]) -> Array2<f64> {
2572        let d = self.marginal_dims.len();
2573        let p: usize = self.marginal_dims.iter().copied().product();
2574        let mut s = Array2::<f64>::zeros((p, p));
2575
2576        // Delegate the per-cell tensor-penalty accumulation to the shared
2577        // `kronecker_cell_sigma` (#1172/#1185 single source of truth). Fold the
2578        // `lambdas.len() > d` guard into `has_double` to preserve exact gating.
2579        let eigenvalue_views: Vec<ArrayView1<'_, f64>> =
2580            self.marginal_eigenvalues.iter().map(|m| m.view()).collect();
2581        let has_double = self.has_double_penalty && lambdas.len() > d;
2582        let mut multi_idx = vec![0usize; d];
2583        let mut flat = 0usize;
2584        loop {
2585            let (sigma, _structural_sigma, _joint_null) = kronecker_cell_sigma(
2586                &eigenvalue_views,
2587                &multi_idx,
2588                lambdas,
2589                d,
2590                has_double,
2591                self.penalty_shrinkage_ridge,
2592            );
2593            s[[flat, flat]] = sigma;
2594            flat += 1;
2595
2596            if kronecker_multi_index_advance(&mut multi_idx, &self.marginal_dims) {
2597                break;
2598            }
2599        }
2600        s
2601    }
2602
2603    /// Explicitly materialize the dense artifact bundle expected by legacy
2604    /// downstream consumers. This is not part of the native Kronecker solve path.
2605    pub fn materialize_dense_artifact_result(
2606        &self,
2607        rs_list: &[Array2<f64>],
2608        lambdas: &[f64],
2609        p: usize,
2610    ) -> Result<ReparamResult, EstimationError> {
2611        const KRONECKER_DENSE_COMPAT_FALLBACK_MAX_P: usize = 4096;
2612        if p > KRONECKER_DENSE_COMPAT_FALLBACK_MAX_P {
2613            return Err(EstimationError::LayoutError(format!(
2614                "Kronecker reparameterization would materialize dense {}x{} compatibility tensors; \
2615                 large-model dense fallback is disabled. Wire the downstream solver to consume \
2616                 the factored Kronecker result directly",
2617                p, p
2618            )));
2619        }
2620        let qs = self.materialize_qs();
2621        let s_transformed = self.materialize_s_transformed(lambdas);
2622
2623        // Transform penalty roots: R_k_transformed = R_k · Qs
2624        let rs_transformed: Vec<Array2<f64>> = if rs_list.len() >= 2 {
2625            use rayon::prelude::*;
2626            rs_list
2627                .par_iter()
2628                .map(|r| gam_linalg::faer_ndarray::fast_ab(r, &qs))
2629                .collect()
2630        } else {
2631            rs_list
2632                .iter()
2633                .map(|r| gam_linalg::faer_ndarray::fast_ab(r, &qs))
2634                .collect()
2635        };
2636        // rs_transposed removed — canonical_transformed is the single source of truth.
2637
2638        // Build e_transformed: combined penalty square root in transformed coords.
2639        // For Kronecker structure, the penalty is diagonal in the eigenbasis.
2640        // e_transformed rows are the nonzero rows of sqrt(Σ_k λ_k S_k)^{1/2}.
2641        let d = self.marginal_dims.len();
2642        // Delegate the per-cell tensor-penalty accumulation to the shared
2643        // `kronecker_cell_sigma` (the #1172/#1185 single source of truth). The
2644        // double-penalty term is only valid when `lambdas` actually carries the
2645        // λ_d entry, so fold the original `lambdas.len() > d` guard into the
2646        // `has_double_penalty` flag passed to the helper — preserving the exact
2647        // gating behavior.
2648        let eigenvalue_views: Vec<ArrayView1<'_, f64>> =
2649            self.marginal_eigenvalues.iter().map(|m| m.view()).collect();
2650        let has_double = self.has_double_penalty && lambdas.len() > d;
2651        let diag_vals: Vec<f64> = {
2652            let mut vals = Vec::with_capacity(p);
2653            let mut multi_idx = vec![0usize; d];
2654            loop {
2655                let (sigma, _structural_sigma, _joint_null) = kronecker_cell_sigma(
2656                    &eigenvalue_views,
2657                    &multi_idx,
2658                    lambdas,
2659                    d,
2660                    has_double,
2661                    self.penalty_shrinkage_ridge,
2662                );
2663                vals.push(if sigma > 0.0 { sigma.sqrt() } else { 0.0 });
2664
2665                if kronecker_multi_index_advance(&mut multi_idx, &self.marginal_dims) {
2666                    break;
2667                }
2668            }
2669            vals
2670        };
2671        let rank = diag_vals.iter().filter(|&&v| v > 1e-12).count();
2672        let mut e_transformed = Array2::<f64>::zeros((rank, p));
2673        let mut row = 0;
2674        for (j, &v) in diag_vals.iter().enumerate() {
2675            if v > 1e-12 {
2676                e_transformed[[row, j]] = v;
2677                row += 1;
2678            }
2679        }
2680
2681        // u_truncated: null-space eigenvectors (columns with zero eigenvalue).
2682        let null_count = p - rank;
2683        let mut u_truncated = Array2::<f64>::zeros((p, null_count));
2684        let mut col = 0;
2685        for (j, &v) in diag_vals.iter().enumerate() {
2686            if v <= 1e-12 {
2687                u_truncated[[j, col]] = 1.0; // standard basis vector in eigenbasis
2688                col += 1;
2689            }
2690        }
2691
2692        let canonical_transformed: Vec<CanonicalPenalty> = rs_transformed
2693            .iter()
2694            .map(|r| CanonicalPenalty::from_dense_root(r.clone(), p))
2695            .collect();
2696        Ok(ReparamResult {
2697            s_transformed,
2698            log_det: self.log_det,
2699            det1: self.det1.clone(),
2700            qs,
2701            canonical_transformed,
2702            e_transformed,
2703            u_truncated,
2704            penalty_shrinkage_ridge: self.penalty_shrinkage_ridge,
2705        })
2706    }
2707}
2708
2709/// Compute `log|S|₊` and its first/second derivatives w.r.t. `ρ_k = log(λ_k)`
2710/// from factored marginal eigenvalues.
2711///
2712/// Shared implementation for `KroneckerPenaltySystem::logdet_and_derivatives`
2713/// and `kronecker_reparameterization_engine`.  Iterates over the ∏q_j
2714/// multi-index grid in O(d · ∏q_j) time with no O(p²) storage.
2715const KRONECKER_STRUCTURAL_ZERO_TOL: f64 = 1e-12;
2716
2717/// Per-cell Kronecker eigenvalue accumulation — the single source of truth for
2718/// the #1172/#1185 tensor-penalty math.
2719///
2720/// For the multi-index cell `multi_idx`, accumulates:
2721///   - `sigma`            = Σ_k λ_k · μ_k  (+ joint-null double-penalty term + ridge)
2722///   - `structural_sigma` = Σ_k μ_k        (unweighted; classifies joint-null cells)
2723///   - `joint_null`       = whether the cell lies in the joint null space
2724///
2725/// `marginal_eigenvalues[k][multi_idx[k]]` is the k-th marginal eigenvalue μ_k.
2726/// The double-penalty (global ridge) term `λ_d` is added only on joint-null
2727/// cells; the structural shrinkage `ridge` is added only on structurally
2728/// penalized cells. This mirrors the gated logic fixed in #1172/#1185 and MUST
2729/// be kept identical across every caller.
2730#[inline]
2731fn kronecker_cell_sigma(
2732    marginal_eigenvalues: &[ArrayView1<'_, f64>],
2733    multi_idx: &[usize],
2734    lambdas: &[f64],
2735    d: usize,
2736    has_double_penalty: bool,
2737    ridge: f64,
2738) -> (f64, f64, bool) {
2739    let mut sigma = 0.0;
2740    let mut structural_sigma = 0.0;
2741    for k in 0..d {
2742        let marginal_eigenvalue = marginal_eigenvalues[k][multi_idx[k]];
2743        structural_sigma += marginal_eigenvalue;
2744        sigma += lambdas[k] * marginal_eigenvalue;
2745    }
2746    let joint_null = structural_sigma <= KRONECKER_STRUCTURAL_ZERO_TOL;
2747    if has_double_penalty && joint_null {
2748        sigma += lambdas[d];
2749    }
2750    if structural_sigma > KRONECKER_STRUCTURAL_ZERO_TOL {
2751        sigma += ridge;
2752    }
2753    (sigma, structural_sigma, joint_null)
2754}
2755
2756/// Advance a row-major multi-index over the `dims` grid in place.
2757/// Returns `true` when the grid is exhausted (the index wrapped back to all-zero).
2758#[inline]
2759fn kronecker_multi_index_advance(multi_idx: &mut [usize], dims: &[usize]) -> bool {
2760    let mut carry = true;
2761    for dim in (0..dims.len()).rev() {
2762        if carry {
2763            multi_idx[dim] += 1;
2764            if multi_idx[dim] < dims[dim] {
2765                carry = false;
2766            } else {
2767                multi_idx[dim] = 0;
2768            }
2769        }
2770    }
2771    carry
2772}
2773
2774pub fn kronecker_logdet_and_derivatives(
2775    marginal_eigenvalues: &[ArrayView1<'_, f64>],
2776    marginal_dims: &[usize],
2777    lambdas: &[f64],
2778    has_double_penalty: bool,
2779    ridge: f64,
2780) -> (f64, Array1<f64>, Array2<f64>) {
2781    let d = marginal_dims.len();
2782    let n_pen = d + if has_double_penalty { 1 } else { 0 };
2783
2784    let mut logdet = 0.0;
2785    let mut grad = Array1::<f64>::zeros(n_pen);
2786    let mut hess = Array2::<f64>::zeros((n_pen, n_pen));
2787    let tol = 1e-12;
2788
2789    let mut multi_idx = vec![0usize; d];
2790    loop {
2791        let (sigma, _structural_sigma, joint_null) = kronecker_cell_sigma(
2792            marginal_eigenvalues,
2793            &multi_idx,
2794            lambdas,
2795            d,
2796            has_double_penalty,
2797            ridge,
2798        );
2799
2800        if sigma > tol {
2801            logdet += sigma.ln();
2802            let inv_sigma = 1.0 / sigma;
2803            let inv_sigma2 = inv_sigma * inv_sigma;
2804
2805            for k in 0..d {
2806                let ck = lambdas[k] * marginal_eigenvalues[k][multi_idx[k]];
2807                grad[k] += ck * inv_sigma;
2808            }
2809            if has_double_penalty && joint_null {
2810                grad[d] += lambdas[d] * inv_sigma;
2811            }
2812
2813            for k in 0..n_pen {
2814                let ck = if k < d {
2815                    lambdas[k] * marginal_eigenvalues[k][multi_idx[k]]
2816                } else if joint_null {
2817                    lambdas[d]
2818                } else {
2819                    0.0
2820                };
2821                // When ck == 0 (a zero λ, a zero marginal eigenvalue, or a cell
2822                // outside the joint null for the ridge penalty) every term this
2823                // index k contributes — `ck·inv_sigma − ck²·inv_sigma2` on the
2824                // diagonal and `−ck·cl·inv_sigma2` on every off-diagonal — is
2825                // exactly 0.0, so adding them to the finite running accumulators
2826                // is a bit-identical no-op. Skip the inner sweep entirely.
2827                if ck == 0.0 {
2828                    continue;
2829                }
2830                hess[[k, k]] += ck * inv_sigma - ck * ck * inv_sigma2;
2831                for l in (k + 1)..n_pen {
2832                    let cl = if l < d {
2833                        lambdas[l] * marginal_eigenvalues[l][multi_idx[l]]
2834                    } else if joint_null {
2835                        lambdas[d]
2836                    } else {
2837                        0.0
2838                    };
2839                    let off = -ck * cl * inv_sigma2;
2840                    hess[[k, l]] += off;
2841                    hess[[l, k]] += off;
2842                }
2843            }
2844        }
2845
2846        if kronecker_multi_index_advance(&mut multi_idx, marginal_dims) {
2847            break;
2848        }
2849    }
2850
2851    (logdet, grad, hess)
2852}
2853
2854// #1521: `KroneckerInvariantStructure` is defined once in `crate::kronecker`
2855// (the leaf data+compute module). The byte-identical copy that the carve left
2856// here is replaced by an import so the cache and this engine share one type.
2857use crate::kronecker::KroneckerInvariantStructure;
2858
2859/// Kronecker-factored reparameterization for tensor-product penalties.
2860///
2861/// Instead of eigendecomposing the full p×p balanced penalty (O(p³)), this
2862/// eigendecomposes each marginal penalty separately (O(Σ q_k³)) and computes
2863/// the joint eigensystem as the Kronecker product of marginal eigensystems.
2864pub fn kronecker_reparameterization_engine(
2865    marginal_designs: &[Array2<f64>],
2866    marginal_penalties: &[Array2<f64>],
2867    marginal_dims: &[usize],
2868    lambdas: &[f64],
2869    has_double_penalty: bool,
2870    penalty_shrinkage_floor: Option<f64>,
2871) -> Result<KroneckerReparamResult, EstimationError> {
2872    let d = marginal_dims.len();
2873    if marginal_designs.len() != d || marginal_penalties.len() != d {
2874        return Err(EstimationError::LayoutError(format!(
2875            "kronecker_reparameterization_engine: dimension mismatch: designs={}, penalties={}, dims={}",
2876            marginal_designs.len(),
2877            marginal_penalties.len(),
2878            d
2879        )));
2880    }
2881
2882    let invariant =
2883        KroneckerInvariantStructure::compute(marginal_designs, marginal_penalties, marginal_dims)?;
2884    kronecker_reparameterization_engine_with_invariant(
2885        &invariant,
2886        marginal_dims,
2887        lambdas,
2888        has_double_penalty,
2889        penalty_shrinkage_floor,
2890    )
2891}
2892
2893/// Kronecker-factored reparameterization reusing a precomputed λ-invariant
2894/// structure (eigensystems, reparameterized marginals, shrinkage scale).
2895///
2896/// Bit-identical to `kronecker_reparameterization_engine` for the same marginal
2897/// data — the only difference is that the `eigh()` / `B_k U_k` work was hoisted
2898/// out of the per-iterate path into the cached `invariant`. Only the λ-dependent
2899/// `kronecker_logdet_and_derivatives` sweep and `floor * max_bal` scaling run here.
2900pub fn kronecker_reparameterization_engine_with_invariant(
2901    invariant: &KroneckerInvariantStructure,
2902    marginal_dims: &[usize],
2903    lambdas: &[f64],
2904    has_double_penalty: bool,
2905    penalty_shrinkage_floor: Option<f64>,
2906) -> Result<KroneckerReparamResult, EstimationError> {
2907    // Arc refcount bumps — the underlying eigensystems / reparameterized
2908    // marginals are λ-invariant and shared with the cache, not deep-copied.
2909    let marginal_eigenvalues = Arc::clone(&invariant.marginal_eigenvalues);
2910    let marginal_qs = Arc::clone(&invariant.marginal_qs);
2911    let reparameterized_marginals = Arc::clone(&invariant.reparameterized_marginals);
2912
2913    // Compute shrinkage ridge from balanced penalty eigenvalue scale.
2914    let penalty_shrinkage_ridge = if let Some(floor) = penalty_shrinkage_floor {
2915        floor * invariant.max_balanced_eigenvalue
2916    } else {
2917        0.0
2918    };
2919
2920    let marginal_eigenvalue_views: Vec<_> = marginal_eigenvalues
2921        .iter()
2922        .map(|evals| evals.view())
2923        .collect();
2924    let (log_det, det1, det2) = kronecker_logdet_and_derivatives(
2925        &marginal_eigenvalue_views,
2926        marginal_dims,
2927        lambdas,
2928        has_double_penalty,
2929        penalty_shrinkage_ridge,
2930    );
2931
2932    Ok(KroneckerReparamResult {
2933        reparameterized_marginals,
2934        marginal_eigenvalues,
2935        marginal_qs,
2936        log_det,
2937        det1,
2938        det2,
2939        penalty_shrinkage_ridge,
2940        has_double_penalty,
2941        marginal_dims: marginal_dims.to_vec(),
2942    })
2943}
2944
2945#[cfg(test)]
2946mod tests {
2947    use super::{
2948        CanonicalPenalty, REL_PSD_FLOOR, SubspaceLeakageMetrics, assess_subspace_leakage,
2949        classify_eigenvalues_strict, precompute_reparam_invariant_from_canonical,
2950        report_penalty_pair_redundancy, stable_reparameterizationwith_invariant,
2951        subspace_split_is_consistent,
2952    };
2953    use crate::EstimationError;
2954    use crate::construction::kronecker_product;
2955    use faer::Mat;
2956    use gam_linalg::faer_ndarray::FaerEigh;
2957    use gam_linalg::utils::inf_norm;
2958    use ndarray::{Array1, Array2, array};
2959
2960    /// Build CanonicalPenalty values from full-width roots for tests.
2961    fn canonical_from_roots(rs_list: &[Array2<f64>], p: usize) -> Vec<CanonicalPenalty> {
2962        rs_list
2963            .iter()
2964            .map(|r| {
2965                let local = r.t().dot(r);
2966                CanonicalPenalty {
2967                    root: r.clone(),
2968                    col_range: 0..p,
2969                    total_dim: p,
2970                    nullity: 0,
2971                    local,
2972                    prior_mean: Array1::zeros(p),
2973                    positive_eigenvalues: Vec::new(),
2974                    op: None,
2975                }
2976            })
2977            .collect()
2978    }
2979
2980    fn metrics_for(
2981        qs: &Mat<f64>,
2982        rs: &[Mat<f64>],
2983        structural_rank: usize,
2984        p: usize,
2985    ) -> SubspaceLeakageMetrics {
2986        assess_subspace_leakage(qs, rs, structural_rank, p)
2987    }
2988
2989    #[test]
2990    fn subspace_leakage_iszero_for_clean_split() {
2991        let p = 4usize;
2992        let structural_rank = 2usize;
2993        let qs = Mat::<f64>::identity(p, p);
2994        let mut r0 = Mat::<f64>::zeros(2, p);
2995        r0[(0, 0)] = 1.0;
2996        r0[(1, 1)] = 2.0;
2997
2998        let m = metrics_for(&qs, &[r0], structural_rank, p);
2999        assert!(m.max_abs_sq <= 1e-16);
3000        assert!(m.max_rel_sq <= 1e-16);
3001        assert!(m.max_cross_gram_abs <= 1e-16);
3002    }
3003
3004    #[test]
3005    fn subspace_leakage_detects_null_column_energy() {
3006        let p = 4usize;
3007        let structural_rank = 2usize;
3008        let qs = Mat::<f64>::identity(p, p);
3009        let mut r0 = Mat::<f64>::zeros(1, p);
3010        r0[(0, 2)] = 3.0;
3011
3012        let m = metrics_for(&qs, &[r0], structural_rank, p);
3013        assert!(m.max_abs_sq > 0.0);
3014        assert!(m.max_rel_sq > 0.99);
3015    }
3016
3017    #[test]
3018    fn subspace_leakage_detects_qp_qn_nonorthogonality() {
3019        let p = 3usize;
3020        let structural_rank = 1usize;
3021        let mut qs = Mat::<f64>::identity(p, p);
3022        qs[(0, 1)] = 0.2;
3023        let r0 = Mat::<f64>::zeros(1, p);
3024
3025        let m = metrics_for(&qs, &[r0], structural_rank, p);
3026        assert!(m.max_cross_gram_abs > 1e-3);
3027    }
3028
3029    #[test]
3030    fn subspace_split_admits_near_threshold_manifold_leakage_1802() {
3031        // #1802: on sphere / Duchon / spline-on-sphere bases the REML outer
3032        // startup rejected EVERY candidate seed with
3033        //   "Reparameterization subspace split is inconsistent:
3034        //    max null leakage 1.174e-4 (rel 1.031e-4, worst penalty 0),
3035        //    max |Qp'Qn| 4.316e-16"
3036        // The split is perfectly ORTHOGONAL (|Qp'Qn| ≈ 4e-16); the only tripped
3037        // quantity is the transformed-root null-block leakage, whose RELATIVE
3038        // energy (1.031e-4)² ≈ 1.06e-8 sits right at `REL_PSD_FLOOR`. That is the
3039        // eigensolver's own numerically-PSD floor — the same floor
3040        // `classify_eigenvalues_strict` uses to declare a mode null — so a
3041        // manifold penalty whose Laplace-Beltrami spectrum decays through the
3042        // rank threshold with no clean gap MUST be admitted. Reproduce that exact
3043        // signature and assert the guard accepts it.
3044        let p = 40usize;
3045        let structural_rank = p - 1;
3046        // Unit-amplitude range column + a null column at √(REL_PSD_FLOOR)
3047        // amplitude, so the null-block relative energy lands at ~REL_PSD_FLOOR.
3048        let null_amp = (1.06e-8_f64).sqrt();
3049        let mut rs = Mat::<f64>::zeros(1, p);
3050        rs[(0, 0)] = 1.0;
3051        rs[(0, p - 1)] = null_amp;
3052        let qs = Mat::<f64>::identity(p, p);
3053        let leakage = metrics_for(&qs, &[rs], structural_rank, p);
3054        // The leakage reproduces the observed band: above the OLD fixed 1e-10
3055        // tolerance (which rejected the sphere fit) yet a benign ~REL_PSD_FLOOR.
3056        assert!(
3057            leakage.max_rel_sq > 1e-10 && leakage.max_rel_sq < 1e-6,
3058            "reproduced leakage should sit in the near-REL_PSD_FLOOR band, got {:.3e}",
3059            leakage.max_rel_sq
3060        );
3061        assert!(leakage.max_cross_gram_abs <= 1e-12);
3062        assert!(
3063            subspace_split_is_consistent(&leakage, p),
3064            "near-REL_PSD_FLOOR null leakage on a manifold basis must be admitted \
3065             (rel_sq={:.3e}, tol={:.3e})",
3066            leakage.max_rel_sq,
3067            (p as f64) * REL_PSD_FLOOR,
3068        );
3069    }
3070
3071    #[test]
3072    fn subspace_split_still_rejects_genuine_inconsistency_1802() {
3073        // The #1802 relaxation only widens the leakage tolerance to the
3074        // classifier's own `p · REL_PSD_FLOOR` floor; it must NOT admit a
3075        // genuinely broken split. A whole penalized mode dumped into the null
3076        // block (O(1) relative leakage) is still rejected.
3077        let p = 40usize;
3078        let structural_rank = p - 1;
3079        let mut rs = Mat::<f64>::zeros(1, p);
3080        rs[(0, p - 1)] = 1.0; // all penalty-root energy in the null column
3081        let qs = Mat::<f64>::identity(p, p);
3082        let leakage = metrics_for(&qs, &[rs], structural_rank, p);
3083        assert!(leakage.max_rel_sq > 0.99);
3084        assert!(
3085            !subspace_split_is_consistent(&leakage, p),
3086            "an O(1) null-block leakage is a real inconsistency and must be rejected"
3087        );
3088
3089        // A non-orthogonal split is rejected regardless of root leakage.
3090        let mut qs_bad = Mat::<f64>::identity(3, 3);
3091        qs_bad[(0, 1)] = 0.2;
3092        let clean = Mat::<f64>::zeros(1, 3);
3093        let leakage2 = metrics_for(&qs_bad, &[clean], 1, 3);
3094        assert!(leakage2.max_cross_gram_abs > 1e-3);
3095        assert!(
3096            !subspace_split_is_consistent(&leakage2, 3),
3097            "a non-orthogonal Qp/Qn split must be rejected"
3098        );
3099    }
3100
3101    #[test]
3102    fn u_truncated_is_transformed_frame_in_nonzero_case() {
3103        let p = 3usize;
3104        let rs_list = vec![array![[1.0, 0.0, 0.0]]];
3105        let canonical = canonical_from_roots(&rs_list, p);
3106        let lambdas = vec![2.0];
3107        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3108            .expect("precompute invariant");
3109        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3110            .expect("stable reparam");
3111
3112        let expected = rep.qs.t().dot(&inv.split.q_null);
3113        let diff = &rep.u_truncated - &expected;
3114        let max_abs = inf_norm(diff.iter().copied());
3115        assert!(
3116            max_abs <= 1e-10,
3117            "u_truncated frame mismatch: max_abs={max_abs}"
3118        );
3119    }
3120
3121    #[test]
3122    fn infinite_lambda_keeps_range_penalty_block_finite_1379() {
3123        // gam#1379 / gam#1074: a genuinely infinite λ = exp(ρ) is NOT silently
3124        // clamped to a finite ceiling. The original #1379 fix added a 1e300
3125        // ceiling so `∞ · 0` could not poison the range block Σ_k λ_k S_k, but
3126        // #1074 DELETED that clamp on purpose (see the comment at the top of
3127        // `stable_reparameterizationwith_invariant`): masking ∞ hid the real
3128        // defect — the outer optimizer driving a redundant/unidentified penalty
3129        // direction off to ∞ instead of that direction being detected and
3130        // dropped. With the clamp gone, a literal `f64::INFINITY` λ surfaces as
3131        // a clean, detectable error (the eigensolver rejects the NaN-poisoned
3132        // block) rather than a silent finite success. Pin that contract: ∞ must
3133        // ERROR, not be quietly clamped.
3134        //
3135        // Fixture: two penalties on a 3-wide block. The first penalizes only
3136        // coordinate 0 (so its block S_k has structural zeros everywhere except
3137        // [0,0]); give it λ = +∞. The second penalizes coordinate 1 at a normal
3138        // λ.
3139        let p = 3usize;
3140        let rs_list = vec![array![[1.0, 0.0, 0.0]], array![[0.0, 1.0, 0.0]]];
3141        let canonical = canonical_from_roots(&rs_list, p);
3142        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3143            .expect("precompute invariant");
3144
3145        let lambdas_inf = vec![f64::INFINITY, 3.0];
3146        let inf_result =
3147            stable_reparameterizationwith_invariant(&canonical, &lambdas_inf, p, &inv, None);
3148        assert!(
3149            inf_result.is_err(),
3150            "an infinite lambda must surface as an error, not be silently clamped (#1074)"
3151        );
3152
3153        // A finite (even very large) λ must still produce an all-finite reparam:
3154        // the function is robust to large-but-finite penalties; only the
3155        // non-finite input is rejected.
3156        let lambdas_big = vec![1e300_f64, 3.0];
3157        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas_big, p, &inv, None)
3158            .expect("stable reparam at large-but-finite lambda");
3159        assert!(
3160            rep.s_transformed.iter().all(|v| v.is_finite()),
3161            "transformed penalty must be finite at large-but-finite lambda"
3162        );
3163        assert!(
3164            rep.qs.iter().all(|v| v.is_finite()),
3165            "reparam rotation must be finite at large-but-finite lambda"
3166        );
3167        assert!(
3168            rep.log_det.is_finite(),
3169            "penalty log-det must be finite at large-but-finite lambda"
3170        );
3171        assert!(
3172            rep.det1.iter().all(|v| v.is_finite()),
3173            "penalty log-det derivatives must be finite at large-but-finite lambda"
3174        );
3175    }
3176
3177    #[test]
3178    fn u_truncated_is_identitywhen_no_penalties() {
3179        let p = 4usize;
3180        let canonical: Vec<CanonicalPenalty> = Vec::new();
3181        let lambdas: Vec<f64> = Vec::new();
3182        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3183            .expect("precompute invariant");
3184        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3185            .expect("stable reparam");
3186        assert_eq!(rep.u_truncated, Array2::<f64>::eye(p));
3187    }
3188
3189    #[test]
3190    fn dense_shrinkage_floor_skips_structurally_unpenalized_range_columns() {
3191        let p = 3usize;
3192        let canonical = canonical_from_roots(&[array![[1.0, 0.0, 0.0]]], p);
3193        let invariant = super::ReparamInvariant {
3194            split: super::SubspaceSplit {
3195                q_pen: array![[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]],
3196                q_null: array![[0.0], [0.0], [1.0]],
3197            },
3198            qs_base: Array2::eye(p),
3199            has_nonzero: true,
3200            max_balanced_eigenvalue: 1.0,
3201        };
3202
3203        let rep =
3204            stable_reparameterizationwith_invariant(&canonical, &[2.0], p, &invariant, Some(1e-6))
3205                .expect("stable reparameterization");
3206        assert!(rep.s_transformed[[0, 0]] > 2.0);
3207        assert!(
3208            rep.s_transformed[[1, 1]] <= 1e-11,
3209            "structurally unpenalized range coordinate received shrinkage ridge: {}",
3210            rep.s_transformed[[1, 1]]
3211        );
3212    }
3213
3214    #[test]
3215    fn kronecker_shrinkage_floor_preserves_joint_null_space() {
3216        let marginal_designs = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
3217        let marginal_penalties = vec![
3218            array![[0.0, 0.0], [0.0, 2.0]],
3219            array![[0.0, 0.0], [0.0, 3.0]],
3220        ];
3221        let marginal_dims = vec![2usize, 2usize];
3222        let lambdas = vec![5.0, 7.0];
3223
3224        let rep = super::kronecker_reparameterization_engine(
3225            &marginal_designs,
3226            &marginal_penalties,
3227            &marginal_dims,
3228            &lambdas,
3229            false,
3230            Some(1e-6),
3231        )
3232        .expect("kronecker reparameterization");
3233        assert!(rep.penalty_shrinkage_ridge > 0.0);
3234
3235        let s = rep.materialize_s_transformed(&lambdas);
3236        assert!(
3237            s[[0, 0]].abs() <= 1e-14,
3238            "joint tensor null direction must remain unpenalized, got {}",
3239            s[[0, 0]]
3240        );
3241        assert!(s[[1, 1]] > lambdas[1] * 3.0);
3242        assert!(s[[2, 2]] > lambdas[0] * 2.0);
3243        assert!(s[[3, 3]] > lambdas[0] * 2.0 + lambdas[1] * 3.0);
3244
3245        let tensor_roots = vec![
3246            array![
3247                [0.0, 0.0, 2.0_f64.sqrt(), 0.0],
3248                [0.0, 0.0, 0.0, 2.0_f64.sqrt()]
3249            ],
3250            array![
3251                [0.0, 3.0_f64.sqrt(), 0.0, 0.0],
3252                [0.0, 0.0, 0.0, 3.0_f64.sqrt()]
3253            ],
3254        ];
3255        let dense = rep
3256            .materialize_dense_artifact_result(&tensor_roots, &lambdas, 4)
3257            .expect("dense artifact materialization");
3258        assert_eq!(dense.e_transformed.nrows(), 3);
3259        assert_eq!(dense.u_truncated.ncols(), 1);
3260    }
3261
3262    #[test]
3263    fn kronecker_memoized_invariant_is_bit_identical_to_unmemoized_engine() {
3264        // The hot-path memoization (compute the marginal eigensystems /
3265        // reparameterized marginals once, reuse across outer iterates) must
3266        // produce a KroneckerReparamResult that is *bit-identical* to the
3267        // unmemoized engine for the same marginal data and λ — the cached work
3268        // is literally the same eigendecomposition. Cover several λ on one fixed
3269        // invariant structure (the realistic outer-loop pattern).
3270        let marginal_designs = vec![
3271            array![[1.0, 0.3, -0.2], [0.4, 1.0, 0.1], [-0.1, 0.2, 1.0]],
3272            array![[1.0, -0.5], [0.2, 1.0], [0.7, 0.3]],
3273        ];
3274        let marginal_penalties = vec![
3275            array![[2.0, -1.0, 0.0], [-1.0, 2.0, -1.0], [0.0, -1.0, 1.0]],
3276            array![[3.0, -1.5], [-1.5, 3.0]],
3277        ];
3278        let marginal_dims = vec![3usize, 2usize];
3279
3280        let invariant = super::KroneckerInvariantStructure::compute(
3281            &marginal_designs,
3282            &marginal_penalties,
3283            &marginal_dims,
3284        )
3285        .expect("invariant structure");
3286
3287        for lambdas in [
3288            vec![5.0, 7.0],
3289            vec![0.0, 7.0],
3290            vec![5.0, 0.0],
3291            vec![1e-3, 1e3],
3292        ] {
3293            for floor in [None, Some(1e-6)] {
3294                let unmemoized = super::kronecker_reparameterization_engine(
3295                    &marginal_designs,
3296                    &marginal_penalties,
3297                    &marginal_dims,
3298                    &lambdas,
3299                    true,
3300                    floor,
3301                )
3302                .expect("unmemoized engine");
3303                let memoized = super::kronecker_reparameterization_engine_with_invariant(
3304                    &invariant,
3305                    &marginal_dims,
3306                    &lambdas,
3307                    true,
3308                    floor,
3309                )
3310                .expect("memoized engine");
3311
3312                assert_eq!(memoized.log_det.to_bits(), unmemoized.log_det.to_bits());
3313                assert_eq!(
3314                    memoized.penalty_shrinkage_ridge.to_bits(),
3315                    unmemoized.penalty_shrinkage_ridge.to_bits()
3316                );
3317                for (a, b) in memoized.det1.iter().zip(unmemoized.det1.iter()) {
3318                    assert_eq!(a.to_bits(), b.to_bits());
3319                }
3320                for (a, b) in memoized.det2.iter().zip(unmemoized.det2.iter()) {
3321                    assert_eq!(a.to_bits(), b.to_bits());
3322                }
3323                for (ma, ua) in memoized
3324                    .reparameterized_marginals
3325                    .iter()
3326                    .zip(unmemoized.reparameterized_marginals.iter())
3327                {
3328                    for (a, b) in ma.iter().zip(ua.iter()) {
3329                        assert_eq!(a.to_bits(), b.to_bits());
3330                    }
3331                }
3332                for (mq, uq) in memoized
3333                    .marginal_qs
3334                    .iter()
3335                    .zip(unmemoized.marginal_qs.iter())
3336                {
3337                    for (a, b) in mq.iter().zip(uq.iter()) {
3338                        assert_eq!(a.to_bits(), b.to_bits());
3339                    }
3340                }
3341            }
3342        }
3343    }
3344
3345    #[test]
3346    fn kronecker_double_penalty_shrinks_only_joint_null_space() {
3347        let marginal_designs = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
3348        let marginal_penalties = vec![
3349            array![[0.0, 0.0], [0.0, 2.0]],
3350            array![[0.0, 0.0], [0.0, 3.0]],
3351        ];
3352        let marginal_dims = vec![2usize, 2usize];
3353        let lambdas = vec![5.0, 7.0, 11.0];
3354
3355        let rep = super::kronecker_reparameterization_engine(
3356            &marginal_designs,
3357            &marginal_penalties,
3358            &marginal_dims,
3359            &lambdas,
3360            true,
3361            None,
3362        )
3363        .expect("kronecker reparameterization");
3364
3365        let s = rep.materialize_s_transformed(&lambdas);
3366        let expected = [11.0, 21.0, 10.0, 31.0];
3367        for (idx, expected_diag) in expected.iter().copied().enumerate() {
3368            assert!(
3369                (s[[idx, idx]] - expected_diag).abs() <= 1e-12,
3370                "diagonal {idx} got {}, expected {expected_diag}",
3371                s[[idx, idx]]
3372            );
3373        }
3374
3375        let expected_logdet: f64 = expected.iter().map(|v| f64::ln(*v)).sum();
3376        assert!((rep.log_det - expected_logdet).abs() <= 1e-12);
3377        assert!(
3378            (rep.det1[2] - 1.0).abs() <= 1e-12,
3379            "double-penalty derivative must come only from the joint null mode, got {}",
3380            rep.det1[2]
3381        );
3382        assert!(rep.det2[[2, 2]].abs() <= 1e-12);
3383
3384        let tensor_roots = vec![
3385            array![
3386                [0.0, 0.0, 2.0_f64.sqrt(), 0.0],
3387                [0.0, 0.0, 0.0, 2.0_f64.sqrt()]
3388            ],
3389            array![
3390                [0.0, 3.0_f64.sqrt(), 0.0, 0.0],
3391                [0.0, 0.0, 0.0, 3.0_f64.sqrt()]
3392            ],
3393        ];
3394        let dense = rep
3395            .materialize_dense_artifact_result(&tensor_roots, &lambdas, 4)
3396            .expect("dense artifact materialization");
3397        for (idx, expected_diag) in expected.iter().copied().enumerate() {
3398            assert!(
3399                (dense.s_transformed[[idx, idx]] - expected_diag).abs() <= 1e-12,
3400                "dense artifact diagonal {idx} got {}, expected {expected_diag}",
3401                dense.s_transformed[[idx, idx]]
3402            );
3403        }
3404    }
3405
3406    #[test]
3407    fn transformed_penalty_is_diagonal_in_transformed_frame() {
3408        let p = 3usize;
3409        let inv_sqrt2 = 2.0_f64.sqrt().recip();
3410        // Penalize a rotated direction in original space so Qs is non-trivial.
3411        let rs_list = vec![array![[inv_sqrt2, inv_sqrt2, 0.0]]];
3412        let canonical = canonical_from_roots(&rs_list, p);
3413        let lambdas = vec![4.0];
3414        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3415            .expect("precompute invariant");
3416        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3417            .expect("stable reparam");
3418
3419        assert_eq!(rep.e_transformed.nrows(), 1);
3420        assert!(rep.e_transformed[[0, 0]].abs() > 0.0);
3421        assert!(rep.e_transformed[[0, 1]].abs() <= 1e-12);
3422        assert!(rep.e_transformed[[0, 2]].abs() <= 1e-12);
3423        // Exact pseudo-logdet on the structural penalized block has no
3424        // delta-dependent nullspace normalization.
3425        let expected_det1 = 1.0_f64;
3426        assert!((rep.det1[0] - expected_det1).abs() <= 1e-12);
3427
3428        let s = rep.s_transformed;
3429        let mut max_offdiag = 0.0_f64;
3430        for i in 0..p {
3431            for j in 0..p {
3432                if i != j {
3433                    max_offdiag = max_offdiag.max(s[[i, j]].abs());
3434                }
3435            }
3436        }
3437        assert!(
3438            max_offdiag <= 1e-10,
3439            "transformed penalty should be diagonal, max offdiag={max_offdiag}"
3440        );
3441        assert!(s[[1, 1]].abs() <= 1e-10);
3442        assert!(s[[2, 2]].abs() <= 1e-10);
3443    }
3444
3445    #[test]
3446    fn det1_matches_rank_for_single_full_rank_penalty() {
3447        let p = 2usize;
3448        let inv_sqrt2 = 2.0_f64.sqrt().recip();
3449        // Q^T for a 45-degree rotation.
3450        let q_t = [[inv_sqrt2, inv_sqrt2], [-inv_sqrt2, inv_sqrt2]];
3451        // R = diag(3, 1) * Q^T gives S = Q * diag(9, 1) * Q^T.
3452        let rs = array![
3453            [3.0 * q_t[0][0], 3.0 * q_t[0][1]],
3454            [1.0 * q_t[1][0], 1.0 * q_t[1][1]]
3455        ];
3456        let rs_list = vec![rs];
3457        let canonical = canonical_from_roots(&rs_list, p);
3458        let lambdas = vec![5.0];
3459
3460        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3461            .expect("precompute invariant");
3462        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3463            .expect("stable reparam");
3464
3465        assert_eq!(rep.e_transformed.nrows(), p);
3466        let det1 = rep.det1[0];
3467        // Exact pseudo-logdet on the structural penalized block:
3468        //   det1 = lambda * sum_l d_l / (lambda*d_l)
3469        // where d_l are eigenvalues of S_k.
3470        let s_k_eigs = [9.0_f64, 1.0_f64];
3471        let lambda = 5.0_f64;
3472        let expected_det1: f64 = s_k_eigs.iter().map(|&d| lambda * d / (lambda * d)).sum();
3473        assert!(
3474            (det1 - expected_det1).abs() <= 1e-12,
3475            "expected det1={expected_det1}, got {det1}",
3476        );
3477
3478        let s = rep.s_transformed;
3479        assert!(s[[0, 1]].abs() <= 1e-10);
3480        assert!(s[[1, 0]].abs() <= 1e-10);
3481        assert!(s[[0, 0]] > 0.0);
3482        assert!(s[[1, 1]] > 0.0);
3483    }
3484
3485    #[test]
3486    fn kronecker_reparam_logdet_matches_dense() {
3487        // 2D tensor product: q1=3, q2=4.
3488        // Marginal penalties: second-order difference matrices.
3489        let q1 = 3;
3490        let q2 = 4;
3491        let s1 = {
3492            let mut s = Array2::<f64>::zeros((q1, q1));
3493            // D2' D2 for order 2 on 3 points: [[1,-2,1],[-2,4,-2],[1,-2,1]]... simplified
3494            s[[0, 0]] = 1.0;
3495            s[[0, 1]] = -1.0;
3496            s[[1, 0]] = -1.0;
3497            s[[1, 1]] = 2.0;
3498            s[[1, 2]] = -1.0;
3499            s[[2, 1]] = -1.0;
3500            s[[2, 2]] = 1.0;
3501            s
3502        };
3503        let s2 = {
3504            let mut s = Array2::<f64>::zeros((q2, q2));
3505            s[[0, 0]] = 1.0;
3506            s[[0, 1]] = -1.0;
3507            s[[1, 0]] = -1.0;
3508            s[[1, 1]] = 2.0;
3509            s[[1, 2]] = -1.0;
3510            s[[2, 1]] = -1.0;
3511            s[[2, 2]] = 2.0;
3512            s[[2, 3]] = -1.0;
3513            s[[3, 2]] = -1.0;
3514            s[[3, 3]] = 1.0;
3515            s
3516        };
3517
3518        let lambdas = [2.5, 1.3];
3519        // Build dense Kronecker penalty: λ1 (S1⊗I) + λ2 (I⊗S2).
3520        let p = q1 * q2;
3521        let i1 = Array2::<f64>::eye(q1);
3522        let i2 = Array2::<f64>::eye(q2);
3523        let pen0 = kronecker_product(&s1, &i2);
3524        let pen1 = kronecker_product(&i1, &s2);
3525        let mut s_dense = Array2::<f64>::zeros((p, p));
3526        s_dense.scaled_add(lambdas[0], &pen0);
3527        s_dense.scaled_add(lambdas[1], &pen1);
3528
3529        // Dense eigendecomposition for reference pseudo-logdet.
3530        let (evals_dense, _): (ndarray::Array1<f64>, ndarray::Array2<f64>) =
3531            s_dense.eigh(faer::Side::Lower).unwrap();
3532        let tol = 1e-12;
3533        let ref_logdet: f64 = evals_dense
3534            .iter()
3535            .filter(|&&v: &&f64| v > tol)
3536            .map(|&v: &f64| v.ln())
3537            .sum();
3538
3539        // Kronecker reparameterization engine.
3540        let marginal_designs = vec![
3541            Array2::<f64>::eye(q1), // dummy designs
3542            Array2::<f64>::eye(q2),
3543        ];
3544        let marginal_penalties = vec![s1, s2];
3545        let kron_result = super::kronecker_reparameterization_engine(
3546            &marginal_designs,
3547            &marginal_penalties,
3548            &[q1, q2],
3549            &lambdas,
3550            false,
3551            None,
3552        )
3553        .unwrap();
3554
3555        let diff = (kron_result.log_det - ref_logdet).abs();
3556        assert!(
3557            diff < 1e-8,
3558            "Kronecker logdet {:.10} vs dense {:.10}, diff={:.3e}",
3559            kron_result.log_det,
3560            ref_logdet,
3561            diff,
3562        );
3563
3564        // Check derivatives via central FD in rho-space (rho = log lambda).
3565        let rhos: Vec<f64> = lambdas.iter().map(|&l| l.ln()).collect();
3566        let eps = 1e-5;
3567        for k in 0..2 {
3568            let mut rho_plus = rhos.clone();
3569            rho_plus[k] += eps;
3570            let mut rho_minus = rhos.clone();
3571            rho_minus[k] -= eps;
3572            let lam_plus: Vec<f64> = rho_plus.iter().map(|&r| r.exp()).collect();
3573            let lam_minus: Vec<f64> = rho_minus.iter().map(|&r| r.exp()).collect();
3574            let result_plus = super::kronecker_reparameterization_engine(
3575                &marginal_designs,
3576                &marginal_penalties,
3577                &[q1, q2],
3578                &lam_plus,
3579                false,
3580                None,
3581            )
3582            .unwrap();
3583            let result_minus = super::kronecker_reparameterization_engine(
3584                &marginal_designs,
3585                &marginal_penalties,
3586                &[q1, q2],
3587                &lam_minus,
3588                false,
3589                None,
3590            )
3591            .unwrap();
3592            let fd_deriv = (result_plus.log_det - result_minus.log_det) / (2.0 * eps);
3593            let analytic_deriv = kron_result.det1[k];
3594            let rel_err = if analytic_deriv.abs() > 1e-10 {
3595                (fd_deriv - analytic_deriv).abs() / analytic_deriv.abs()
3596            } else {
3597                (fd_deriv - analytic_deriv).abs()
3598            };
3599            assert!(
3600                rel_err < 1e-4,
3601                "det1[{k}] mismatch: analytic={:.8}, fd={:.8}, rel_err={:.3e}",
3602                analytic_deriv,
3603                fd_deriv,
3604                rel_err,
3605            );
3606        }
3607    }
3608
3609    #[test]
3610    fn classify_strict_rejects_nan_eigenvalue() {
3611        let mut eigs = [1.0, f64::NAN, 0.5];
3612        match classify_eigenvalues_strict(&mut eigs, "test_nan") {
3613            Err(EstimationError::PenaltySpectrumNonFinite {
3614                context,
3615                index,
3616                value,
3617            }) => {
3618                assert_eq!(context, "test_nan");
3619                assert_eq!(index, 1);
3620                assert!(value.is_nan());
3621            }
3622            other => panic!("expected PenaltySpectrumNonFinite, got {:?}", other),
3623        }
3624    }
3625
3626    #[test]
3627    fn classify_strict_rejects_inf_eigenvalue() {
3628        let mut eigs = [1.0, 0.5, f64::INFINITY];
3629        match classify_eigenvalues_strict(&mut eigs, "test_inf") {
3630            Err(EstimationError::PenaltySpectrumNonFinite { index, value, .. }) => {
3631                assert_eq!(index, 2);
3632                assert!(value.is_infinite());
3633            }
3634            other => panic!("expected PenaltySpectrumNonFinite, got {:?}", other),
3635        }
3636    }
3637
3638    #[test]
3639    fn classify_strict_rejects_materially_indefinite() {
3640        // -1e-2 with scale ~1.0 is well above any reasonable roundoff tolerance.
3641        let mut eigs = [1.0, -1e-2, 0.5];
3642        match classify_eigenvalues_strict(&mut eigs, "test_indef") {
3643            Err(EstimationError::PenaltySpectrumIndefinite {
3644                context,
3645                index,
3646                value,
3647                ..
3648            }) => {
3649                assert_eq!(context, "test_indef");
3650                assert_eq!(index, 1);
3651                assert!((value + 1e-2).abs() <= 1e-15);
3652            }
3653            other => panic!("expected PenaltySpectrumIndefinite, got {:?}", other),
3654        }
3655    }
3656
3657    #[test]
3658    fn classify_strict_accepts_roundoff_negative() {
3659        // -1e-16 * scale is well within tol = 64 * eps * p * scale.
3660        let scale = 1.0_f64;
3661        let roundoff = -1e-16 * scale;
3662        let mut eigs = [scale, 0.5 * scale, roundoff, 0.25 * scale];
3663        classify_eigenvalues_strict(&mut eigs, "test_roundoff").expect("roundoff must classify");
3664        // The roundoff eigenvalue is snapped to exact zero.
3665        assert_eq!(eigs[2], 0.0);
3666        // Strictly positive entries must be preserved.
3667        assert!(eigs[0] > 0.0 && eigs[1] > 0.0 && eigs[3] > 0.0);
3668    }
3669
3670    #[test]
3671    fn classify_strict_accepts_extreme_lambda_assembly_noise_1619() {
3672        // #1619: high-rank thin-plate / Duchon penalties (p≈200) assembled and
3673        // reparameterized at extreme λ produce float-noise negative eigenvalues at
3674        // ~1e-11 relative to the spectrum scale (~1e13 there). The bare machine-ε
3675        // floor (~12×ε relative ≈ 1e-12) rejected these as "indefinite" and
3676        // spuriously failed the inner P-IRLS solve. They are PSD to numerical
3677        // precision and must be snapped to zero, not rejected.
3678        let scale = 8.509e12_f64;
3679        // Worst (eig, scale) pair observed in the issue: eig/scale ≈ -7.7e-11.
3680        let noise = -6.546e2_f64;
3681        assert!(
3682            (noise.abs() / scale) < 1.0e-10,
3683            "fixture must reproduce the ~1e-11-relative noise from #1619"
3684        );
3685        let mut eigs = vec![scale, 0.5 * scale, noise, 0.1 * scale];
3686        classify_eigenvalues_strict(&mut eigs, "range penalty block")
3687            .expect("a ~1e-11-relative roundoff-negative eigenvalue must be accepted (#1619)");
3688        // The roundoff-negative eigenvalue is snapped to exact zero.
3689        assert_eq!(eigs[2], 0.0);
3690        // Strictly positive entries are preserved.
3691        assert!(eigs[0] > 0.0 && eigs[1] > 0.0 && eigs[3] > 0.0);
3692    }
3693
3694    #[test]
3695    fn classify_strict_snaps_subtol_positive_to_zero() {
3696        // Positive eigenvalues below the tolerance are also snapped to exact 0
3697        // so downstream rank counts and pseudo-logdets are deterministic.
3698        let scale = 10.0_f64;
3699        let subtol = 1e-15 * scale;
3700        let mut eigs = [scale, subtol];
3701        classify_eigenvalues_strict(&mut eigs, "test_sub_pos").expect("sub-tol positive ok");
3702        assert_eq!(eigs[1], 0.0);
3703    }
3704
3705    /// Build a `CanonicalPenalty` directly from a symmetric `local` matrix.
3706    /// Bypasses root extraction — the redundancy diagnostic only reads `local`
3707    /// and `col_range`, so the rest is filler.
3708    fn canonical_from_local(
3709        local: Array2<f64>,
3710        col_range: std::ops::Range<usize>,
3711        total_dim: usize,
3712    ) -> CanonicalPenalty {
3713        let block_dim = local.nrows();
3714        // A trivially valid root: zero rank. The diagnostic doesn't read root.
3715        let root = Array2::<f64>::zeros((0, block_dim));
3716        CanonicalPenalty {
3717            root,
3718            col_range,
3719            total_dim,
3720            nullity: 0,
3721            local,
3722            prior_mean: Array1::zeros(block_dim),
3723            positive_eigenvalues: Vec::new(),
3724            op: None,
3725        }
3726    }
3727
3728    #[test]
3729    fn report_penalty_pair_redundancy_detects_identical_pair() {
3730        // Penalty 0: a "generic" SPD matrix on cols 0..3.
3731        let s0 = ndarray::array![[2.0, 0.5, 0.0], [0.5, 1.0, 0.25], [0.0, 0.25, 1.5],];
3732        // Penalties 1 and 2: identical block-local penalty on the SAME col_range.
3733        // This is the Z₂-symmetric saddle scenario.
3734        let s_shared = ndarray::array![[1.0, -0.5, 0.0], [-0.5, 2.0, -0.5], [0.0, -0.5, 1.0],];
3735
3736        let bundle = vec![
3737            canonical_from_local(s0, 0..3, 3),
3738            canonical_from_local(s_shared.clone(), 0..3, 3),
3739            canonical_from_local(s_shared, 0..3, 3),
3740        ];
3741
3742        let redundant = report_penalty_pair_redundancy(&bundle);
3743
3744        // Exactly one redundant pair: (1, 2). Pairs (0, 1) and (0, 2) involve
3745        // distinct matrices and must NOT be flagged.
3746        assert_eq!(
3747            redundant.len(),
3748            1,
3749            "expected exactly one redundant pair, got {:?}",
3750            redundant
3751        );
3752        let (i, j, cos) = redundant[0];
3753        assert_eq!((i, j), (1, 2));
3754        assert!(
3755            cos > 1.0 - 1e-12,
3756            "cosine for identical penalties should be ~1.0, got {cos}"
3757        );
3758    }
3759
3760    #[test]
3761    fn report_penalty_pair_redundancy_skips_different_col_ranges() {
3762        // Two identical local matrices but on disjoint col_ranges. The
3763        // function must NOT flag them — they live in different parameter
3764        // subspaces by construction.
3765        let s = ndarray::array![[1.0, 0.0], [0.0, 1.0]];
3766        let bundle = vec![
3767            canonical_from_local(s.clone(), 0..2, 4),
3768            canonical_from_local(s, 2..4, 4),
3769        ];
3770        let redundant = report_penalty_pair_redundancy(&bundle);
3771        assert!(
3772            redundant.is_empty(),
3773            "different col_ranges must not be flagged"
3774        );
3775    }
3776}