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::{FaerEigh, 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        let mut range_block = Mat::<f64>::zeros(penalized_rank, penalized_rank);
2122        // Deterministic assembly: the independent S_k transforms were computed in
2123        // parallel above, but the lambda-weighted sum is accumulated serially in
2124        // canonical penalty order to avoid order-dependent floating-point drift.
2125        for (lambda, s_k) in lambdas.iter().zip(s_k_penalized_cache.iter()) {
2126            for i in 0..penalized_rank {
2127                for j in 0..penalized_rank {
2128                    range_block[(i, j)] += *lambda * s_k[(i, j)];
2129                }
2130            }
2131        }
2132        let (range_eigenvalues, range_eigenvectors) =
2133            robust_eigh_faer(&range_block, Side::Lower, "range penalty block")?;
2134
2135        let mut range_order: Vec<usize> = (0..penalized_rank).collect();
2136        range_order.sort_by(|&i, &j| {
2137            range_eigenvalues[j]
2138                .partial_cmp(&range_eigenvalues[i])
2139                .unwrap_or(std::cmp::Ordering::Equal)
2140                .then(i.cmp(&j))
2141        });
2142        range_eigenvalues_sorted = range_order
2143            .iter()
2144            .map(|&idx| range_eigenvalues[idx])
2145            .collect();
2146
2147        // Build range_rotation = U (sorted eigenvectors) for E and S⁺
2148        // construction only.  DO NOT apply to q_pen or rs_transformed —
2149        // keeping Q_s lambda-independent prevents BFGS coordinate-system
2150        // drift when multiple penalties interact (the eigenvectors of
2151        // Σ λ_k S_k rotate with λ, breaking the quasi-Newton Hessian
2152        // approximation at eigenvalue crossings).
2153        for (col_idx, &idx) in range_order.iter().enumerate() {
2154            for row in 0..penalized_rank {
2155                range_rotation[(row, col_idx)] = range_eigenvectors[(row, idx)];
2156            }
2157        }
2158        // q_pen and rs_transformed stay in the lambda-independent
2159        // invariant basis.  E and S⁺ below are expressed in this same
2160        // basis using U from the eigendecomposition.
2161    }
2162
2163    // Subspace-invariant penalty spectral calculus:
2164    // - Penalized and null spaces are fixed by the lambda-invariant basis `qs_base`.
2165    // - Runtime lambda dependence only appears in the penalized block eigenvalues.
2166    // This avoids basis mixing inside the degenerate zero-eigenspace.
2167    let structural_rank = penalized_rank;
2168    let mut range_eigs_sorted: Vec<f64> = range_eigenvalues_sorted;
2169    let structurally_penalized_cols = structurally_penalized_columns(penalties, p);
2170
2171    // Shrinkage floor: add a rho-independent ridge to the penalized block eigenvalues.
2172    // This prevents barely-penalized directions from causing pathological non-Gaussianity
2173    // in the posterior (extreme skewness under non-canonical links like logit with
2174    // high-dimensional spatial smooths). The ridge magnitude is proportional to the
2175    // balanced penalty's max eigenvalue (lambda-independent scale), so LAML gradients
2176    // w.r.t. rho remain correct: d(epsilon * I)/d(rho_k) = 0.
2177    //
2178    // The shrinkage ridge is a real prior contribution: it changes the quadratic
2179    // form, the penalty pseudo-logdet, and downstream Hessians. It is currently
2180    // surfaced through `ReparamResult::penalty_shrinkage_ridge` and consumed by
2181    // PIRLS/REML via that per-call channel. The longer-term home is a
2182    // `RidgePassport` with `RidgePolicy::explicit_stabilization_full` scoped to
2183    // the penalized block so the same delta is reflected in serialization, the
2184    // Laplace Hessian, and the prior logdet without callers having to thread an
2185    // additional scalar — once the ridge-ledger plumbing covers per-block
2186    // ScaledIdentity passports.
2187    let shrinkage_ridge = penalty_shrinkage_floor
2188        .filter(|&eps| eps > 0.0)
2189        .map(|eps| eps * invariant.max_balanced_eigenvalue)
2190        .unwrap_or(0.0);
2191    if shrinkage_ridge > 0.0 {
2192        let min_eig_before = range_eigs_sorted
2193            .iter()
2194            .copied()
2195            .fold(f64::INFINITY, f64::min);
2196        let mut shrinkage_floor_applied = 0usize;
2197        for eig_idx in 0..range_eigs_sorted.len() {
2198            let mut penalized_energy = 0.0;
2199            for original_col in 0..p {
2200                if structurally_penalized_cols[original_col] {
2201                    let mut coordinate = 0.0;
2202                    for pen_col in 0..penalized_rank {
2203                        coordinate +=
2204                            q_pen[(original_col, pen_col)] * range_rotation[(pen_col, eig_idx)];
2205                    }
2206                    penalized_energy += coordinate * coordinate;
2207                }
2208            }
2209            if penalized_energy > 1e-8 {
2210                range_eigs_sorted[eig_idx] += shrinkage_ridge;
2211                shrinkage_floor_applied += 1;
2212            }
2213        }
2214        // Log when the floor materially changes the smallest eigenvalue (>1% relative shift).
2215        if min_eig_before > 0.0 && shrinkage_ridge / min_eig_before > 0.01 {
2216            log::debug!(
2217                "Penalty shrinkage floor active: ridge={:.3e} (min_eig_before={:.3e}, ratio={:.1e}, max_bal_eig={:.3e}, applied_dirs={})",
2218                shrinkage_ridge,
2219                min_eig_before,
2220                shrinkage_ridge / min_eig_before,
2221                invariant.max_balanced_eigenvalue,
2222                shrinkage_floor_applied,
2223            );
2224        }
2225    }
2226
2227    let eigenvalue_floor = invariant.max_balanced_eigenvalue.max(1.0) * 1e-12;
2228    let qs = compose_qs_from_split(&q_pen, &q_null, p);
2229
2230    // Guard against any accidental penalized/null mixing. The transformed penalty
2231    // roots must have negligible support on null columns by construction.
2232    let leakage = assess_subspace_leakage(&qs, &rs_transformed, structural_rank, p);
2233    if !subspace_split_is_consistent(&leakage, p) {
2234        return Err(EstimationError::LayoutError(format!(
2235            "Reparameterization subspace split is inconsistent: max null leakage {:.3e} (rel {:.3e}, worst penalty {}), max |Qp'Qn| {:.3e}",
2236            leakage.max_abs_sq.sqrt(),
2237            leakage.max_rel_sq.sqrt(),
2238            leakage.worst_penalty,
2239            leakage.max_cross_gram_abs,
2240        )));
2241    }
2242
2243    // Truncated basis in transformed coordinates:
2244    //   U_⊥^(t) = Qs^T U_⊥^(orig) = Qs^T Q_n.
2245    let mut u_truncated_mat = Mat::<f64>::zeros(p, q_null.ncols());
2246    matmul(
2247        u_truncated_mat.as_mut(),
2248        Accum::Replace,
2249        qs.transpose(),
2250        q_null.as_ref(),
2251        1.0,
2252        Par::Seq,
2253    );
2254
2255    // E is represented in TRANSFORMED coordinates (beta_t).  Because the
2256    // penalized subspace is NOT rotated by the lambda-dependent eigenvectors
2257    // (to keep Q_s stable across BFGS iterations), E is no longer diagonal.
2258    // Instead E = diag(√d) · U' embedded in structural_rank × p, so that
2259    // E'E = U diag(d) U' = Σ λ_k S_k in the invariant penalized basis.
2260    let mut e_transformed_mat = Mat::<f64>::zeros(structural_rank, p);
2261    for row_idx in 0..structural_rank {
2262        let safe_eigenval = range_eigs_sorted[row_idx].max(eigenvalue_floor);
2263        let sqrt_eigenval = safe_eigenval.sqrt();
2264        // E[row, j] = sqrt(d_row) * U'[row, j] = sqrt(d_row) * U[j, row]
2265        for j in 0..penalized_rank {
2266            e_transformed_mat[(row_idx, j)] = sqrt_eigenval * range_rotation[(j, row_idx)];
2267        }
2268    }
2269
2270    // Pseudo-logdet on the structural penalized block.  The null block is split
2271    // out above, so there is no nullspace normalization here.  Spectrally-noisy
2272    // directions (eigenvalues snapped to 0 by `classify_eigenvalues_strict`
2273    // because they fall below the `c * eps_machine * p * scale` tolerance in
2274    // the lambda-weighted sum) are floored to `eigenvalue_floor` to keep the
2275    // log-det finite and consistent with the floored values used to construct
2276    // `e_transformed_mat` above.  This avoids spurious P-IRLS failures when the
2277    // lambda dynamic range is wide (e.g. during BFGS line search probing extreme
2278    // rho candidates).  Materially negative or non-finite spectra are already
2279    // rejected by the strict classifier upstream; this loop re-checks the
2280    // *post-shrinkage* range eigenvalues against the same floor.
2281    //
2282    // The same floored spectrum is used in the trace formula tr(S⁺ S_k) below,
2283    // matching the rank structure embedded in `e_transformed_mat` and avoiding
2284    // a 1/0 in the trace contraction when an eigenvalue was floored to 0.
2285    let mut floored_eigs: Vec<f64> = Vec::with_capacity(range_eigs_sorted.len());
2286    let mut log_det_sum = KahanSum::default();
2287    for (idx, &ev) in range_eigs_sorted.iter().enumerate() {
2288        if !ev.is_finite() || ev < -eigenvalue_floor {
2289            return Err(EstimationError::LayoutError(format!(
2290                "Penalty pseudo-logdet has a non-finite or large-negative structural eigenvalue at index {idx}: {ev:.3e}"
2291            )));
2292        }
2293        let safe_ev = ev.max(eigenvalue_floor);
2294        floored_eigs.push(safe_ev);
2295        if idx < penalized_rank {
2296            log_det_sum.add(safe_ev.ln());
2297        }
2298    }
2299    let log_det = log_det_sum.sum();
2300    let delta = 0.0;
2301
2302    // The det1 contractions are independent once the eigensystem is fixed.  Use
2303    // indexed parallel collection so the output vector preserves lambda order.
2304    let det1vec: Vec<f64> = (0..lambdas.len())
2305        .into_par_iter()
2306        .map(|k| {
2307            let s_k = &s_k_penalized_cache[k];
2308            // Compute tr((S+δI)⁻¹ S_k) in the range eigenbasis without ever
2309            // materializing (S+δI)⁻¹. Using faer's matmul keeps this contraction
2310            // aligned with the orthogonal-similarity debug reference path.
2311            let trace = trace_penalty_in_orthogonal_basis(
2312                s_k,
2313                penalized_rank,
2314                &range_rotation,
2315                &floored_eigs,
2316                delta,
2317            );
2318            lambdas[k] * trace
2319        })
2320        .collect();
2321
2322    {
2323        // Guardrail: cross-check the primary Rayleigh-quotient contraction
2324        // against a full orthogonal similarity transform, while staying in
2325        // the same numerically stable eigenbasis coordinates.
2326        let mut maxdet1_mismatch = 0.0_f64;
2327        let mut det1_scale = 0.0_f64;
2328        for (k, lambda) in lambdas.iter().enumerate() {
2329            let s_k_penalized = &s_k_penalized_cache[k];
2330            let s_k_eigenbasis = orthogonal_similarity_transform_faer(
2331                s_k_penalized,
2332                penalized_rank,
2333                &range_rotation,
2334            );
2335            let mut trace = KahanSum::default();
2336            for l in 0..penalized_rank {
2337                trace.add(s_k_eigenbasis[(l, l)] / (floored_eigs[l] + delta));
2338            }
2339            let reference = *lambda * trace.sum();
2340            maxdet1_mismatch = maxdet1_mismatch.max((reference - det1vec[k]).abs());
2341            det1_scale = det1_scale.max(reference.abs()).max(det1vec[k].abs());
2342        }
2343        let det1_tolerance = 1e-7 * det1_scale.max(1.0);
2344        assert!(
2345            maxdet1_mismatch <= det1_tolerance,
2346            "det1 mismatch between optimized and reference formulas: max_abs={maxdet1_mismatch:.3e}, tol={det1_tolerance:.3e}"
2347        );
2348    }
2349
2350    // Rebuild s_transformed from e_transformed to ensure rank consistency.
2351    //
2352    // The sum of λ*S_k may contain numerical noise modes (eigenvalues ~1e-15) that
2353    // become significant when λ is large (e.g., 10^12). These modes would appear in H
2354    // but are truncated from log|S|_+, creating a "phantom penalty" in the objective.
2355    //
2356    // By reconstructing s_transformed = E^T * E, we force the penalty matrix used
2357    // in H to have the EXACT same rank structure as the one used for log|S|_+.
2358    // Any mode truncated from the prior is now strictly zero in the Hessian
2359    // calculation, ensuring mathematical consistency of the gradients.
2360    let mut s_truncated = Mat::<f64>::zeros(p, p);
2361    matmul(
2362        s_truncated.as_mut(),
2363        Accum::Replace,
2364        e_transformed_mat.transpose(),
2365        e_transformed_mat.as_ref(),
2366        1.0,
2367        Par::Seq,
2368    );
2369
2370    {
2371        // Structural check: transformed S must not leak into declared null coordinates.
2372        let mut max_null_diag = 0.0_f64;
2373        let mut max_null_offdiag = 0.0_f64;
2374        for i in structural_rank..p {
2375            max_null_diag = max_null_diag.max(s_truncated[(i, i)].abs());
2376            for j in 0..p {
2377                if i != j {
2378                    max_null_offdiag = max_null_offdiag.max(s_truncated[(i, j)].abs());
2379                }
2380            }
2381        }
2382        assert!(
2383            max_null_diag <= 1e-10 && max_null_offdiag <= 1e-10,
2384            "null-space leakage in transformed penalty: max_null_diag={max_null_diag:.3e}, max_null_offdiag={max_null_offdiag:.3e}"
2385        );
2386    }
2387
2388    let qs_array = mat_to_array(&qs);
2389    let canonical_transformed: Vec<CanonicalPenalty> = rs_transformed
2390        .par_iter()
2391        .zip(penalties.par_iter())
2392        .map(|(r, cp)| {
2393            let mean_transformed = qs_array.t().dot(&cp.full_width_prior_mean());
2394            CanonicalPenalty::from_dense_root_with_mean(mat_to_array(r), p, mean_transformed)
2395        })
2396        .collect();
2397    Ok(ReparamResult {
2398        s_transformed: mat_to_array(&s_truncated),
2399        log_det,
2400        det1: Array1::from(det1vec),
2401        qs: qs_array,
2402        canonical_transformed,
2403        e_transformed: mat_to_array(&e_transformed_mat),
2404        u_truncated: mat_to_array(&u_truncated_mat),
2405        penalty_shrinkage_ridge: shrinkage_ridge,
2406    })
2407}
2408
2409/// Minimal engine layout descriptor that avoids domain-specific layout coupling.
2410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2411pub struct EngineDims {
2412    pub p: usize,
2413    pub k: usize,
2414}
2415
2416impl EngineDims {
2417    pub fn new(p: usize, k: usize) -> Self {
2418        Self { p, k }
2419    }
2420}
2421
2422/// Engine-facing stable reparameterization API using only `(p, k)`.
2423///
2424/// When `cached_invariant` is `Some`, reuses the precomputed eigendecomposition
2425/// (the hot path inside the REML loop). When `None`, computes the invariant on
2426/// the fly (the post-REML refit path). Merging both cases into a single entry
2427/// point ensures `penalty_shrinkage_floor` is always applied regardless of
2428/// whether a cached invariant is available.
2429/// Stable reparameterization from block-local canonical penalties.
2430pub fn stable_reparameterization_engine_canonical(
2431    penalties: &[CanonicalPenalty],
2432    lambdas: &[f64],
2433    dims: EngineDims,
2434    cached_invariant: Option<&ReparamInvariant>,
2435    penalty_shrinkage_floor: Option<f64>,
2436) -> Result<ReparamResult, EstimationError> {
2437    let owned;
2438    let invariant = match cached_invariant {
2439        Some(inv) => inv,
2440        None => {
2441            owned = precompute_reparam_invariant_from_canonical(penalties, dims.p)?;
2442            &owned
2443        }
2444    };
2445    stable_reparameterizationwith_invariant(
2446        penalties,
2447        lambdas,
2448        dims.p,
2449        invariant,
2450        penalty_shrinkage_floor,
2451    )
2452}
2453
2454// ---------------------------------------------------------------------------
2455// Kronecker-factored reparameterization for tensor-product smooths
2456// ---------------------------------------------------------------------------
2457
2458/// Result of Kronecker-factored reparameterization.
2459///
2460/// Exploits the fact that for Kronecker-structured penalties, the joint
2461/// eigenvector matrix is `U_1 ⊗ ... ⊗ U_d` and the reparameterized design
2462/// is a rowwise Kronecker of `(B_k U_k)` — all remaining factored.
2463#[derive(Clone)]
2464pub struct KroneckerReparamResult {
2465    /// Reparameterized marginal designs: `B_k · U_k` for each marginal k.
2466    ///
2467    /// `Arc`-shared with the λ-invariant cache so the per-outer-iterate
2468    /// memoized engine bumps a refcount instead of deep-copying the
2469    /// (n × q) reparameterized marginals every call.
2470    pub reparameterized_marginals: Arc<Vec<Array2<f64>>>,
2471    /// Marginal eigenvalues from each marginal penalty eigendecomposition.
2472    pub marginal_eigenvalues: Arc<Vec<Array1<f64>>>,
2473    /// Marginal eigenvector matrices U_k.
2474    pub marginal_qs: Arc<Vec<Array2<f64>>>,
2475    /// log|S|₊ computed from marginal eigenvalue grid.
2476    pub log_det: f64,
2477    /// First derivatives of log|S|₊ w.r.t. ρ_k = log(λ_k).
2478    pub det1: Array1<f64>,
2479    /// Second derivatives of log|S|₊ w.r.t. ρ.
2480    pub det2: Array2<f64>,
2481    /// Shrinkage ridge added to eigenvalues (if any).
2482    pub penalty_shrinkage_ridge: f64,
2483    /// Whether a double penalty (global ridge) is present.
2484    pub has_double_penalty: bool,
2485    /// Marginal basis dimensions.
2486    pub marginal_dims: Vec<usize>,
2487}
2488
2489impl KroneckerReparamResult {
2490    /// Materialize the joint Qs matrix (U_1 ⊗ ... ⊗ U_d) as dense p×p.
2491    /// Only for fallback paths — avoid in hot loops.
2492    pub fn materialize_qs(&self) -> Array2<f64> {
2493        let mut qs = Array2::<f64>::eye(1);
2494        for u_k in self.marginal_qs.iter() {
2495            qs = kronecker_product(&qs, u_k);
2496        }
2497        qs
2498    }
2499
2500    /// Materialize s_transformed (the penalty in the reparameterized basis).
2501    /// In the eigenbasis, this is diagonal with entries Σ_k λ_k μ_{k,j_k}.
2502    pub fn materialize_s_transformed(&self, lambdas: &[f64]) -> Array2<f64> {
2503        let d = self.marginal_dims.len();
2504        let p: usize = self.marginal_dims.iter().copied().product();
2505        let mut s = Array2::<f64>::zeros((p, p));
2506
2507        // Delegate the per-cell tensor-penalty accumulation to the shared
2508        // `kronecker_cell_sigma` (#1172/#1185 single source of truth). Fold the
2509        // `lambdas.len() > d` guard into `has_double` to preserve exact gating.
2510        let eigenvalue_views: Vec<ArrayView1<'_, f64>> =
2511            self.marginal_eigenvalues.iter().map(|m| m.view()).collect();
2512        let has_double = self.has_double_penalty && lambdas.len() > d;
2513        let mut multi_idx = vec![0usize; d];
2514        let mut flat = 0usize;
2515        loop {
2516            let (sigma, _structural_sigma, _joint_null) = kronecker_cell_sigma(
2517                &eigenvalue_views,
2518                &multi_idx,
2519                lambdas,
2520                d,
2521                has_double,
2522                self.penalty_shrinkage_ridge,
2523            );
2524            s[[flat, flat]] = sigma;
2525            flat += 1;
2526
2527            if kronecker_multi_index_advance(&mut multi_idx, &self.marginal_dims) {
2528                break;
2529            }
2530        }
2531        s
2532    }
2533
2534    /// Explicitly materialize the dense artifact bundle expected by legacy
2535    /// downstream consumers. This is not part of the native Kronecker solve path.
2536    pub fn materialize_dense_artifact_result(
2537        &self,
2538        rs_list: &[Array2<f64>],
2539        lambdas: &[f64],
2540        p: usize,
2541    ) -> Result<ReparamResult, EstimationError> {
2542        const KRONECKER_DENSE_COMPAT_FALLBACK_MAX_P: usize = 4096;
2543        if p > KRONECKER_DENSE_COMPAT_FALLBACK_MAX_P {
2544            return Err(EstimationError::LayoutError(format!(
2545                "Kronecker reparameterization would materialize dense {}x{} compatibility tensors; \
2546                 large-model dense fallback is disabled. Wire the downstream solver to consume \
2547                 the factored Kronecker result directly",
2548                p, p
2549            )));
2550        }
2551        let qs = self.materialize_qs();
2552        let s_transformed = self.materialize_s_transformed(lambdas);
2553
2554        // Transform penalty roots: R_k_transformed = R_k · Qs
2555        let rs_transformed: Vec<Array2<f64>> = if rs_list.len() >= 2 {
2556            use rayon::prelude::*;
2557            rs_list
2558                .par_iter()
2559                .map(|r| gam_linalg::faer_ndarray::fast_ab(r, &qs))
2560                .collect()
2561        } else {
2562            rs_list
2563                .iter()
2564                .map(|r| gam_linalg::faer_ndarray::fast_ab(r, &qs))
2565                .collect()
2566        };
2567        // rs_transposed removed — canonical_transformed is the single source of truth.
2568
2569        // Build e_transformed: combined penalty square root in transformed coords.
2570        // For Kronecker structure, the penalty is diagonal in the eigenbasis.
2571        // e_transformed rows are the nonzero rows of sqrt(Σ_k λ_k S_k)^{1/2}.
2572        let d = self.marginal_dims.len();
2573        // Delegate the per-cell tensor-penalty accumulation to the shared
2574        // `kronecker_cell_sigma` (the #1172/#1185 single source of truth). The
2575        // double-penalty term is only valid when `lambdas` actually carries the
2576        // λ_d entry, so fold the original `lambdas.len() > d` guard into the
2577        // `has_double_penalty` flag passed to the helper — preserving the exact
2578        // gating behavior.
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 diag_vals: Vec<f64> = {
2583            let mut vals = Vec::with_capacity(p);
2584            let mut multi_idx = vec![0usize; d];
2585            loop {
2586                let (sigma, _structural_sigma, _joint_null) = kronecker_cell_sigma(
2587                    &eigenvalue_views,
2588                    &multi_idx,
2589                    lambdas,
2590                    d,
2591                    has_double,
2592                    self.penalty_shrinkage_ridge,
2593                );
2594                vals.push(if sigma > 0.0 { sigma.sqrt() } else { 0.0 });
2595
2596                if kronecker_multi_index_advance(&mut multi_idx, &self.marginal_dims) {
2597                    break;
2598                }
2599            }
2600            vals
2601        };
2602        let rank = diag_vals.iter().filter(|&&v| v > 1e-12).count();
2603        let mut e_transformed = Array2::<f64>::zeros((rank, p));
2604        let mut row = 0;
2605        for (j, &v) in diag_vals.iter().enumerate() {
2606            if v > 1e-12 {
2607                e_transformed[[row, j]] = v;
2608                row += 1;
2609            }
2610        }
2611
2612        // u_truncated: null-space eigenvectors (columns with zero eigenvalue).
2613        let null_count = p - rank;
2614        let mut u_truncated = Array2::<f64>::zeros((p, null_count));
2615        let mut col = 0;
2616        for (j, &v) in diag_vals.iter().enumerate() {
2617            if v <= 1e-12 {
2618                u_truncated[[j, col]] = 1.0; // standard basis vector in eigenbasis
2619                col += 1;
2620            }
2621        }
2622
2623        let canonical_transformed: Vec<CanonicalPenalty> = rs_transformed
2624            .iter()
2625            .map(|r| CanonicalPenalty::from_dense_root(r.clone(), p))
2626            .collect();
2627        Ok(ReparamResult {
2628            s_transformed,
2629            log_det: self.log_det,
2630            det1: self.det1.clone(),
2631            qs,
2632            canonical_transformed,
2633            e_transformed,
2634            u_truncated,
2635            penalty_shrinkage_ridge: self.penalty_shrinkage_ridge,
2636        })
2637    }
2638}
2639
2640/// Compute `log|S|₊` and its first/second derivatives w.r.t. `ρ_k = log(λ_k)`
2641/// from factored marginal eigenvalues.
2642///
2643/// Shared implementation for `KroneckerPenaltySystem::logdet_and_derivatives`
2644/// and `kronecker_reparameterization_engine`.  Iterates over the ∏q_j
2645/// multi-index grid in O(d · ∏q_j) time with no O(p²) storage.
2646const KRONECKER_STRUCTURAL_ZERO_TOL: f64 = 1e-12;
2647
2648/// Per-cell Kronecker eigenvalue accumulation — the single source of truth for
2649/// the #1172/#1185 tensor-penalty math.
2650///
2651/// For the multi-index cell `multi_idx`, accumulates:
2652///   - `sigma`            = Σ_k λ_k · μ_k  (+ joint-null double-penalty term + ridge)
2653///   - `structural_sigma` = Σ_k μ_k        (unweighted; classifies joint-null cells)
2654///   - `joint_null`       = whether the cell lies in the joint null space
2655///
2656/// `marginal_eigenvalues[k][multi_idx[k]]` is the k-th marginal eigenvalue μ_k.
2657/// The double-penalty (global ridge) term `λ_d` is added only on joint-null
2658/// cells; the structural shrinkage `ridge` is added only on structurally
2659/// penalized cells. This mirrors the gated logic fixed in #1172/#1185 and MUST
2660/// be kept identical across every caller.
2661#[inline]
2662fn kronecker_cell_sigma(
2663    marginal_eigenvalues: &[ArrayView1<'_, f64>],
2664    multi_idx: &[usize],
2665    lambdas: &[f64],
2666    d: usize,
2667    has_double_penalty: bool,
2668    ridge: f64,
2669) -> (f64, f64, bool) {
2670    let mut sigma = 0.0;
2671    let mut structural_sigma = 0.0;
2672    for k in 0..d {
2673        let marginal_eigenvalue = marginal_eigenvalues[k][multi_idx[k]];
2674        structural_sigma += marginal_eigenvalue;
2675        sigma += lambdas[k] * marginal_eigenvalue;
2676    }
2677    let joint_null = structural_sigma <= KRONECKER_STRUCTURAL_ZERO_TOL;
2678    if has_double_penalty && joint_null {
2679        sigma += lambdas[d];
2680    }
2681    if structural_sigma > KRONECKER_STRUCTURAL_ZERO_TOL {
2682        sigma += ridge;
2683    }
2684    (sigma, structural_sigma, joint_null)
2685}
2686
2687/// Advance a row-major multi-index over the `dims` grid in place.
2688/// Returns `true` when the grid is exhausted (the index wrapped back to all-zero).
2689#[inline]
2690fn kronecker_multi_index_advance(multi_idx: &mut [usize], dims: &[usize]) -> bool {
2691    let mut carry = true;
2692    for dim in (0..dims.len()).rev() {
2693        if carry {
2694            multi_idx[dim] += 1;
2695            if multi_idx[dim] < dims[dim] {
2696                carry = false;
2697            } else {
2698                multi_idx[dim] = 0;
2699            }
2700        }
2701    }
2702    carry
2703}
2704
2705pub fn kronecker_logdet_and_derivatives(
2706    marginal_eigenvalues: &[ArrayView1<'_, f64>],
2707    marginal_dims: &[usize],
2708    lambdas: &[f64],
2709    has_double_penalty: bool,
2710    ridge: f64,
2711) -> (f64, Array1<f64>, Array2<f64>) {
2712    let d = marginal_dims.len();
2713    let n_pen = d + if has_double_penalty { 1 } else { 0 };
2714
2715    let mut logdet = 0.0;
2716    let mut grad = Array1::<f64>::zeros(n_pen);
2717    let mut hess = Array2::<f64>::zeros((n_pen, n_pen));
2718    let tol = 1e-12;
2719
2720    let mut multi_idx = vec![0usize; d];
2721    loop {
2722        let (sigma, _structural_sigma, joint_null) = kronecker_cell_sigma(
2723            marginal_eigenvalues,
2724            &multi_idx,
2725            lambdas,
2726            d,
2727            has_double_penalty,
2728            ridge,
2729        );
2730
2731        if sigma > tol {
2732            logdet += sigma.ln();
2733            let inv_sigma = 1.0 / sigma;
2734            let inv_sigma2 = inv_sigma * inv_sigma;
2735
2736            for k in 0..d {
2737                let ck = lambdas[k] * marginal_eigenvalues[k][multi_idx[k]];
2738                grad[k] += ck * inv_sigma;
2739            }
2740            if has_double_penalty && joint_null {
2741                grad[d] += lambdas[d] * inv_sigma;
2742            }
2743
2744            for k in 0..n_pen {
2745                let ck = if k < d {
2746                    lambdas[k] * marginal_eigenvalues[k][multi_idx[k]]
2747                } else if joint_null {
2748                    lambdas[d]
2749                } else {
2750                    0.0
2751                };
2752                // When ck == 0 (a zero λ, a zero marginal eigenvalue, or a cell
2753                // outside the joint null for the ridge penalty) every term this
2754                // index k contributes — `ck·inv_sigma − ck²·inv_sigma2` on the
2755                // diagonal and `−ck·cl·inv_sigma2` on every off-diagonal — is
2756                // exactly 0.0, so adding them to the finite running accumulators
2757                // is a bit-identical no-op. Skip the inner sweep entirely.
2758                if ck == 0.0 {
2759                    continue;
2760                }
2761                hess[[k, k]] += ck * inv_sigma - ck * ck * inv_sigma2;
2762                for l in (k + 1)..n_pen {
2763                    let cl = if l < d {
2764                        lambdas[l] * marginal_eigenvalues[l][multi_idx[l]]
2765                    } else if joint_null {
2766                        lambdas[d]
2767                    } else {
2768                        0.0
2769                    };
2770                    let off = -ck * cl * inv_sigma2;
2771                    hess[[k, l]] += off;
2772                    hess[[l, k]] += off;
2773                }
2774            }
2775        }
2776
2777        if kronecker_multi_index_advance(&mut multi_idx, marginal_dims) {
2778            break;
2779        }
2780    }
2781
2782    (logdet, grad, hess)
2783}
2784
2785// #1521: `KroneckerInvariantStructure` is defined once in `crate::kronecker`
2786// (the leaf data+compute module). The byte-identical copy that the carve left
2787// here is replaced by an import so the cache and this engine share one type.
2788use crate::kronecker::KroneckerInvariantStructure;
2789
2790/// Kronecker-factored reparameterization for tensor-product penalties.
2791///
2792/// Instead of eigendecomposing the full p×p balanced penalty (O(p³)), this
2793/// eigendecomposes each marginal penalty separately (O(Σ q_k³)) and computes
2794/// the joint eigensystem as the Kronecker product of marginal eigensystems.
2795pub fn kronecker_reparameterization_engine(
2796    marginal_designs: &[Array2<f64>],
2797    marginal_penalties: &[Array2<f64>],
2798    marginal_dims: &[usize],
2799    lambdas: &[f64],
2800    has_double_penalty: bool,
2801    penalty_shrinkage_floor: Option<f64>,
2802) -> Result<KroneckerReparamResult, EstimationError> {
2803    let d = marginal_dims.len();
2804    if marginal_designs.len() != d || marginal_penalties.len() != d {
2805        return Err(EstimationError::LayoutError(format!(
2806            "kronecker_reparameterization_engine: dimension mismatch: designs={}, penalties={}, dims={}",
2807            marginal_designs.len(),
2808            marginal_penalties.len(),
2809            d
2810        )));
2811    }
2812
2813    let invariant =
2814        KroneckerInvariantStructure::compute(marginal_designs, marginal_penalties, marginal_dims)?;
2815    kronecker_reparameterization_engine_with_invariant(
2816        &invariant,
2817        marginal_dims,
2818        lambdas,
2819        has_double_penalty,
2820        penalty_shrinkage_floor,
2821    )
2822}
2823
2824/// Kronecker-factored reparameterization reusing a precomputed λ-invariant
2825/// structure (eigensystems, reparameterized marginals, shrinkage scale).
2826///
2827/// Bit-identical to `kronecker_reparameterization_engine` for the same marginal
2828/// data — the only difference is that the `eigh()` / `B_k U_k` work was hoisted
2829/// out of the per-iterate path into the cached `invariant`. Only the λ-dependent
2830/// `kronecker_logdet_and_derivatives` sweep and `floor * max_bal` scaling run here.
2831pub fn kronecker_reparameterization_engine_with_invariant(
2832    invariant: &KroneckerInvariantStructure,
2833    marginal_dims: &[usize],
2834    lambdas: &[f64],
2835    has_double_penalty: bool,
2836    penalty_shrinkage_floor: Option<f64>,
2837) -> Result<KroneckerReparamResult, EstimationError> {
2838    // Arc refcount bumps — the underlying eigensystems / reparameterized
2839    // marginals are λ-invariant and shared with the cache, not deep-copied.
2840    let marginal_eigenvalues = Arc::clone(&invariant.marginal_eigenvalues);
2841    let marginal_qs = Arc::clone(&invariant.marginal_qs);
2842    let reparameterized_marginals = Arc::clone(&invariant.reparameterized_marginals);
2843
2844    // Compute shrinkage ridge from balanced penalty eigenvalue scale.
2845    let penalty_shrinkage_ridge = if let Some(floor) = penalty_shrinkage_floor {
2846        floor * invariant.max_balanced_eigenvalue
2847    } else {
2848        0.0
2849    };
2850
2851    let marginal_eigenvalue_views: Vec<_> = marginal_eigenvalues
2852        .iter()
2853        .map(|evals| evals.view())
2854        .collect();
2855    let (log_det, det1, det2) = kronecker_logdet_and_derivatives(
2856        &marginal_eigenvalue_views,
2857        marginal_dims,
2858        lambdas,
2859        has_double_penalty,
2860        penalty_shrinkage_ridge,
2861    );
2862
2863    Ok(KroneckerReparamResult {
2864        reparameterized_marginals,
2865        marginal_eigenvalues,
2866        marginal_qs,
2867        log_det,
2868        det1,
2869        det2,
2870        penalty_shrinkage_ridge,
2871        has_double_penalty,
2872        marginal_dims: marginal_dims.to_vec(),
2873    })
2874}
2875
2876/// Calculate the 2-norm condition number of a matrix.
2877///
2878/// For symmetric matrices (the dominant case for GAM Hessians/penalties),
2879/// this uses an eigenvalue path and computes:
2880///   cond_2(A) = max_i |lambda_i| / min_i |lambda_i|
2881/// which is exactly equal to the singular-value definition for symmetric A.
2882///
2883/// For non-symmetric matrices, this falls back to SVD:
2884///   cond_2(A) = sigma_max / sigma_min
2885///
2886/// This preserves semantics while avoiding full SVD in hot paths.
2887///
2888/// # Arguments
2889/// * `matrix` - The matrix to analyze
2890///
2891/// # Returns
2892/// * `Ok(condition_number)` - The condition number (max_sv / min_sv)
2893/// * `Ok(f64::INFINITY)` - If the matrix is effectively singular (min_sv < 1e-12)
2894/// * `Err` - If SVD computation fails
2895pub fn calculate_condition_number(matrix: &Array2<f64>) -> Result<f64, FaerLinalgError> {
2896    let (rows, cols) = matrix.dim();
2897    if rows == 0 || cols == 0 {
2898        return Ok(1.0);
2899    }
2900
2901    // Fast path for (near-)symmetric square matrices.
2902    if rows == cols {
2903        let mut max_abs = 0.0_f64;
2904        let mut max_asym = 0.0_f64;
2905        for i in 0..rows {
2906            for j in 0..cols {
2907                max_abs = max_abs.max(matrix[[i, j]].abs());
2908            }
2909            for j in 0..i {
2910                let diff = (matrix[[i, j]] - matrix[[j, i]]).abs();
2911                if diff > max_asym {
2912                    max_asym = diff;
2913                }
2914            }
2915        }
2916        let sym_tol = max_abs.max(1.0) * 1e-12;
2917        if max_asym <= sym_tol {
2918            let (evals, _) = matrix.eigh(Side::Lower)?;
2919            let mut max_abs_eval = 0.0_f64;
2920            let mut min_abs_eval = f64::INFINITY;
2921            for &lam in evals.iter() {
2922                let s = lam.abs();
2923                max_abs_eval = max_abs_eval.max(s);
2924                min_abs_eval = min_abs_eval.min(s);
2925            }
2926            if min_abs_eval < 1e-12 {
2927                return Ok(f64::INFINITY);
2928            }
2929            return Ok(max_abs_eval / min_abs_eval);
2930        }
2931    }
2932
2933    // General matrix fallback.
2934    let (_, s, _) = matrix.svd(false, false)?;
2935    let max_sv = s.iter().fold(0.0_f64, |max, &val| max.max(val));
2936    let min_sv = s.iter().fold(f64::INFINITY, |min, &val| min.min(val));
2937    if min_sv < 1e-12 {
2938        return Ok(f64::INFINITY);
2939    }
2940    Ok(max_sv / min_sv)
2941}
2942
2943#[cfg(test)]
2944mod tests {
2945    use super::{
2946        CanonicalPenalty, REL_PSD_FLOOR, SubspaceLeakageMetrics, assess_subspace_leakage,
2947        classify_eigenvalues_strict, precompute_reparam_invariant_from_canonical,
2948        report_penalty_pair_redundancy, stable_reparameterizationwith_invariant,
2949        subspace_split_is_consistent,
2950    };
2951    use crate::EstimationError;
2952    use crate::construction::kronecker_product;
2953    use faer::Mat;
2954    use gam_linalg::faer_ndarray::FaerEigh;
2955    use gam_linalg::utils::inf_norm;
2956    use ndarray::{Array1, Array2, array};
2957
2958    /// Build CanonicalPenalty values from full-width roots for tests.
2959    fn canonical_from_roots(rs_list: &[Array2<f64>], p: usize) -> Vec<CanonicalPenalty> {
2960        rs_list
2961            .iter()
2962            .map(|r| {
2963                let local = r.t().dot(r);
2964                CanonicalPenalty {
2965                    root: r.clone(),
2966                    col_range: 0..p,
2967                    total_dim: p,
2968                    nullity: 0,
2969                    local,
2970                    prior_mean: Array1::zeros(p),
2971                    positive_eigenvalues: Vec::new(),
2972                    op: None,
2973                }
2974            })
2975            .collect()
2976    }
2977
2978    fn metrics_for(
2979        qs: &Mat<f64>,
2980        rs: &[Mat<f64>],
2981        structural_rank: usize,
2982        p: usize,
2983    ) -> SubspaceLeakageMetrics {
2984        assess_subspace_leakage(qs, rs, structural_rank, p)
2985    }
2986
2987    #[test]
2988    fn subspace_leakage_iszero_for_clean_split() {
2989        let p = 4usize;
2990        let structural_rank = 2usize;
2991        let qs = Mat::<f64>::identity(p, p);
2992        let mut r0 = Mat::<f64>::zeros(2, p);
2993        r0[(0, 0)] = 1.0;
2994        r0[(1, 1)] = 2.0;
2995
2996        let m = metrics_for(&qs, &[r0], structural_rank, p);
2997        assert!(m.max_abs_sq <= 1e-16);
2998        assert!(m.max_rel_sq <= 1e-16);
2999        assert!(m.max_cross_gram_abs <= 1e-16);
3000    }
3001
3002    #[test]
3003    fn subspace_leakage_detects_null_column_energy() {
3004        let p = 4usize;
3005        let structural_rank = 2usize;
3006        let qs = Mat::<f64>::identity(p, p);
3007        let mut r0 = Mat::<f64>::zeros(1, p);
3008        r0[(0, 2)] = 3.0;
3009
3010        let m = metrics_for(&qs, &[r0], structural_rank, p);
3011        assert!(m.max_abs_sq > 0.0);
3012        assert!(m.max_rel_sq > 0.99);
3013    }
3014
3015    #[test]
3016    fn subspace_leakage_detects_qp_qn_nonorthogonality() {
3017        let p = 3usize;
3018        let structural_rank = 1usize;
3019        let mut qs = Mat::<f64>::identity(p, p);
3020        qs[(0, 1)] = 0.2;
3021        let r0 = Mat::<f64>::zeros(1, p);
3022
3023        let m = metrics_for(&qs, &[r0], structural_rank, p);
3024        assert!(m.max_cross_gram_abs > 1e-3);
3025    }
3026
3027    #[test]
3028    fn subspace_split_admits_near_threshold_manifold_leakage_1802() {
3029        // #1802: on sphere / Duchon / spline-on-sphere bases the REML outer
3030        // startup rejected EVERY candidate seed with
3031        //   "Reparameterization subspace split is inconsistent:
3032        //    max null leakage 1.174e-4 (rel 1.031e-4, worst penalty 0),
3033        //    max |Qp'Qn| 4.316e-16"
3034        // The split is perfectly ORTHOGONAL (|Qp'Qn| ≈ 4e-16); the only tripped
3035        // quantity is the transformed-root null-block leakage, whose RELATIVE
3036        // energy (1.031e-4)² ≈ 1.06e-8 sits right at `REL_PSD_FLOOR`. That is the
3037        // eigensolver's own numerically-PSD floor — the same floor
3038        // `classify_eigenvalues_strict` uses to declare a mode null — so a
3039        // manifold penalty whose Laplace-Beltrami spectrum decays through the
3040        // rank threshold with no clean gap MUST be admitted. Reproduce that exact
3041        // signature and assert the guard accepts it.
3042        let p = 40usize;
3043        let structural_rank = p - 1;
3044        // Unit-amplitude range column + a null column at √(REL_PSD_FLOOR)
3045        // amplitude, so the null-block relative energy lands at ~REL_PSD_FLOOR.
3046        let null_amp = (1.06e-8_f64).sqrt();
3047        let mut rs = Mat::<f64>::zeros(1, p);
3048        rs[(0, 0)] = 1.0;
3049        rs[(0, p - 1)] = null_amp;
3050        let qs = Mat::<f64>::identity(p, p);
3051        let leakage = metrics_for(&qs, &[rs], structural_rank, p);
3052        // The leakage reproduces the observed band: above the OLD fixed 1e-10
3053        // tolerance (which rejected the sphere fit) yet a benign ~REL_PSD_FLOOR.
3054        assert!(
3055            leakage.max_rel_sq > 1e-10 && leakage.max_rel_sq < 1e-6,
3056            "reproduced leakage should sit in the near-REL_PSD_FLOOR band, got {:.3e}",
3057            leakage.max_rel_sq
3058        );
3059        assert!(leakage.max_cross_gram_abs <= 1e-12);
3060        assert!(
3061            subspace_split_is_consistent(&leakage, p),
3062            "near-REL_PSD_FLOOR null leakage on a manifold basis must be admitted \
3063             (rel_sq={:.3e}, tol={:.3e})",
3064            leakage.max_rel_sq,
3065            (p as f64) * REL_PSD_FLOOR,
3066        );
3067    }
3068
3069    #[test]
3070    fn subspace_split_still_rejects_genuine_inconsistency_1802() {
3071        // The #1802 relaxation only widens the leakage tolerance to the
3072        // classifier's own `p · REL_PSD_FLOOR` floor; it must NOT admit a
3073        // genuinely broken split. A whole penalized mode dumped into the null
3074        // block (O(1) relative leakage) is still rejected.
3075        let p = 40usize;
3076        let structural_rank = p - 1;
3077        let mut rs = Mat::<f64>::zeros(1, p);
3078        rs[(0, p - 1)] = 1.0; // all penalty-root energy in the null column
3079        let qs = Mat::<f64>::identity(p, p);
3080        let leakage = metrics_for(&qs, &[rs], structural_rank, p);
3081        assert!(leakage.max_rel_sq > 0.99);
3082        assert!(
3083            !subspace_split_is_consistent(&leakage, p),
3084            "an O(1) null-block leakage is a real inconsistency and must be rejected"
3085        );
3086
3087        // A non-orthogonal split is rejected regardless of root leakage.
3088        let mut qs_bad = Mat::<f64>::identity(3, 3);
3089        qs_bad[(0, 1)] = 0.2;
3090        let clean = Mat::<f64>::zeros(1, 3);
3091        let leakage2 = metrics_for(&qs_bad, &[clean], 1, 3);
3092        assert!(leakage2.max_cross_gram_abs > 1e-3);
3093        assert!(
3094            !subspace_split_is_consistent(&leakage2, 3),
3095            "a non-orthogonal Qp/Qn split must be rejected"
3096        );
3097    }
3098
3099    #[test]
3100    fn u_truncated_is_transformed_frame_in_nonzero_case() {
3101        let p = 3usize;
3102        let rs_list = vec![array![[1.0, 0.0, 0.0]]];
3103        let canonical = canonical_from_roots(&rs_list, p);
3104        let lambdas = vec![2.0];
3105        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3106            .expect("precompute invariant");
3107        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3108            .expect("stable reparam");
3109
3110        let expected = rep.qs.t().dot(&inv.split.q_null);
3111        let diff = &rep.u_truncated - &expected;
3112        let max_abs = inf_norm(diff.iter().copied());
3113        assert!(
3114            max_abs <= 1e-10,
3115            "u_truncated frame mismatch: max_abs={max_abs}"
3116        );
3117    }
3118
3119    #[test]
3120    fn infinite_lambda_keeps_range_penalty_block_finite_1379() {
3121        // gam#1379 / gam#1074: a genuinely infinite λ = exp(ρ) is NOT silently
3122        // clamped to a finite ceiling. The original #1379 fix added a 1e300
3123        // ceiling so `∞ · 0` could not poison the range block Σ_k λ_k S_k, but
3124        // #1074 DELETED that clamp on purpose (see the comment at the top of
3125        // `stable_reparameterizationwith_invariant`): masking ∞ hid the real
3126        // defect — the outer optimizer driving a redundant/unidentified penalty
3127        // direction off to ∞ instead of that direction being detected and
3128        // dropped. With the clamp gone, a literal `f64::INFINITY` λ surfaces as
3129        // a clean, detectable error (the eigensolver rejects the NaN-poisoned
3130        // block) rather than a silent finite success. Pin that contract: ∞ must
3131        // ERROR, not be quietly clamped.
3132        //
3133        // Fixture: two penalties on a 3-wide block. The first penalizes only
3134        // coordinate 0 (so its block S_k has structural zeros everywhere except
3135        // [0,0]); give it λ = +∞. The second penalizes coordinate 1 at a normal
3136        // λ.
3137        let p = 3usize;
3138        let rs_list = vec![array![[1.0, 0.0, 0.0]], array![[0.0, 1.0, 0.0]]];
3139        let canonical = canonical_from_roots(&rs_list, p);
3140        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3141            .expect("precompute invariant");
3142
3143        let lambdas_inf = vec![f64::INFINITY, 3.0];
3144        let inf_result =
3145            stable_reparameterizationwith_invariant(&canonical, &lambdas_inf, p, &inv, None);
3146        assert!(
3147            inf_result.is_err(),
3148            "an infinite lambda must surface as an error, not be silently clamped (#1074)"
3149        );
3150
3151        // A finite (even very large) λ must still produce an all-finite reparam:
3152        // the function is robust to large-but-finite penalties; only the
3153        // non-finite input is rejected.
3154        let lambdas_big = vec![1e300_f64, 3.0];
3155        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas_big, p, &inv, None)
3156            .expect("stable reparam at large-but-finite lambda");
3157        assert!(
3158            rep.s_transformed.iter().all(|v| v.is_finite()),
3159            "transformed penalty must be finite at large-but-finite lambda"
3160        );
3161        assert!(
3162            rep.qs.iter().all(|v| v.is_finite()),
3163            "reparam rotation must be finite at large-but-finite lambda"
3164        );
3165        assert!(
3166            rep.log_det.is_finite(),
3167            "penalty log-det must be finite at large-but-finite lambda"
3168        );
3169        assert!(
3170            rep.det1.iter().all(|v| v.is_finite()),
3171            "penalty log-det derivatives must be finite at large-but-finite lambda"
3172        );
3173    }
3174
3175    #[test]
3176    fn u_truncated_is_identitywhen_no_penalties() {
3177        let p = 4usize;
3178        let canonical: Vec<CanonicalPenalty> = Vec::new();
3179        let lambdas: Vec<f64> = Vec::new();
3180        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3181            .expect("precompute invariant");
3182        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3183            .expect("stable reparam");
3184        assert_eq!(rep.u_truncated, Array2::<f64>::eye(p));
3185    }
3186
3187    #[test]
3188    fn dense_shrinkage_floor_skips_structurally_unpenalized_range_columns() {
3189        let p = 3usize;
3190        let canonical = canonical_from_roots(&[array![[1.0, 0.0, 0.0]]], p);
3191        let invariant = super::ReparamInvariant {
3192            split: super::SubspaceSplit {
3193                q_pen: array![[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]],
3194                q_null: array![[0.0], [0.0], [1.0]],
3195            },
3196            qs_base: Array2::eye(p),
3197            has_nonzero: true,
3198            max_balanced_eigenvalue: 1.0,
3199        };
3200
3201        let rep =
3202            stable_reparameterizationwith_invariant(&canonical, &[2.0], p, &invariant, Some(1e-6))
3203                .expect("stable reparameterization");
3204        assert!(rep.s_transformed[[0, 0]] > 2.0);
3205        assert!(
3206            rep.s_transformed[[1, 1]] <= 1e-11,
3207            "structurally unpenalized range coordinate received shrinkage ridge: {}",
3208            rep.s_transformed[[1, 1]]
3209        );
3210    }
3211
3212    #[test]
3213    fn kronecker_shrinkage_floor_preserves_joint_null_space() {
3214        let marginal_designs = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
3215        let marginal_penalties = vec![
3216            array![[0.0, 0.0], [0.0, 2.0]],
3217            array![[0.0, 0.0], [0.0, 3.0]],
3218        ];
3219        let marginal_dims = vec![2usize, 2usize];
3220        let lambdas = vec![5.0, 7.0];
3221
3222        let rep = super::kronecker_reparameterization_engine(
3223            &marginal_designs,
3224            &marginal_penalties,
3225            &marginal_dims,
3226            &lambdas,
3227            false,
3228            Some(1e-6),
3229        )
3230        .expect("kronecker reparameterization");
3231        assert!(rep.penalty_shrinkage_ridge > 0.0);
3232
3233        let s = rep.materialize_s_transformed(&lambdas);
3234        assert!(
3235            s[[0, 0]].abs() <= 1e-14,
3236            "joint tensor null direction must remain unpenalized, got {}",
3237            s[[0, 0]]
3238        );
3239        assert!(s[[1, 1]] > lambdas[1] * 3.0);
3240        assert!(s[[2, 2]] > lambdas[0] * 2.0);
3241        assert!(s[[3, 3]] > lambdas[0] * 2.0 + lambdas[1] * 3.0);
3242
3243        let tensor_roots = vec![
3244            array![
3245                [0.0, 0.0, 2.0_f64.sqrt(), 0.0],
3246                [0.0, 0.0, 0.0, 2.0_f64.sqrt()]
3247            ],
3248            array![
3249                [0.0, 3.0_f64.sqrt(), 0.0, 0.0],
3250                [0.0, 0.0, 0.0, 3.0_f64.sqrt()]
3251            ],
3252        ];
3253        let dense = rep
3254            .materialize_dense_artifact_result(&tensor_roots, &lambdas, 4)
3255            .expect("dense artifact materialization");
3256        assert_eq!(dense.e_transformed.nrows(), 3);
3257        assert_eq!(dense.u_truncated.ncols(), 1);
3258    }
3259
3260    #[test]
3261    fn kronecker_memoized_invariant_is_bit_identical_to_unmemoized_engine() {
3262        // The hot-path memoization (compute the marginal eigensystems /
3263        // reparameterized marginals once, reuse across outer iterates) must
3264        // produce a KroneckerReparamResult that is *bit-identical* to the
3265        // unmemoized engine for the same marginal data and λ — the cached work
3266        // is literally the same eigendecomposition. Cover several λ on one fixed
3267        // invariant structure (the realistic outer-loop pattern).
3268        let marginal_designs = vec![
3269            array![[1.0, 0.3, -0.2], [0.4, 1.0, 0.1], [-0.1, 0.2, 1.0]],
3270            array![[1.0, -0.5], [0.2, 1.0], [0.7, 0.3]],
3271        ];
3272        let marginal_penalties = vec![
3273            array![[2.0, -1.0, 0.0], [-1.0, 2.0, -1.0], [0.0, -1.0, 1.0]],
3274            array![[3.0, -1.5], [-1.5, 3.0]],
3275        ];
3276        let marginal_dims = vec![3usize, 2usize];
3277
3278        let invariant = super::KroneckerInvariantStructure::compute(
3279            &marginal_designs,
3280            &marginal_penalties,
3281            &marginal_dims,
3282        )
3283        .expect("invariant structure");
3284
3285        for lambdas in [
3286            vec![5.0, 7.0],
3287            vec![0.0, 7.0],
3288            vec![5.0, 0.0],
3289            vec![1e-3, 1e3],
3290        ] {
3291            for floor in [None, Some(1e-6)] {
3292                let unmemoized = super::kronecker_reparameterization_engine(
3293                    &marginal_designs,
3294                    &marginal_penalties,
3295                    &marginal_dims,
3296                    &lambdas,
3297                    true,
3298                    floor,
3299                )
3300                .expect("unmemoized engine");
3301                let memoized = super::kronecker_reparameterization_engine_with_invariant(
3302                    &invariant,
3303                    &marginal_dims,
3304                    &lambdas,
3305                    true,
3306                    floor,
3307                )
3308                .expect("memoized engine");
3309
3310                assert_eq!(memoized.log_det.to_bits(), unmemoized.log_det.to_bits());
3311                assert_eq!(
3312                    memoized.penalty_shrinkage_ridge.to_bits(),
3313                    unmemoized.penalty_shrinkage_ridge.to_bits()
3314                );
3315                for (a, b) in memoized.det1.iter().zip(unmemoized.det1.iter()) {
3316                    assert_eq!(a.to_bits(), b.to_bits());
3317                }
3318                for (a, b) in memoized.det2.iter().zip(unmemoized.det2.iter()) {
3319                    assert_eq!(a.to_bits(), b.to_bits());
3320                }
3321                for (ma, ua) in memoized
3322                    .reparameterized_marginals
3323                    .iter()
3324                    .zip(unmemoized.reparameterized_marginals.iter())
3325                {
3326                    for (a, b) in ma.iter().zip(ua.iter()) {
3327                        assert_eq!(a.to_bits(), b.to_bits());
3328                    }
3329                }
3330                for (mq, uq) in memoized
3331                    .marginal_qs
3332                    .iter()
3333                    .zip(unmemoized.marginal_qs.iter())
3334                {
3335                    for (a, b) in mq.iter().zip(uq.iter()) {
3336                        assert_eq!(a.to_bits(), b.to_bits());
3337                    }
3338                }
3339            }
3340        }
3341    }
3342
3343    #[test]
3344    fn kronecker_double_penalty_shrinks_only_joint_null_space() {
3345        let marginal_designs = vec![Array2::<f64>::eye(2), Array2::<f64>::eye(2)];
3346        let marginal_penalties = vec![
3347            array![[0.0, 0.0], [0.0, 2.0]],
3348            array![[0.0, 0.0], [0.0, 3.0]],
3349        ];
3350        let marginal_dims = vec![2usize, 2usize];
3351        let lambdas = vec![5.0, 7.0, 11.0];
3352
3353        let rep = super::kronecker_reparameterization_engine(
3354            &marginal_designs,
3355            &marginal_penalties,
3356            &marginal_dims,
3357            &lambdas,
3358            true,
3359            None,
3360        )
3361        .expect("kronecker reparameterization");
3362
3363        let s = rep.materialize_s_transformed(&lambdas);
3364        let expected = [11.0, 21.0, 10.0, 31.0];
3365        for (idx, expected_diag) in expected.iter().copied().enumerate() {
3366            assert!(
3367                (s[[idx, idx]] - expected_diag).abs() <= 1e-12,
3368                "diagonal {idx} got {}, expected {expected_diag}",
3369                s[[idx, idx]]
3370            );
3371        }
3372
3373        let expected_logdet: f64 = expected.iter().map(|v| f64::ln(*v)).sum();
3374        assert!((rep.log_det - expected_logdet).abs() <= 1e-12);
3375        assert!(
3376            (rep.det1[2] - 1.0).abs() <= 1e-12,
3377            "double-penalty derivative must come only from the joint null mode, got {}",
3378            rep.det1[2]
3379        );
3380        assert!(rep.det2[[2, 2]].abs() <= 1e-12);
3381
3382        let tensor_roots = vec![
3383            array![
3384                [0.0, 0.0, 2.0_f64.sqrt(), 0.0],
3385                [0.0, 0.0, 0.0, 2.0_f64.sqrt()]
3386            ],
3387            array![
3388                [0.0, 3.0_f64.sqrt(), 0.0, 0.0],
3389                [0.0, 0.0, 0.0, 3.0_f64.sqrt()]
3390            ],
3391        ];
3392        let dense = rep
3393            .materialize_dense_artifact_result(&tensor_roots, &lambdas, 4)
3394            .expect("dense artifact materialization");
3395        for (idx, expected_diag) in expected.iter().copied().enumerate() {
3396            assert!(
3397                (dense.s_transformed[[idx, idx]] - expected_diag).abs() <= 1e-12,
3398                "dense artifact diagonal {idx} got {}, expected {expected_diag}",
3399                dense.s_transformed[[idx, idx]]
3400            );
3401        }
3402    }
3403
3404    #[test]
3405    fn transformed_penalty_is_diagonal_in_transformed_frame() {
3406        let p = 3usize;
3407        let inv_sqrt2 = 2.0_f64.sqrt().recip();
3408        // Penalize a rotated direction in original space so Qs is non-trivial.
3409        let rs_list = vec![array![[inv_sqrt2, inv_sqrt2, 0.0]]];
3410        let canonical = canonical_from_roots(&rs_list, p);
3411        let lambdas = vec![4.0];
3412        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3413            .expect("precompute invariant");
3414        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3415            .expect("stable reparam");
3416
3417        assert_eq!(rep.e_transformed.nrows(), 1);
3418        assert!(rep.e_transformed[[0, 0]].abs() > 0.0);
3419        assert!(rep.e_transformed[[0, 1]].abs() <= 1e-12);
3420        assert!(rep.e_transformed[[0, 2]].abs() <= 1e-12);
3421        // Exact pseudo-logdet on the structural penalized block has no
3422        // delta-dependent nullspace normalization.
3423        let expected_det1 = 1.0_f64;
3424        assert!((rep.det1[0] - expected_det1).abs() <= 1e-12);
3425
3426        let s = rep.s_transformed;
3427        let mut max_offdiag = 0.0_f64;
3428        for i in 0..p {
3429            for j in 0..p {
3430                if i != j {
3431                    max_offdiag = max_offdiag.max(s[[i, j]].abs());
3432                }
3433            }
3434        }
3435        assert!(
3436            max_offdiag <= 1e-10,
3437            "transformed penalty should be diagonal, max offdiag={max_offdiag}"
3438        );
3439        assert!(s[[1, 1]].abs() <= 1e-10);
3440        assert!(s[[2, 2]].abs() <= 1e-10);
3441    }
3442
3443    #[test]
3444    fn det1_matches_rank_for_single_full_rank_penalty() {
3445        let p = 2usize;
3446        let inv_sqrt2 = 2.0_f64.sqrt().recip();
3447        // Q^T for a 45-degree rotation.
3448        let q_t = [[inv_sqrt2, inv_sqrt2], [-inv_sqrt2, inv_sqrt2]];
3449        // R = diag(3, 1) * Q^T gives S = Q * diag(9, 1) * Q^T.
3450        let rs = array![
3451            [3.0 * q_t[0][0], 3.0 * q_t[0][1]],
3452            [1.0 * q_t[1][0], 1.0 * q_t[1][1]]
3453        ];
3454        let rs_list = vec![rs];
3455        let canonical = canonical_from_roots(&rs_list, p);
3456        let lambdas = vec![5.0];
3457
3458        let inv = precompute_reparam_invariant_from_canonical(&canonical, p)
3459            .expect("precompute invariant");
3460        let rep = stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &inv, None)
3461            .expect("stable reparam");
3462
3463        assert_eq!(rep.e_transformed.nrows(), p);
3464        let det1 = rep.det1[0];
3465        // Exact pseudo-logdet on the structural penalized block:
3466        //   det1 = lambda * sum_l d_l / (lambda*d_l)
3467        // where d_l are eigenvalues of S_k.
3468        let s_k_eigs = [9.0_f64, 1.0_f64];
3469        let lambda = 5.0_f64;
3470        let expected_det1: f64 = s_k_eigs.iter().map(|&d| lambda * d / (lambda * d)).sum();
3471        assert!(
3472            (det1 - expected_det1).abs() <= 1e-12,
3473            "expected det1={expected_det1}, got {det1}",
3474        );
3475
3476        let s = rep.s_transformed;
3477        assert!(s[[0, 1]].abs() <= 1e-10);
3478        assert!(s[[1, 0]].abs() <= 1e-10);
3479        assert!(s[[0, 0]] > 0.0);
3480        assert!(s[[1, 1]] > 0.0);
3481    }
3482
3483    #[test]
3484    fn kronecker_reparam_logdet_matches_dense() {
3485        // 2D tensor product: q1=3, q2=4.
3486        // Marginal penalties: second-order difference matrices.
3487        let q1 = 3;
3488        let q2 = 4;
3489        let s1 = {
3490            let mut s = Array2::<f64>::zeros((q1, q1));
3491            // D2' D2 for order 2 on 3 points: [[1,-2,1],[-2,4,-2],[1,-2,1]]... simplified
3492            s[[0, 0]] = 1.0;
3493            s[[0, 1]] = -1.0;
3494            s[[1, 0]] = -1.0;
3495            s[[1, 1]] = 2.0;
3496            s[[1, 2]] = -1.0;
3497            s[[2, 1]] = -1.0;
3498            s[[2, 2]] = 1.0;
3499            s
3500        };
3501        let s2 = {
3502            let mut s = Array2::<f64>::zeros((q2, q2));
3503            s[[0, 0]] = 1.0;
3504            s[[0, 1]] = -1.0;
3505            s[[1, 0]] = -1.0;
3506            s[[1, 1]] = 2.0;
3507            s[[1, 2]] = -1.0;
3508            s[[2, 1]] = -1.0;
3509            s[[2, 2]] = 2.0;
3510            s[[2, 3]] = -1.0;
3511            s[[3, 2]] = -1.0;
3512            s[[3, 3]] = 1.0;
3513            s
3514        };
3515
3516        let lambdas = [2.5, 1.3];
3517        // Build dense Kronecker penalty: λ1 (S1⊗I) + λ2 (I⊗S2).
3518        let p = q1 * q2;
3519        let i1 = Array2::<f64>::eye(q1);
3520        let i2 = Array2::<f64>::eye(q2);
3521        let pen0 = kronecker_product(&s1, &i2);
3522        let pen1 = kronecker_product(&i1, &s2);
3523        let mut s_dense = Array2::<f64>::zeros((p, p));
3524        s_dense.scaled_add(lambdas[0], &pen0);
3525        s_dense.scaled_add(lambdas[1], &pen1);
3526
3527        // Dense eigendecomposition for reference pseudo-logdet.
3528        let (evals_dense, _): (ndarray::Array1<f64>, ndarray::Array2<f64>) =
3529            s_dense.eigh(faer::Side::Lower).unwrap();
3530        let tol = 1e-12;
3531        let ref_logdet: f64 = evals_dense
3532            .iter()
3533            .filter(|&&v: &&f64| v > tol)
3534            .map(|&v: &f64| v.ln())
3535            .sum();
3536
3537        // Kronecker reparameterization engine.
3538        let marginal_designs = vec![
3539            Array2::<f64>::eye(q1), // dummy designs
3540            Array2::<f64>::eye(q2),
3541        ];
3542        let marginal_penalties = vec![s1, s2];
3543        let kron_result = super::kronecker_reparameterization_engine(
3544            &marginal_designs,
3545            &marginal_penalties,
3546            &[q1, q2],
3547            &lambdas,
3548            false,
3549            None,
3550        )
3551        .unwrap();
3552
3553        let diff = (kron_result.log_det - ref_logdet).abs();
3554        assert!(
3555            diff < 1e-8,
3556            "Kronecker logdet {:.10} vs dense {:.10}, diff={:.3e}",
3557            kron_result.log_det,
3558            ref_logdet,
3559            diff,
3560        );
3561
3562        // Check derivatives via central FD in rho-space (rho = log lambda).
3563        let rhos: Vec<f64> = lambdas.iter().map(|&l| l.ln()).collect();
3564        let eps = 1e-5;
3565        for k in 0..2 {
3566            let mut rho_plus = rhos.clone();
3567            rho_plus[k] += eps;
3568            let mut rho_minus = rhos.clone();
3569            rho_minus[k] -= eps;
3570            let lam_plus: Vec<f64> = rho_plus.iter().map(|&r| r.exp()).collect();
3571            let lam_minus: Vec<f64> = rho_minus.iter().map(|&r| r.exp()).collect();
3572            let result_plus = super::kronecker_reparameterization_engine(
3573                &marginal_designs,
3574                &marginal_penalties,
3575                &[q1, q2],
3576                &lam_plus,
3577                false,
3578                None,
3579            )
3580            .unwrap();
3581            let result_minus = super::kronecker_reparameterization_engine(
3582                &marginal_designs,
3583                &marginal_penalties,
3584                &[q1, q2],
3585                &lam_minus,
3586                false,
3587                None,
3588            )
3589            .unwrap();
3590            let fd_deriv = (result_plus.log_det - result_minus.log_det) / (2.0 * eps);
3591            let analytic_deriv = kron_result.det1[k];
3592            let rel_err = if analytic_deriv.abs() > 1e-10 {
3593                (fd_deriv - analytic_deriv).abs() / analytic_deriv.abs()
3594            } else {
3595                (fd_deriv - analytic_deriv).abs()
3596            };
3597            assert!(
3598                rel_err < 1e-4,
3599                "det1[{k}] mismatch: analytic={:.8}, fd={:.8}, rel_err={:.3e}",
3600                analytic_deriv,
3601                fd_deriv,
3602                rel_err,
3603            );
3604        }
3605    }
3606
3607    #[test]
3608    fn classify_strict_rejects_nan_eigenvalue() {
3609        let mut eigs = [1.0, f64::NAN, 0.5];
3610        match classify_eigenvalues_strict(&mut eigs, "test_nan") {
3611            Err(EstimationError::PenaltySpectrumNonFinite {
3612                context,
3613                index,
3614                value,
3615            }) => {
3616                assert_eq!(context, "test_nan");
3617                assert_eq!(index, 1);
3618                assert!(value.is_nan());
3619            }
3620            other => panic!("expected PenaltySpectrumNonFinite, got {:?}", other),
3621        }
3622    }
3623
3624    #[test]
3625    fn classify_strict_rejects_inf_eigenvalue() {
3626        let mut eigs = [1.0, 0.5, f64::INFINITY];
3627        match classify_eigenvalues_strict(&mut eigs, "test_inf") {
3628            Err(EstimationError::PenaltySpectrumNonFinite { index, value, .. }) => {
3629                assert_eq!(index, 2);
3630                assert!(value.is_infinite());
3631            }
3632            other => panic!("expected PenaltySpectrumNonFinite, got {:?}", other),
3633        }
3634    }
3635
3636    #[test]
3637    fn classify_strict_rejects_materially_indefinite() {
3638        // -1e-2 with scale ~1.0 is well above any reasonable roundoff tolerance.
3639        let mut eigs = [1.0, -1e-2, 0.5];
3640        match classify_eigenvalues_strict(&mut eigs, "test_indef") {
3641            Err(EstimationError::PenaltySpectrumIndefinite {
3642                context,
3643                index,
3644                value,
3645                ..
3646            }) => {
3647                assert_eq!(context, "test_indef");
3648                assert_eq!(index, 1);
3649                assert!((value + 1e-2).abs() <= 1e-15);
3650            }
3651            other => panic!("expected PenaltySpectrumIndefinite, got {:?}", other),
3652        }
3653    }
3654
3655    #[test]
3656    fn classify_strict_accepts_roundoff_negative() {
3657        // -1e-16 * scale is well within tol = 64 * eps * p * scale.
3658        let scale = 1.0_f64;
3659        let roundoff = -1e-16 * scale;
3660        let mut eigs = [scale, 0.5 * scale, roundoff, 0.25 * scale];
3661        classify_eigenvalues_strict(&mut eigs, "test_roundoff").expect("roundoff must classify");
3662        // The roundoff eigenvalue is snapped to exact zero.
3663        assert_eq!(eigs[2], 0.0);
3664        // Strictly positive entries must be preserved.
3665        assert!(eigs[0] > 0.0 && eigs[1] > 0.0 && eigs[3] > 0.0);
3666    }
3667
3668    #[test]
3669    fn classify_strict_accepts_extreme_lambda_assembly_noise_1619() {
3670        // #1619: high-rank thin-plate / Duchon penalties (p≈200) assembled and
3671        // reparameterized at extreme λ produce float-noise negative eigenvalues at
3672        // ~1e-11 relative to the spectrum scale (~1e13 there). The bare machine-ε
3673        // floor (~12×ε relative ≈ 1e-12) rejected these as "indefinite" and
3674        // spuriously failed the inner P-IRLS solve. They are PSD to numerical
3675        // precision and must be snapped to zero, not rejected.
3676        let scale = 8.509e12_f64;
3677        // Worst (eig, scale) pair observed in the issue: eig/scale ≈ -7.7e-11.
3678        let noise = -6.546e2_f64;
3679        assert!(
3680            (noise.abs() / scale) < 1.0e-10,
3681            "fixture must reproduce the ~1e-11-relative noise from #1619"
3682        );
3683        let mut eigs = vec![scale, 0.5 * scale, noise, 0.1 * scale];
3684        classify_eigenvalues_strict(&mut eigs, "range penalty block")
3685            .expect("a ~1e-11-relative roundoff-negative eigenvalue must be accepted (#1619)");
3686        // The roundoff-negative eigenvalue is snapped to exact zero.
3687        assert_eq!(eigs[2], 0.0);
3688        // Strictly positive entries are preserved.
3689        assert!(eigs[0] > 0.0 && eigs[1] > 0.0 && eigs[3] > 0.0);
3690    }
3691
3692    #[test]
3693    fn classify_strict_snaps_subtol_positive_to_zero() {
3694        // Positive eigenvalues below the tolerance are also snapped to exact 0
3695        // so downstream rank counts and pseudo-logdets are deterministic.
3696        let scale = 10.0_f64;
3697        let subtol = 1e-15 * scale;
3698        let mut eigs = [scale, subtol];
3699        classify_eigenvalues_strict(&mut eigs, "test_sub_pos").expect("sub-tol positive ok");
3700        assert_eq!(eigs[1], 0.0);
3701    }
3702
3703    /// Build a `CanonicalPenalty` directly from a symmetric `local` matrix.
3704    /// Bypasses root extraction — the redundancy diagnostic only reads `local`
3705    /// and `col_range`, so the rest is filler.
3706    fn canonical_from_local(
3707        local: Array2<f64>,
3708        col_range: std::ops::Range<usize>,
3709        total_dim: usize,
3710    ) -> CanonicalPenalty {
3711        let block_dim = local.nrows();
3712        // A trivially valid root: zero rank. The diagnostic doesn't read root.
3713        let root = Array2::<f64>::zeros((0, block_dim));
3714        CanonicalPenalty {
3715            root,
3716            col_range,
3717            total_dim,
3718            nullity: 0,
3719            local,
3720            prior_mean: Array1::zeros(block_dim),
3721            positive_eigenvalues: Vec::new(),
3722            op: None,
3723        }
3724    }
3725
3726    #[test]
3727    fn report_penalty_pair_redundancy_detects_identical_pair() {
3728        // Penalty 0: a "generic" SPD matrix on cols 0..3.
3729        let s0 = ndarray::array![[2.0, 0.5, 0.0], [0.5, 1.0, 0.25], [0.0, 0.25, 1.5],];
3730        // Penalties 1 and 2: identical block-local penalty on the SAME col_range.
3731        // This is the Z₂-symmetric saddle scenario.
3732        let s_shared = ndarray::array![[1.0, -0.5, 0.0], [-0.5, 2.0, -0.5], [0.0, -0.5, 1.0],];
3733
3734        let bundle = vec![
3735            canonical_from_local(s0, 0..3, 3),
3736            canonical_from_local(s_shared.clone(), 0..3, 3),
3737            canonical_from_local(s_shared, 0..3, 3),
3738        ];
3739
3740        let redundant = report_penalty_pair_redundancy(&bundle);
3741
3742        // Exactly one redundant pair: (1, 2). Pairs (0, 1) and (0, 2) involve
3743        // distinct matrices and must NOT be flagged.
3744        assert_eq!(
3745            redundant.len(),
3746            1,
3747            "expected exactly one redundant pair, got {:?}",
3748            redundant
3749        );
3750        let (i, j, cos) = redundant[0];
3751        assert_eq!((i, j), (1, 2));
3752        assert!(
3753            cos > 1.0 - 1e-12,
3754            "cosine for identical penalties should be ~1.0, got {cos}"
3755        );
3756    }
3757
3758    #[test]
3759    fn report_penalty_pair_redundancy_skips_different_col_ranges() {
3760        // Two identical local matrices but on disjoint col_ranges. The
3761        // function must NOT flag them — they live in different parameter
3762        // subspaces by construction.
3763        let s = ndarray::array![[1.0, 0.0], [0.0, 1.0]];
3764        let bundle = vec![
3765            canonical_from_local(s.clone(), 0..2, 4),
3766            canonical_from_local(s, 2..4, 4),
3767        ];
3768        let redundant = report_penalty_pair_redundancy(&bundle);
3769        assert!(
3770            redundant.is_empty(),
3771            "different col_ranges must not be flagged"
3772        );
3773    }
3774}