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    NonFiniteInitialLogLambda {
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::NonFiniteInitialLogLambda { value } => {
114                write!(f, "joint penalty initial_log_lambda is non-finite: {value}")
115            }
116            Self::NotSymmetric {
117                row,
118                col,
119                asymmetry,
120            } => write!(
121                f,
122                "joint penalty matrix is not symmetric at ({row},{col}): |S - Sᵀ|={asymmetry:.3e}"
123            ),
124            Self::NullspaceTooLarge {
125                total,
126                nullspace_dim,
127            } => write!(
128                f,
129                "joint penalty nullspace_dim={nullspace_dim} exceeds dim={total}"
130            ),
131            Self::NotPositiveSemidefinite {
132                min_eigenvalue,
133                max_abs_eigenvalue,
134            } => write!(
135                f,
136                "joint penalty matrix is not positive semidefinite: min eigenvalue \
137                 {min_eigenvalue:.6e} (max |eigenvalue| {max_abs_eigenvalue:.6e}); the \
138                 penalized objective is unbounded below along the negative mode"
139            ),
140            Self::NullspaceMismatch {
141                declared,
142                numerical,
143            } => write!(
144                f,
145                "joint penalty declares nullspace_dim={declared} but the eigenspectrum has \
146                 {numerical} numerical-zero direction(s); the REML pseudo-logdet rank would \
147                 be wrong"
148            ),
149            Self::EigendecompositionFailed { reason } => write!(
150                f,
151                "joint penalty eigendecomposition failed during validation: {reason}"
152            ),
153        }
154    }
155}
156
157impl std::error::Error for JointPenaltyError {}
158
159impl JointPenaltySpec {
160    /// Symmetry tolerance for [`validate`]. Cross-block pullbacks via `T`
161    /// accumulate roundoff, so an exact symmetric requirement is too tight;
162    /// this matches the floor used by the surrounding penalty code paths.
163    const SYMMETRY_TOL: f64 = 1e-10;
164
165    /// Total compiled parameter count this penalty acts on.
166    #[inline]
167    pub fn dim(&self) -> usize {
168        self.matrix.nrows()
169    }
170
171    /// Trace of the penalty matrix (`Σ_i S[i,i]`).
172    pub fn trace(&self) -> f64 {
173        self.matrix.diag().iter().copied().sum()
174    }
175
176    /// Structural pseudo-rank, derived from the declared `nullspace_dim`.
177    /// This is the rank used by the REML pseudo-logdet under the
178    /// no-numerical-thresholds policy in the surrounding code.
179    #[inline]
180    pub fn pseudo_rank(&self) -> usize {
181        self.dim().saturating_sub(self.nullspace_dim)
182    }
183
184    /// Quadratic form `βᵀ S β`. Mirrors
185    /// [`crate::families::custom_family::PenaltyMatrix::quadratic_form`] for
186    /// the full-width case.
187    pub fn quadratic_form(&self, beta: ArrayView1<'_, f64>) -> f64 {
188        assert_eq!(
189            beta.len(),
190            self.dim(),
191            "joint penalty quadratic form: beta length {} != dim {}",
192            beta.len(),
193            self.dim()
194        );
195        beta.dot(&self.matrix.dot(&beta))
196    }
197
198    /// Validate shape, finiteness, symmetry, and nullspace bookkeeping.
199    pub fn validate(&self) -> Result<(), JointPenaltyError> {
200        let (nrows, ncols) = self.matrix.dim();
201        if nrows != ncols {
202            return Err(JointPenaltyError::NotSquare { nrows, ncols });
203        }
204        if !self.initial_log_lambda.is_finite() {
205            return Err(JointPenaltyError::NonFiniteInitialLogLambda {
206                value: self.initial_log_lambda,
207            });
208        }
209        if self.nullspace_dim > nrows {
210            return Err(JointPenaltyError::NullspaceTooLarge {
211                total: nrows,
212                nullspace_dim: self.nullspace_dim,
213            });
214        }
215        for ((row, col), &value) in self.matrix.indexed_iter() {
216            if !value.is_finite() {
217                return Err(JointPenaltyError::NonFiniteEntry { row, col, value });
218            }
219        }
220        for row in 0..nrows {
221            for col in (row + 1)..ncols {
222                let asymmetry = (self.matrix[[row, col]] - self.matrix[[col, row]]).abs();
223                if asymmetry > Self::SYMMETRY_TOL {
224                    return Err(JointPenaltyError::NotSymmetric {
225                        row,
226                        col,
227                        asymmetry,
228                    });
229                }
230            }
231        }
232        // PSD + declared-nullity honesty. An indefinite joint penalty makes
233        // the penalized objective unbounded below along its negative mode
234        // while the pseudo-logdet's positive-eigenspace filter would silently
235        // drop that mode; a wrong declared nullity mis-ranks the REML
236        // pseudo-logdet (the whole point of declaring it is to avoid runtime
237        // thresholds, so it must agree with the spectrum at construction).
238        if nrows > 0 {
239            use gam_linalg::faer_ndarray::FaerEigh;
240            let (eigenvalues, _) =
241                FaerEigh::eigh(&self.matrix, faer::Side::Lower).map_err(|e| {
242                    JointPenaltyError::EigendecompositionFailed {
243                        reason: e.to_string(),
244                    }
245                })?;
246            let max_abs_eigenvalue = eigenvalues
247                .iter()
248                .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
249            // Same relative classification as the REML pseudo-logdet kernel:
250            // the eigensolver noise floor is O(p·ε·‖S‖), never an absolute cut.
251            let tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eigenvalue;
252            if let Some(&min_eigenvalue) = eigenvalues
253                .iter()
254                .filter(|&&ev| ev < -tol)
255                .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
256            {
257                return Err(JointPenaltyError::NotPositiveSemidefinite {
258                    min_eigenvalue,
259                    max_abs_eigenvalue,
260                });
261            }
262            let numerical = eigenvalues.iter().filter(|&&ev| ev <= tol).count();
263            if numerical != self.nullspace_dim {
264                return Err(JointPenaltyError::NullspaceMismatch {
265                    declared: self.nullspace_dim,
266                    numerical,
267                });
268            }
269        }
270        Ok(())
271    }
272}
273
274/// Per-evaluation bundle of cross-block penalties paired with their current
275/// log-smoothing parameters.
276///
277/// The outer optimizer concatenates joint penalty `log λ` values onto the
278/// per-block ρ vector; the inner solver receives this bundle via
279/// [`crate::families::custom_family::BlockwiseFitOptions::joint_penalties`]
280/// and adds the full-width quadratic / matvec / preconditioner / Hessian
281/// contributions to the joint-Newton primitives.
282#[derive(Clone, Debug)]
283pub struct JointPenaltyBundle {
284    pub specs: std::sync::Arc<Vec<JointPenaltySpec>>,
285    pub log_lambdas: Vec<f64>,
286}
287
288impl JointPenaltyBundle {
289    /// Build a bundle, validating the per-penalty `log λ` count and dimension
290    /// agreement against `total_compiled`.
291    pub fn new(
292        specs: std::sync::Arc<Vec<JointPenaltySpec>>,
293        log_lambdas: Vec<f64>,
294        total_compiled: usize,
295    ) -> Result<Self, String> {
296        if specs.len() != log_lambdas.len() {
297            return Err(format!(
298                "joint penalty bundle: {} specs vs {} log_lambdas",
299                specs.len(),
300                log_lambdas.len(),
301            ));
302        }
303        for (i, spec) in specs.iter().enumerate() {
304            if spec.dim() != total_compiled {
305                return Err(format!(
306                    "joint penalty {i}: dim {} != total_compiled {}",
307                    spec.dim(),
308                    total_compiled,
309                ));
310            }
311        }
312        Ok(Self { specs, log_lambdas })
313    }
314
315    #[inline]
316    pub fn len(&self) -> usize {
317        self.specs.len()
318    }
319
320    #[inline]
321    pub fn is_empty(&self) -> bool {
322        self.specs.is_empty()
323    }
324
325    /// Total joint-penalty contribution to the objective:
326    ///   `½ Σ_j exp(ρ_j) · βᵀ S_j β`.
327    pub fn quadratic(&self, beta: ArrayView1<'_, f64>) -> f64 {
328        let mut total = 0.0;
329        for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
330            let lam = log_lambda.exp();
331            total += 0.5 * lam * spec.quadratic_form(beta);
332        }
333        total
334    }
335
336    /// Accumulate `Σ_j exp(ρ_j) · S_j · v` into `out` (additive).
337    pub fn add_apply_into(&self, vector: ArrayView1<'_, f64>, out: &mut ndarray::Array1<f64>) {
338        assert_eq!(out.len(), vector.len());
339        for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
340            let lam = log_lambda.exp();
341            let sv = spec.matrix.dot(&vector);
342            out.scaled_add(lam, &sv);
343        }
344    }
345
346    /// Accumulate `Σ_j exp(ρ_j) · diag(S_j)` into `diag` (additive).
347    pub fn add_diag(&self, diag: &mut ndarray::Array1<f64>) {
348        for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
349            let lam = log_lambda.exp();
350            for (i, value) in spec.matrix.diag().iter().enumerate() {
351                diag[i] += lam * *value;
352            }
353        }
354    }
355
356    /// Accumulate `Σ_j exp(ρ_j) · S_j` into the full `matrix` (additive).
357    pub fn add_to_matrix(&self, matrix: &mut Array2<f64>) {
358        assert_eq!(matrix.nrows(), matrix.ncols());
359        for (spec, &log_lambda) in self.specs.iter().zip(self.log_lambdas.iter()) {
360            let lam = log_lambda.exp();
361            matrix.scaled_add(lam, &spec.matrix);
362        }
363    }
364
365    /// Per-penalty ρ-gradient contribution to the outer objective term:
366    ///   `∂/∂ρ_j [½ exp(ρ_j) βᵀ S_j β] = exp(ρ_j) · ½ βᵀ S_j β`.
367    pub fn rho_objective_gradient(&self, beta: ArrayView1<'_, f64>, out: &mut [f64]) {
368        assert_eq!(out.len(), self.specs.len());
369        for (i, (spec, &log_lambda)) in self.specs.iter().zip(self.log_lambdas.iter()).enumerate() {
370            let lam = log_lambda.exp();
371            out[i] = 0.5 * lam * spec.quadratic_form(beta);
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use ndarray::{Array1, Array2, array};
380
381    /// 4-dim cross-block dense penalty: a rank-2 operator that couples
382    /// indices {0,1} to {2,3} (i.e. nonzero off the 2×2 block diagonal),
383    /// which is exactly the shape that defeats a per-block `PenaltyMatrix`.
384    fn cross_block_spec() -> JointPenaltySpec {
385        // Build S = vᵀv + wᵀw where v and w span across both 2-blocks.
386        let v: Array1<f64> = array![1.0, 0.0, -1.0, 0.0];
387        let w: Array1<f64> = array![0.0, 1.0, 0.0, -1.0];
388        let mut matrix: Array2<f64> = Array2::zeros((4, 4));
389        for i in 0..4 {
390            for j in 0..4 {
391                matrix[[i, j]] = v[i] * v[j] + w[i] * w[j];
392            }
393        }
394        JointPenaltySpec {
395            label: Some("cross_block_pullback".to_string()),
396            matrix,
397            initial_log_lambda: -1.5,
398            nullspace_dim: 2,
399        }
400    }
401
402    #[test]
403    fn cross_block_dense_validates() {
404        let result = cross_block_spec().validate();
405        assert!(
406            result.is_ok(),
407            "valid cross-block spec rejected: {result:?}"
408        );
409    }
410
411    #[test]
412    fn trace_matches_diagonal_sum() {
413        let spec = cross_block_spec();
414        // diag(S) = [v0^2+w0^2, v1^2+w1^2, v2^2+w2^2, v3^2+w3^2] = [1,1,1,1]
415        assert!((spec.trace() - 4.0).abs() < 1e-12);
416    }
417
418    #[test]
419    fn pseudo_rank_uses_declared_nullspace() {
420        let spec = cross_block_spec();
421        assert_eq!(spec.dim(), 4);
422        assert_eq!(spec.pseudo_rank(), 2);
423    }
424
425    #[test]
426    fn quadratic_form_matches_explicit_mat_vec() {
427        let spec = cross_block_spec();
428        // Pick a beta that has support in both 2-blocks.
429        let beta: Array1<f64> = array![0.5, -0.25, 1.0, 0.75];
430        // v·β = 0.5 - 1.0 = -0.5; w·β = -0.25 - 0.75 = -1.0
431        // βᵀSβ = (v·β)^2 + (w·β)^2 = 0.25 + 1.0 = 1.25
432        let q = spec.quadratic_form(beta.view());
433        assert!((q - 1.25).abs() < 1e-12, "got {q}");
434    }
435
436    #[test]
437    fn determinant_zero_for_rank_deficient_matches_nullspace() {
438        use gam_linalg::faer_ndarray::FaerEigh;
439        let spec = cross_block_spec();
440        // Symmetric eigendecomposition; expect exactly nullspace_dim
441        // zeros (up to floating-point), matching the declared rank.
442        let (eigvals, _) =
443            FaerEigh::eigh(&spec.matrix, faer::Side::Lower).expect("symmetric eigh succeeds");
444        let mut sorted: Vec<f64> = eigvals.iter().copied().collect();
445        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
446        let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-10).count();
447        assert_eq!(
448            zeros, spec.nullspace_dim,
449            "spectrum {sorted:?} should have {} near-zeros",
450            spec.nullspace_dim
451        );
452        // Determinant = product of eigenvalues; with a real nullspace
453        // it is exactly zero modulo roundoff.
454        let det: f64 = sorted.iter().product();
455        assert!(det.abs() < 1e-10, "expected ~0 determinant, got {det}");
456    }
457
458    #[test]
459    fn validate_rejects_non_square() {
460        let spec = JointPenaltySpec {
461            label: None,
462            matrix: Array2::zeros((3, 4)),
463            initial_log_lambda: 0.0,
464            nullspace_dim: 0,
465        };
466        assert!(matches!(
467            spec.validate(),
468            Err(JointPenaltyError::NotSquare { nrows: 3, ncols: 4 })
469        ));
470    }
471
472    #[test]
473    fn validate_rejects_non_symmetric() {
474        let mut matrix = Array2::<f64>::zeros((3, 3));
475        matrix[[0, 1]] = 1.0;
476        matrix[[1, 0]] = -1.0;
477        let spec = JointPenaltySpec {
478            label: None,
479            matrix,
480            initial_log_lambda: 0.0,
481            nullspace_dim: 0,
482        };
483        assert!(matches!(
484            spec.validate(),
485            Err(JointPenaltyError::NotSymmetric { .. })
486        ));
487    }
488
489    #[test]
490    fn validate_rejects_oversized_nullspace() {
491        let spec = JointPenaltySpec {
492            label: None,
493            matrix: Array2::zeros((3, 3)),
494            initial_log_lambda: 0.0,
495            nullspace_dim: 4,
496        };
497        assert!(matches!(
498            spec.validate(),
499            Err(JointPenaltyError::NullspaceTooLarge {
500                total: 3,
501                nullspace_dim: 4
502            })
503        ));
504    }
505
506    #[test]
507    fn validate_rejects_non_finite_initial_log_lambda() {
508        let spec = JointPenaltySpec {
509            label: None,
510            matrix: Array2::zeros((2, 2)),
511            initial_log_lambda: f64::NAN,
512            nullspace_dim: 0,
513        };
514        assert!(matches!(
515            spec.validate(),
516            Err(JointPenaltyError::NonFiniteInitialLogLambda { .. })
517        ));
518    }
519
520    /// 2-block toy with one full-width SPD joint penalty:
521    ///
522    /// Two scalar blocks (`p = 1 + 1 = 2`). The unpenalised "log-likelihood"
523    /// is a quadratic with optimum at `b`:
524    ///     ℓ(β) = −½ (β − b)ᵀ I (β − b)
525    /// so `−∇ℓ = (β − b)` and `−∇²ℓ = I`. We add ONE joint penalty
526    /// `S = [[2, 1], [1, 2]]` (SPD, full rank, cross-block coupling
527    /// off-diagonal). With `λ = exp(ρ)` the penalised objective is
528    ///     F(β) = ½ (β − b)ᵀ (β − b) + ½ λ βᵀ S β
529    /// whose minimiser solves `(I + λ S) β̂ = b`. We verify the bundle's
530    /// `add_to_matrix` builds the right LHS and the `add_apply_into` /
531    /// `quadratic` helpers agree with the analytic gradient / objective at
532    /// `β̂`.
533    #[test]
534    fn bundle_two_block_minimiser_matches_analytic_solution() {
535        use gam_linalg::faer_ndarray::FaerCholesky;
536        use ndarray::Array2;
537
538        let spec = JointPenaltySpec {
539            label: Some("toy_cross_block".to_string()),
540            matrix: array![[2.0_f64, 1.0], [1.0, 2.0]],
541            initial_log_lambda: 0.0,
542            nullspace_dim: 0,
543        };
544        let log_lambda = -0.4_f64;
545        let lam = log_lambda.exp();
546        let bundle = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![log_lambda], 2)
547            .expect("valid bundle");
548
549        // Build LHS = I + λ S via add_to_matrix (the exact path the inner
550        // Newton uses to assemble the penalised joint Hessian).
551        let mut lhs = Array2::<f64>::eye(2);
552        bundle.add_to_matrix(&mut lhs);
553        // Verify add_to_matrix produced I + λ S.
554        let expected_lhs = array![[1.0 + lam * 2.0, lam], [lam, 1.0 + lam * 2.0]];
555        for r in 0..2 {
556            for c in 0..2 {
557                assert!(
558                    (lhs[[r, c]] - expected_lhs[[r, c]]).abs() < 1e-12,
559                    "lhs[{r}, {c}] = {} expected {}",
560                    lhs[[r, c]],
561                    expected_lhs[[r, c]]
562                );
563            }
564        }
565
566        // Solve (I + λ S) β̂ = b for b = [1.0, -0.5].
567        let b: Array1<f64> = array![1.0, -0.5];
568        let chol = lhs.cholesky(faer::Side::Lower).expect("SPD");
569        let mut rhs_mat = Array2::<f64>::zeros((2, 1));
570        rhs_mat[[0, 0]] = b[0];
571        rhs_mat[[1, 0]] = b[1];
572        let mut beta_mat = rhs_mat.clone();
573        chol.solve_mat_in_place(&mut beta_mat);
574        let beta_hat: Array1<f64> = array![beta_mat[[0, 0]], beta_mat[[1, 0]]];
575
576        // Gradient at β̂: (β̂ − b) + λ S β̂ should be ~0.
577        let mut grad = &beta_hat - &b;
578        bundle.add_apply_into(beta_hat.view(), &mut grad);
579        let grad_inf = grad.iter().map(|v: &f64| v.abs()).fold(0.0_f64, f64::max);
580        assert!(
581            grad_inf < 1e-12,
582            "penalised gradient at analytic minimiser must vanish: {grad_inf:.3e}"
583        );
584
585        // Objective ½(β̂−b)·(β̂−b) + bundle.quadratic(β̂) reproduces the
586        // closed-form minimum value F(β̂) = ½(β̂−b)ᵀ(β̂−b) + ½λ β̂ᵀ S β̂.
587        let resid = &beta_hat - &b;
588        let unpen = 0.5 * resid.dot(&resid);
589        let pen = bundle.quadratic(beta_hat.view());
590        let expected_obj = 0.5 * resid.dot(&resid)
591            + 0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
592        assert!(
593            (unpen + pen - expected_obj).abs() < 1e-12,
594            "objective sum {} mismatched expected {}",
595            unpen + pen,
596            expected_obj
597        );
598
599        // Preconditioner diag accumulator: diag(I) + λ diag(S) = [1+2λ, 1+2λ].
600        let mut diag = ndarray::Array1::<f64>::from_elem(2, 1.0);
601        bundle.add_diag(&mut diag);
602        assert!((diag[0] - (1.0 + lam * 2.0)).abs() < 1e-12);
603        assert!((diag[1] - (1.0 + lam * 2.0)).abs() < 1e-12);
604
605        // rho-objective-gradient: ½ λ β̂ᵀ S β̂.
606        let mut rho_grad = vec![0.0_f64];
607        bundle.rho_objective_gradient(beta_hat.view(), &mut rho_grad);
608        let expected_rho_grad =
609            0.5 * lam * beta_hat.dot(&array![[2.0, 1.0], [1.0, 2.0]].dot(&beta_hat));
610        assert!(
611            (rho_grad[0] - expected_rho_grad).abs() < 1e-12,
612            "rho-grad {} expected {}",
613            rho_grad[0],
614            expected_rho_grad
615        );
616    }
617
618    #[test]
619    fn bundle_rejects_dim_mismatch() {
620        let spec = JointPenaltySpec {
621            label: None,
622            matrix: Array2::<f64>::eye(3),
623            initial_log_lambda: 0.0,
624            nullspace_dim: 0,
625        };
626        let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![0.0], 4)
627            .expect_err("dim mismatch must reject");
628        assert!(err.contains("total_compiled"));
629    }
630
631    #[test]
632    fn bundle_rejects_lambda_count_mismatch() {
633        let spec = JointPenaltySpec {
634            label: None,
635            matrix: Array2::<f64>::eye(2),
636            initial_log_lambda: 0.0,
637            nullspace_dim: 0,
638        };
639        let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![], 2)
640            .expect_err("count mismatch must reject");
641        assert!(err.contains("specs vs"));
642    }
643}