Skip to main content

gam_problem/
joint_penalty.rs

1//! Joint (cross-block) penalty specifications.
2//!
3//! After the `T^T S_j T` pullback used by the V+M / SMGS-exact compile path,
4//! a single penalty `S_j` no longer has its nonzero region confined to one
5//! `ParameterBlockSpec`: the pullback by the inter-block coupling matrix `T`
6//! distributes weight across the *entire* compiled parameter vector. The
7//! existing `ParameterBlockSpec.penalties: Vec<PenaltyMatrix>` model encodes
8//! a per-block-local penalty (its dim equals the owning block's column count),
9//! so it cannot represent these full-width operators.
10//!
11//! [`JointPenaltySpec`] is the carrier for that case: one dense
12//! `total_compiled × total_compiled` matrix with its own initial smoothing
13//! parameter and structural nullspace dimension. It lives *alongside*, not
14//! *inside*, the per-block specs.
15//!
16//! ## Inner-solve integration
17//!
18//! `inner_blockwise_fit` and the joint-Newton kernels in `custom_family`
19//! consume ordinary block-local penalties as a `&[Array2<f64>]` paired with
20//! per-block `(start, end)` ranges:
21//!
22//! * `apply_joint_block_penalty_into(ranges, s_lambdas, …)` (≈ line 19960)
23//! * `joint_penalty_preconditioner_diag(…)` (≈ line 20067)
24//! * `add_joint_penalty_to_matrix(matrix, ranges, s_lambdas, …)` (≈ line 20132)
25//!
26//! A cross-block dense `S` has no single owning block range, so the solver also
27//! threads a `JointPenaltyBundle` through those helpers as a full-width path
28//! that:
29//!
30//! 1. computes `S · v` as a full `total × total` mat-vec (cf. `fast_av`),
31//! 2. accumulates `diag(S)` into the Jacobi preconditioner over the full
32//!    parameter vector, and
33//! 3. adds `λ · S` to the dense joint Hessian without slicing.
34//!
35//! The remaining construction-site work is to produce the correct
36//! `JointPenaltySpec` instances for each coupled-family compile path; once a
37//! bundle is supplied through `BlockwiseFitOptions::joint_penalties`, the inner
38//! solve consumes its objective, mat-vec, preconditioner, and dense-Hessian
39//! contributions.
40
41use ndarray::{Array2, ArrayView1};
42
43/// A penalty whose support spans the entire compiled parameter vector.
44///
45/// Unlike [`crate::families::custom_family::PenaltyMatrix`], this carries a
46/// single dense `total_compiled × total_compiled` quadratic form — the
47/// shape produced by `T^T S_j T` pullback after the V+M / SMGS-exact
48/// compile. The `nullspace_dim` is the structural dimension of `ker(S)`
49/// as reported by the construction site (rank-revealing on the *pulled-back*
50/// operator, not the pre-pullback `S_j`), so the REML pseudo-logdet can
51/// avoid numerical rank thresholds.
52#[derive(Debug, Clone)]
53pub struct JointPenaltySpec {
54    /// Optional user-visible precision label. Joint penalties that share a
55    /// label share one smoothing parameter (same convention as
56    /// [`crate::families::custom_family::PenaltyMatrix::Labeled`]).
57    pub label: Option<String>,
58    /// Dense symmetric PSD matrix of shape `(total_compiled, total_compiled)`.
59    pub matrix: Array2<f64>,
60    /// Initial value of `log λ` for this penalty.
61    pub initial_log_lambda: f64,
62    /// Structural nullspace dimension of `matrix` (i.e. `total_compiled - rank`).
63    pub nullspace_dim: usize,
64}
65
66/// Reason a [`JointPenaltySpec`] failed validation.
67#[derive(Debug, Clone, PartialEq)]
68pub enum JointPenaltyError {
69    NotSquare {
70        nrows: usize,
71        ncols: usize,
72    },
73    NonFiniteEntry {
74        row: usize,
75        col: usize,
76        value: f64,
77    },
78    InitialLogStrengthOutOfDomain {
79        value: f64,
80    },
81    NotSymmetric {
82        row: usize,
83        col: usize,
84        asymmetry: f64,
85    },
86    NullspaceTooLarge {
87        total: usize,
88        nullspace_dim: usize,
89    },
90    NotPositiveSemidefinite {
91        min_eigenvalue: f64,
92        max_abs_eigenvalue: f64,
93    },
94    NullspaceMismatch {
95        declared: usize,
96        numerical: usize,
97    },
98    EigendecompositionFailed {
99        reason: String,
100    },
101}
102
103impl std::fmt::Display for JointPenaltyError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::NotSquare { nrows, ncols } => {
107                write!(f, "joint penalty matrix is not square: {nrows}x{ncols}")
108            }
109            Self::NonFiniteEntry { row, col, value } => write!(
110                f,
111                "joint penalty matrix has non-finite entry at ({row},{col}): {value}"
112            ),
113            Self::InitialLogStrengthOutOfDomain { value } => {
114                write!(
115                    f,
116                    "joint penalty initial_log_lambda is outside the exact strength domain: {value}"
117                )
118            }
119            Self::NotSymmetric {
120                row,
121                col,
122                asymmetry,
123            } => write!(
124                f,
125                "joint penalty matrix is not symmetric at ({row},{col}): |S - Sᵀ|={asymmetry:.3e}"
126            ),
127            Self::NullspaceTooLarge {
128                total,
129                nullspace_dim,
130            } => write!(
131                f,
132                "joint penalty nullspace_dim={nullspace_dim} exceeds dim={total}"
133            ),
134            Self::NotPositiveSemidefinite {
135                min_eigenvalue,
136                max_abs_eigenvalue,
137            } => write!(
138                f,
139                "joint penalty matrix is not positive semidefinite: min eigenvalue \
140                 {min_eigenvalue:.6e} (max |eigenvalue| {max_abs_eigenvalue:.6e}); the \
141                 penalized objective is unbounded below along the negative mode"
142            ),
143            Self::NullspaceMismatch {
144                declared,
145                numerical,
146            } => write!(
147                f,
148                "joint penalty declares nullspace_dim={declared} but the eigenspectrum has \
149                 {numerical} numerical-zero direction(s); the REML pseudo-logdet rank would \
150                 be wrong"
151            ),
152            Self::EigendecompositionFailed { reason } => write!(
153                f,
154                "joint penalty eigendecomposition failed during validation: {reason}"
155            ),
156        }
157    }
158}
159
160impl std::error::Error for JointPenaltyError {}
161
162impl JointPenaltySpec {
163    /// Symmetry tolerance for [`validate`]. Cross-block pullbacks via `T`
164    /// accumulate roundoff, so an exact symmetric requirement is too tight;
165    /// this matches the floor used by the surrounding penalty code paths.
166    const SYMMETRY_TOL: f64 = 1e-10;
167
168    /// Total compiled parameter count this penalty acts on.
169    #[inline]
170    pub fn dim(&self) -> usize {
171        self.matrix.nrows()
172    }
173
174    /// Trace of the penalty matrix (`Σ_i S[i,i]`).
175    pub fn trace(&self) -> f64 {
176        self.matrix.diag().iter().copied().sum()
177    }
178
179    /// Structural pseudo-rank, derived from the declared `nullspace_dim`.
180    /// This is the rank used by the REML pseudo-logdet under the
181    /// no-numerical-thresholds policy in the surrounding code.
182    #[inline]
183    pub fn pseudo_rank(&self) -> usize {
184        self.dim().saturating_sub(self.nullspace_dim)
185    }
186
187    /// Quadratic form `βᵀ S β`. Mirrors
188    /// [`crate::families::custom_family::PenaltyMatrix::quadratic_form`] for
189    /// the full-width case.
190    pub fn quadratic_form(&self, beta: ArrayView1<'_, f64>) -> f64 {
191        assert_eq!(
192            beta.len(),
193            self.dim(),
194            "joint penalty quadratic form: beta length {} != dim {}",
195            beta.len(),
196            self.dim()
197        );
198        beta.dot(&self.matrix.dot(&beta))
199    }
200
201    /// Validate shape, finiteness, symmetry, and nullspace bookkeeping.
202    pub fn validate(&self) -> Result<(), JointPenaltyError> {
203        let (nrows, ncols) = self.matrix.dim();
204        if nrows != ncols {
205            return Err(JointPenaltyError::NotSquare { nrows, ncols });
206        }
207        if crate::validate_log_strength(self.initial_log_lambda).is_err() {
208            return Err(JointPenaltyError::InitialLogStrengthOutOfDomain {
209                value: self.initial_log_lambda,
210            });
211        }
212        if self.nullspace_dim > nrows {
213            return Err(JointPenaltyError::NullspaceTooLarge {
214                total: nrows,
215                nullspace_dim: self.nullspace_dim,
216            });
217        }
218        for ((row, col), &value) in self.matrix.indexed_iter() {
219            if !value.is_finite() {
220                return Err(JointPenaltyError::NonFiniteEntry { row, col, value });
221            }
222        }
223        for row in 0..nrows {
224            for col in (row + 1)..ncols {
225                let asymmetry = (self.matrix[[row, col]] - self.matrix[[col, row]]).abs();
226                if asymmetry > Self::SYMMETRY_TOL {
227                    return Err(JointPenaltyError::NotSymmetric {
228                        row,
229                        col,
230                        asymmetry,
231                    });
232                }
233            }
234        }
235        // PSD + declared-nullity honesty. An indefinite joint penalty makes
236        // the penalized objective unbounded below along its negative mode
237        // while the pseudo-logdet's positive-eigenspace filter would silently
238        // drop that mode; a wrong declared nullity mis-ranks the REML
239        // pseudo-logdet (the whole point of declaring it is to avoid runtime
240        // thresholds, so it must agree with the spectrum at construction).
241        if nrows > 0 {
242            use gam_linalg::faer_ndarray::FaerEigh;
243            let (eigenvalues, _) =
244                FaerEigh::eigh(&self.matrix, faer::Side::Lower).map_err(|e| {
245                    JointPenaltyError::EigendecompositionFailed {
246                        reason: e.to_string(),
247                    }
248                })?;
249            let max_abs_eigenvalue = eigenvalues
250                .iter()
251                .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
252            // Same relative classification as the REML pseudo-logdet kernel:
253            // the eigensolver noise floor is O(p·ε·‖S‖), never an absolute cut.
254            let tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eigenvalue;
255            if let Some(&min_eigenvalue) = eigenvalues
256                .iter()
257                .filter(|&&ev| ev < -tol)
258                .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
259            {
260                return Err(JointPenaltyError::NotPositiveSemidefinite {
261                    min_eigenvalue,
262                    max_abs_eigenvalue,
263                });
264            }
265            let numerical = eigenvalues.iter().filter(|&&ev| ev <= tol).count();
266            if numerical != self.nullspace_dim {
267                return Err(JointPenaltyError::NullspaceMismatch {
268                    declared: self.nullspace_dim,
269                    numerical,
270                });
271            }
272        }
273        Ok(())
274    }
275}
276
277/// Per-evaluation bundle of cross-block penalties paired with their current
278/// log-smoothing parameters.
279///
280/// The outer optimizer concatenates joint penalty `log λ` values onto the
281/// per-block ρ vector; the inner solver receives this bundle via
282/// [`crate::families::custom_family::BlockwiseFitOptions::joint_penalties`]
283/// and adds the full-width quadratic / matvec / preconditioner / Hessian
284/// contributions to the joint-Newton primitives.
285#[derive(Clone, Debug)]
286pub struct JointPenaltyBundle {
287    specs: std::sync::Arc<Vec<JointPenaltySpec>>,
288    log_lambdas: Vec<f64>,
289    lambdas: Vec<f64>,
290}
291
292impl JointPenaltyBundle {
293    /// Build a bundle, validating the per-penalty `log λ` count and dimension
294    /// agreement against `total_compiled`.
295    pub fn new(
296        specs: std::sync::Arc<Vec<JointPenaltySpec>>,
297        log_lambdas: Vec<f64>,
298        total_compiled: usize,
299    ) -> Result<Self, String> {
300        if specs.len() != log_lambdas.len() {
301            return Err(format!(
302                "joint penalty bundle: {} specs vs {} log_lambdas",
303                specs.len(),
304                log_lambdas.len(),
305            ));
306        }
307        let mut lambdas = Vec::with_capacity(log_lambdas.len());
308        for (i, (spec, &log_lambda)) in specs.iter().zip(log_lambdas.iter()).enumerate() {
309            if spec.dim() != total_compiled {
310                return Err(format!(
311                    "joint penalty {i}: dim {} != total_compiled {}",
312                    spec.dim(),
313                    total_compiled,
314                ));
315            }
316            lambdas.push(
317                crate::checked_exp_log_strength(log_lambda)
318                    .map_err(|error| format!("joint penalty {i} current log-precision: {error}"))?,
319            );
320        }
321        Ok(Self {
322            specs,
323            log_lambdas,
324            lambdas,
325        })
326    }
327
328    #[inline]
329    pub fn len(&self) -> usize {
330        self.specs.len()
331    }
332
333    #[inline]
334    pub fn is_empty(&self) -> bool {
335        self.specs.is_empty()
336    }
337
338    #[inline]
339    pub fn specs(&self) -> &[JointPenaltySpec] {
340        self.specs.as_slice()
341    }
342
343    #[inline]
344    pub fn log_lambdas(&self) -> &[f64] {
345        self.log_lambdas.as_slice()
346    }
347
348    #[inline]
349    pub fn lambdas(&self) -> &[f64] {
350        self.lambdas.as_slice()
351    }
352
353    /// Total joint-penalty contribution to the objective:
354    ///   `½ Σ_j exp(ρ_j) · βᵀ S_j β`.
355    pub fn quadratic(&self, beta: ArrayView1<'_, f64>) -> f64 {
356        let mut total = 0.0;
357        for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
358            total += 0.5 * lam * spec.quadratic_form(beta);
359        }
360        total
361    }
362
363    /// Accumulate `Σ_j exp(ρ_j) · S_j · v` into `out` (additive).
364    pub fn add_apply_into(&self, vector: ArrayView1<'_, f64>, out: &mut ndarray::Array1<f64>) {
365        assert_eq!(out.len(), vector.len());
366        for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
367            let sv = spec.matrix.dot(&vector);
368            out.scaled_add(lam, &sv);
369        }
370    }
371
372    /// Accumulate `Σ_j exp(ρ_j) · diag(S_j)` into `diag` (additive).
373    pub fn add_diag(&self, diag: &mut ndarray::Array1<f64>) {
374        for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
375            for (i, value) in spec.matrix.diag().iter().enumerate() {
376                diag[i] += lam * *value;
377            }
378        }
379    }
380
381    /// Accumulate `Σ_j exp(ρ_j) · S_j` into the full `matrix` (additive).
382    pub fn add_to_matrix(&self, matrix: &mut Array2<f64>) {
383        assert_eq!(matrix.nrows(), matrix.ncols());
384        for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
385            matrix.scaled_add(lam, &spec.matrix);
386        }
387    }
388
389    /// Per-penalty ρ-gradient contribution to the outer objective term:
390    ///   `∂/∂ρ_j [½ exp(ρ_j) βᵀ S_j β] = exp(ρ_j) · ½ βᵀ S_j β`.
391    pub fn rho_objective_gradient(&self, beta: ArrayView1<'_, f64>, out: &mut [f64]) {
392        assert_eq!(out.len(), self.specs.len());
393        for (i, (spec, &lam)) in self.specs.iter().zip(self.lambdas.iter()).enumerate() {
394            out[i] = 0.5 * lam * spec.quadratic_form(beta);
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use ndarray::{Array1, Array2, array};
403
404    /// 4-dim cross-block dense penalty: a rank-2 operator that couples
405    /// indices {0,1} to {2,3} (i.e. nonzero off the 2×2 block diagonal),
406    /// which is exactly the shape that defeats a per-block `PenaltyMatrix`.
407    fn cross_block_spec() -> JointPenaltySpec {
408        // Build S = vᵀv + wᵀw where v and w span across both 2-blocks.
409        let v: Array1<f64> = array![1.0, 0.0, -1.0, 0.0];
410        let w: Array1<f64> = array![0.0, 1.0, 0.0, -1.0];
411        let mut matrix: Array2<f64> = Array2::zeros((4, 4));
412        for i in 0..4 {
413            for j in 0..4 {
414                matrix[[i, j]] = v[i] * v[j] + w[i] * w[j];
415            }
416        }
417        JointPenaltySpec {
418            label: Some("cross_block_pullback".to_string()),
419            matrix,
420            initial_log_lambda: -1.5,
421            nullspace_dim: 2,
422        }
423    }
424
425    #[test]
426    fn cross_block_dense_validates() {
427        let result = cross_block_spec().validate();
428        assert!(
429            result.is_ok(),
430            "valid cross-block spec rejected: {result:?}"
431        );
432    }
433
434    #[test]
435    fn trace_matches_diagonal_sum() {
436        let spec = cross_block_spec();
437        // diag(S) = [v0^2+w0^2, v1^2+w1^2, v2^2+w2^2, v3^2+w3^2] = [1,1,1,1]
438        assert!((spec.trace() - 4.0).abs() < 1e-12);
439    }
440
441    #[test]
442    fn pseudo_rank_uses_declared_nullspace() {
443        let spec = cross_block_spec();
444        assert_eq!(spec.dim(), 4);
445        assert_eq!(spec.pseudo_rank(), 2);
446    }
447
448    #[test]
449    fn quadratic_form_matches_explicit_mat_vec() {
450        let spec = cross_block_spec();
451        // Pick a beta that has support in both 2-blocks.
452        let beta: Array1<f64> = array![0.5, -0.25, 1.0, 0.75];
453        // v·β = 0.5 - 1.0 = -0.5; w·β = -0.25 - 0.75 = -1.0
454        // βᵀSβ = (v·β)^2 + (w·β)^2 = 0.25 + 1.0 = 1.25
455        let q = spec.quadratic_form(beta.view());
456        assert!((q - 1.25).abs() < 1e-12, "got {q}");
457    }
458
459    #[test]
460    fn determinant_zero_for_rank_deficient_matches_nullspace() {
461        use gam_linalg::faer_ndarray::FaerEigh;
462        let spec = cross_block_spec();
463        // Symmetric eigendecomposition; expect exactly nullspace_dim
464        // zeros (up to floating-point), matching the declared rank.
465        let (eigvals, _) =
466            FaerEigh::eigh(&spec.matrix, faer::Side::Lower).expect("symmetric eigh succeeds");
467        let mut sorted: Vec<f64> = eigvals.iter().copied().collect();
468        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
469        let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-10).count();
470        assert_eq!(
471            zeros, spec.nullspace_dim,
472            "spectrum {sorted:?} should have {} near-zeros",
473            spec.nullspace_dim
474        );
475        // Determinant = product of eigenvalues; with a real nullspace
476        // it is exactly zero modulo roundoff.
477        let det: f64 = sorted.iter().product();
478        assert!(det.abs() < 1e-10, "expected ~0 determinant, got {det}");
479    }
480
481    #[test]
482    fn validate_rejects_non_square() {
483        let spec = JointPenaltySpec {
484            label: None,
485            matrix: Array2::zeros((3, 4)),
486            initial_log_lambda: 0.0,
487            nullspace_dim: 0,
488        };
489        assert!(matches!(
490            spec.validate(),
491            Err(JointPenaltyError::NotSquare { nrows: 3, ncols: 4 })
492        ));
493    }
494
495    #[test]
496    fn validate_rejects_non_symmetric() {
497        let mut matrix = Array2::<f64>::zeros((3, 3));
498        matrix[[0, 1]] = 1.0;
499        matrix[[1, 0]] = -1.0;
500        let spec = JointPenaltySpec {
501            label: None,
502            matrix,
503            initial_log_lambda: 0.0,
504            nullspace_dim: 0,
505        };
506        assert!(matches!(
507            spec.validate(),
508            Err(JointPenaltyError::NotSymmetric { .. })
509        ));
510    }
511
512    #[test]
513    fn validate_rejects_oversized_nullspace() {
514        let spec = JointPenaltySpec {
515            label: None,
516            matrix: Array2::zeros((3, 3)),
517            initial_log_lambda: 0.0,
518            nullspace_dim: 4,
519        };
520        assert!(matches!(
521            spec.validate(),
522            Err(JointPenaltyError::NullspaceTooLarge {
523                total: 3,
524                nullspace_dim: 4
525            })
526        ));
527    }
528
529    #[test]
530    fn validate_rejects_initial_log_strength_outside_exact_domain() {
531        let spec = JointPenaltySpec {
532            label: None,
533            matrix: Array2::zeros((2, 2)),
534            initial_log_lambda: f64::NAN,
535            nullspace_dim: 0,
536        };
537        assert!(matches!(
538            spec.validate(),
539            Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
540        ));
541
542        let mut finite_but_too_large = cross_block_spec();
543        finite_but_too_large.initial_log_lambda = crate::LOG_STRENGTH_MAX + 1.0;
544        assert!(matches!(
545            finite_but_too_large.validate(),
546            Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
547        ));
548    }
549
550    #[test]
551    fn bundle_construction_is_atomic_at_exact_log_strength_faces() {
552        let specs = std::sync::Arc::new(vec![cross_block_spec(), cross_block_spec()]);
553        let bundle = JointPenaltyBundle::new(
554            specs.clone(),
555            vec![crate::LOG_STRENGTH_MIN, crate::LOG_STRENGTH_MAX],
556            4,
557        )
558        .expect("closed endpoints");
559        for ((&actual, &log_strength), expected) in bundle
560            .lambdas()
561            .iter()
562            .zip(bundle.log_lambdas())
563            .zip([crate::LOG_STRENGTH_MIN.exp(), crate::LOG_STRENGTH_MAX.exp()])
564        {
565            assert_eq!(actual.to_bits(), expected.to_bits());
566            assert_eq!(actual.to_bits(), log_strength.exp().to_bits());
567        }
568
569        let error = JointPenaltyBundle::new(specs, vec![0.0, crate::LOG_STRENGTH_MAX + 1.0], 4)
570            .expect_err("one invalid coordinate refuses the whole bundle");
571        assert!(error.contains("joint penalty 1 current log-precision"));
572    }
573
574    /// 2-block toy with one full-width SPD joint penalty:
575    ///
576    /// Two scalar blocks (`p = 1 + 1 = 2`). The unpenalised "log-likelihood"
577    /// is a quadratic with optimum at `b`:
578    ///     ℓ(β) = −½ (β − b)ᵀ I (β − b)
579    /// so `−∇ℓ = (β − b)` and `−∇²ℓ = I`. We add ONE joint penalty
580    /// `S = [[2, 1], [1, 2]]` (SPD, full rank, cross-block coupling
581    /// off-diagonal). With `λ = exp(ρ)` the penalised objective is
582    ///     F(β) = ½ (β − b)ᵀ (β − b) + ½ λ βᵀ S β
583    /// whose minimiser solves `(I + λ S) β̂ = b`. We verify the bundle's
584    /// `add_to_matrix` builds the right LHS and the `add_apply_into` /
585    /// `quadratic` helpers agree with the analytic gradient / objective at
586    /// `β̂`.
587    #[test]
588    fn bundle_two_block_minimiser_matches_analytic_solution() {
589        use gam_linalg::faer_ndarray::FaerCholesky;
590        use ndarray::Array2;
591
592        let spec = JointPenaltySpec {
593            label: Some("toy_cross_block".to_string()),
594            matrix: array![[2.0_f64, 1.0], [1.0, 2.0]],
595            initial_log_lambda: 0.0,
596            nullspace_dim: 0,
597        };
598        let log_lambda = -0.4_f64;
599        let lam = log_lambda.exp();
600        let bundle = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![log_lambda], 2)
601            .expect("valid bundle");
602
603        // Build LHS = I + λ S via add_to_matrix (the exact path the inner
604        // Newton uses to assemble the penalised joint Hessian).
605        let mut lhs = Array2::<f64>::eye(2);
606        bundle.add_to_matrix(&mut lhs);
607        // Verify add_to_matrix produced I + λ S.
608        let expected_lhs = array![[1.0 + lam * 2.0, lam], [lam, 1.0 + lam * 2.0]];
609        for r in 0..2 {
610            for c in 0..2 {
611                assert!(
612                    (lhs[[r, c]] - expected_lhs[[r, c]]).abs() < 1e-12,
613                    "lhs[{r}, {c}] = {} expected {}",
614                    lhs[[r, c]],
615                    expected_lhs[[r, c]]
616                );
617            }
618        }
619
620        // Solve (I + λ S) β̂ = b for b = [1.0, -0.5].
621        let b: Array1<f64> = array![1.0, -0.5];
622        let chol = lhs.cholesky(faer::Side::Lower).expect("SPD");
623        let mut rhs_mat = Array2::<f64>::zeros((2, 1));
624        rhs_mat[[0, 0]] = b[0];
625        rhs_mat[[1, 0]] = b[1];
626        let mut beta_mat = rhs_mat.clone();
627        chol.solve_mat_in_place(&mut beta_mat);
628        let beta_hat: Array1<f64> = array![beta_mat[[0, 0]], beta_mat[[1, 0]]];
629
630        // Gradient at β̂: (β̂ − b) + λ S β̂ should be ~0.
631        let mut grad = &beta_hat - &b;
632        bundle.add_apply_into(beta_hat.view(), &mut grad);
633        let grad_inf = grad.iter().map(|v: &f64| v.abs()).fold(0.0_f64, f64::max);
634        assert!(
635            grad_inf < 1e-12,
636            "penalised gradient at analytic minimiser must vanish: {grad_inf:.3e}"
637        );
638
639        // Objective ½(β̂−b)·(β̂−b) + bundle.quadratic(β̂) reproduces the
640        // closed-form minimum value F(β̂) = ½(β̂−b)ᵀ(β̂−b) + ½λ β̂ᵀ S β̂.
641        let resid = &beta_hat - &b;
642        let unpen = 0.5 * resid.dot(&resid);
643        let pen = bundle.quadratic(beta_hat.view());
644        let expected_obj = 0.5 * resid.dot(&resid)
645            + 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
646        assert!(
647            (unpen + pen - expected_obj).abs() < 1e-12,
648            "objective sum {} mismatched expected {}",
649            unpen + pen,
650            expected_obj
651        );
652
653        // Preconditioner diag accumulator: diag(I) + λ diag(S) = [1+2λ, 1+2λ].
654        let mut diag = ndarray::Array1::<f64>::from_elem(2, 1.0);
655        bundle.add_diag(&mut diag);
656        assert!((diag[0] - (1.0 + lam * 2.0)).abs() < 1e-12);
657        assert!((diag[1] - (1.0 + lam * 2.0)).abs() < 1e-12);
658
659        // rho-objective-gradient: ½ λ β̂ᵀ S β̂.
660        let mut rho_grad = vec![0.0_f64];
661        bundle.rho_objective_gradient(beta_hat.view(), &mut rho_grad);
662        let expected_rho_grad =
663            0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
664        assert!(
665            (rho_grad[0] - expected_rho_grad).abs() < 1e-12,
666            "rho-grad {} expected {}",
667            rho_grad[0],
668            expected_rho_grad
669        );
670    }
671
672    #[test]
673    fn bundle_rejects_dim_mismatch() {
674        let spec = JointPenaltySpec {
675            label: None,
676            matrix: Array2::<f64>::eye(3),
677            initial_log_lambda: 0.0,
678            nullspace_dim: 0,
679        };
680        let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![0.0], 4)
681            .expect_err("dim mismatch must reject");
682        assert!(err.contains("total_compiled"));
683    }
684
685    #[test]
686    fn bundle_rejects_lambda_count_mismatch() {
687        let spec = JointPenaltySpec {
688            label: None,
689            matrix: Array2::<f64>::eye(2),
690            initial_log_lambda: 0.0,
691            nullspace_dim: 0,
692        };
693        let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![], 2)
694            .expect_err("count mismatch must reject");
695        assert!(err.contains("specs vs"));
696    }
697}