Skip to main content

gam_problem/
penalty_matrix.rs

1//! The `PenaltyMatrix` carrier (dense / Kronecker / scaled) used by every
2//! custom-family block, plus its constructors and the `Array2` conversion.
3
4use ndarray::{Array1, Array2, Axis};
5use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
6
7/// A penalty matrix that may be stored in Kronecker-factored form.
8///
9/// For tensor-product terms (e.g. time-varying survival covariates), the penalty
10/// has the structure `S = left ⊗ right` (Kronecker product). Keeping this
11/// factored avoids materializing (p_left × p_right)² dense entries and enables
12/// exact log-determinant computation via `log|A ⊗ B| = n_B log|A| + n_A log|B|`.
13///
14/// Dense penalties are stored as-is.  Callers that need a raw `Array2<f64>` can
15/// call `as_dense()` (zero-cost for Dense, lazy-materialized for KroneckerFactored).
16#[derive(Clone, Debug)]
17pub enum PenaltyMatrix {
18    Dense(Array2<f64>),
19    KroneckerFactored {
20        left: Array2<f64>,
21        right: Array2<f64>,
22    },
23    /// Block-local penalty: `local` is `block_dim × block_dim`, embedded at
24    /// `col_range` in the full parameter space of dimension `total_dim`.
25    /// Avoids materializing the full `total_dim × total_dim` matrix.
26    Blockwise {
27        local: Array2<f64>,
28        col_range: std::ops::Range<usize>,
29        total_dim: usize,
30    },
31    /// Wrapper assigning this penalty component to a user-visible precision
32    /// label. Components with the same label share one smoothing parameter.
33    Labeled {
34        label: String,
35        inner: Box<PenaltyMatrix>,
36    },
37    /// Wrapper fixing this penalty component at a physical log-precision.
38    /// Fixed components remain in the block-local physical penalty layout but
39    /// are removed from the REML outer coordinate vector.
40    Fixed {
41        log_lambda: f64,
42        inner: Box<PenaltyMatrix>,
43    },
44}
45
46impl PenaltyMatrix {
47    /// Number of rows (= number of columns, since penalties are square).
48    pub fn dim(&self) -> usize {
49        match self {
50            Self::Dense(m) => m.nrows(),
51            Self::KroneckerFactored { left, right } => left.nrows() * right.nrows(),
52            Self::Blockwise { total_dim, .. } => *total_dim,
53            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.dim(),
54        }
55    }
56
57    /// Returns (nrows, ncols) like Array2::dim().
58    ///
59    /// Reports the ACTUAL storage shape, not `(dim(), dim())`: a malformed
60    /// non-square carrier must be visible to validation instead of being
61    /// laundered into a fabricated square shape.
62    pub fn shape(&self) -> (usize, usize) {
63        match self {
64            Self::Dense(m) => m.dim(),
65            Self::KroneckerFactored { left, right } => {
66                (left.nrows() * right.nrows(), left.ncols() * right.ncols())
67            }
68            Self::Blockwise { total_dim, .. } => (*total_dim, *total_dim),
69            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.shape(),
70        }
71    }
72
73    /// Validate this penalty as the carrier of a quadratic form `½·λ·βᵀSβ`
74    /// on a coefficient block of width `expected_dim`.
75    ///
76    /// Establishes, at the model boundary, everything downstream code assumes
77    /// without re-checking: square storage of the right size, finite entries,
78    /// symmetry, and positive semidefiniteness (for `Blockwise`, also that the
79    /// embedded range is consistent). A nonsymmetric `S` would make the
80    /// implemented gradient `λSβ` disagree with the true gradient of the
81    /// quadratic, `λ·sym(S)β`; an indefinite `S` makes the penalized objective
82    /// unbounded below along its negative mode while positive-eigenspace
83    /// filtering silently drops that mode from ranks and log-determinants.
84    /// Neither is a fittable model, so both are rejected rather than coerced.
85    pub fn validate(&self, expected_dim: usize) -> Result<(), String> {
86        let (nrows, ncols) = self.shape();
87        if nrows != ncols || nrows != expected_dim {
88            return Err(format!(
89                "penalty must be {expected_dim}x{expected_dim}, got {nrows}x{ncols}"
90            ));
91        }
92        match self {
93            Self::Dense(m) => validate_symmetric_psd_core(m, "dense penalty"),
94            Self::KroneckerFactored { left, right } => {
95                // A ⊗ B is symmetric PSD when both factors are (the canonical
96                // tensor-product construction). Validating the factors avoids
97                // materializing the product and rejects the NSD⊗NSD encoding,
98                // which downstream Kronecker logdet identities do not support.
99                validate_symmetric_psd_core(left, "Kronecker left factor")?;
100                validate_symmetric_psd_core(right, "Kronecker right factor")
101            }
102            Self::Blockwise {
103                local,
104                col_range,
105                total_dim,
106            } => {
107                if col_range.end > *total_dim || col_range.len() != local.nrows() {
108                    return Err(format!(
109                        "blockwise penalty embedding is inconsistent: local {}x{} at columns \
110                         {}..{} of total_dim {}",
111                        local.nrows(),
112                        local.ncols(),
113                        col_range.start,
114                        col_range.end,
115                        total_dim
116                    ));
117                }
118                validate_symmetric_psd_core(local, "blockwise local penalty")
119            }
120            Self::Labeled { inner, .. } => inner.validate(expected_dim),
121            Self::Fixed { log_lambda, inner } => {
122                crate::validate_log_strength(*log_lambda)
123                    .map_err(|error| format!("fixed penalty log-precision: {error}"))?;
124                inner.validate(expected_dim)
125            }
126        }
127    }
128
129    /// Materialize the full dense matrix.
130    pub fn to_dense(&self) -> Array2<f64> {
131        match self {
132            Self::Dense(m) => m.clone(),
133            Self::KroneckerFactored { left, right } => kronecker_product(left, right),
134            Self::Blockwise {
135                local,
136                col_range,
137                total_dim,
138            } => {
139                let mut g = Array2::zeros((*total_dim, *total_dim));
140                g.slice_mut(ndarray::s![
141                    col_range.start..col_range.end,
142                    col_range.start..col_range.end
143                ])
144                .assign(local);
145                g
146            }
147            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.to_dense(),
148        }
149    }
150
151    /// Borrow the inner dense matrix if Dense, otherwise materialize.
152    pub fn as_dense_cow(&self) -> std::borrow::Cow<'_, Array2<f64>> {
153        match self {
154            Self::Dense(m) => std::borrow::Cow::Borrowed(m),
155            Self::KroneckerFactored { .. }
156            | Self::Blockwise { .. }
157            | Self::Labeled { .. }
158            | Self::Fixed { .. } => std::borrow::Cow::Owned(self.to_dense()),
159        }
160    }
161
162    /// Returns a reference to the inner matrix if this is a Dense variant.
163    pub fn as_dense_ref(&self) -> Option<&Array2<f64>> {
164        match self {
165            Self::Dense(m) => Some(m),
166            Self::Fixed { inner, .. } => inner.as_dense_ref(),
167            Self::KroneckerFactored { .. } | Self::Blockwise { .. } | Self::Labeled { .. } => None,
168        }
169    }
170
171    pub fn with_precision_label(self, label: impl Into<String>) -> Self {
172        Self::Labeled {
173            label: label.into(),
174            inner: Box::new(self),
175        }
176    }
177
178    pub fn precision_label(&self) -> Option<&str> {
179        match self {
180            Self::Labeled { label, .. } => Some(label.as_str()),
181            Self::Fixed { .. } => None,
182            _ => None,
183        }
184    }
185
186    pub fn with_fixed_log_lambda(self, log_lambda: f64) -> Self {
187        Self::Fixed {
188            log_lambda,
189            inner: Box::new(self),
190        }
191    }
192
193    pub fn fixed_log_lambda(&self) -> Option<f64> {
194        match self {
195            Self::Fixed { log_lambda, .. } => Some(*log_lambda),
196            Self::Labeled { inner, .. } => inner.fixed_log_lambda(),
197            _ => None,
198        }
199    }
200
201    /// Compute S * v using the row-major Kronecker vec trick when factored:
202    ///   (A ⊗ B) vec_rm(V) = vec_rm(A V Bᵀ)
203    /// where V = reshape(v, (p_left, p_right)).
204    pub fn dot(&self, v: &Array1<f64>) -> Array1<f64> {
205        match self {
206            Self::Dense(m) => m.dot(v),
207            Self::KroneckerFactored { left, right } => {
208                let p_left = left.nrows();
209                let p_right = right.nrows();
210                // v is ordered by i_left * p_right + i_right.
211                let v_mat =
212                    ndarray::ArrayView2::from_shape((p_left, p_right), v.as_slice().unwrap())
213                        .unwrap();
214                let avbt = left.dot(&v_mat).dot(&right.t());
215                let standard = avbt.as_standard_layout();
216                Array1::from_iter(standard.iter().copied())
217            }
218            Self::Blockwise {
219                local,
220                col_range,
221                total_dim,
222            } => {
223                let mut out = Array1::zeros(*total_dim);
224                let v_block = v.slice(ndarray::s![col_range.clone()]);
225                let result_block = local.dot(&v_block);
226                out.slice_mut(ndarray::s![col_range.clone()])
227                    .assign(&result_block);
228                out
229            }
230            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.dot(v),
231        }
232    }
233
234    /// Add λ * self to a mutable dense accumulator.
235    pub fn add_scaled_to(&self, lambda: f64, target: &mut Array2<f64>) {
236        match self {
237            Self::Dense(m) => {
238                target.scaled_add(lambda, m);
239            }
240            Self::KroneckerFactored { left, right } => {
241                let p_left = left.nrows();
242                let p_right = right.nrows();
243                for i1 in 0..p_left {
244                    for j1 in 0..p_left {
245                        let a_ij = left[[i1, j1]];
246                        if a_ij == 0.0 {
247                            continue;
248                        }
249                        let scaled_a = lambda * a_ij;
250                        for i2 in 0..p_right {
251                            let row = i1 * p_right + i2;
252                            for j2 in 0..p_right {
253                                let col = j1 * p_right + j2;
254                                target[[row, col]] += scaled_a * right[[i2, j2]];
255                            }
256                        }
257                    }
258                }
259            }
260            Self::Blockwise {
261                local, col_range, ..
262            } => {
263                target
264                    .slice_mut(ndarray::s![col_range.clone(), col_range.clone()])
265                    .scaled_add(lambda, local);
266            }
267            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => {
268                inner.add_scaled_to(lambda, target)
269            }
270        }
271    }
272
273    /// Add λ * diag(self) to a mutable diagonal accumulator.
274    pub fn add_scaled_diag_to(&self, lambda: f64, target: &mut Array1<f64>) {
275        match self {
276            Self::Dense(m) => {
277                let p = m.nrows().min(target.len());
278                for j in 0..p {
279                    target[j] += lambda * m[[j, j]];
280                }
281            }
282            Self::KroneckerFactored { left, right } => {
283                let p_left = left.nrows();
284                let p_right = right.nrows();
285                assert_eq!(target.len(), p_left * p_right);
286                for i_left in 0..p_left {
287                    let left_diag = left[[i_left, i_left]];
288                    if left_diag == 0.0 {
289                        continue;
290                    }
291                    let scaled_left = lambda * left_diag;
292                    for i_right in 0..p_right {
293                        target[i_left * p_right + i_right] +=
294                            scaled_left * right[[i_right, i_right]];
295                    }
296                }
297            }
298            Self::Blockwise {
299                local, col_range, ..
300            } => {
301                let width = local.nrows().min(col_range.len());
302                for local_idx in 0..width {
303                    target[col_range.start + local_idx] += lambda * local[[local_idx, local_idx]];
304                }
305            }
306            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => {
307                inner.add_scaled_diag_to(lambda, target)
308            }
309        }
310    }
311
312    /// Compute the quadratic form β' S β.
313    pub fn quadratic_form(&self, beta: &Array1<f64>) -> f64 {
314        match self {
315            Self::Dense(m) => beta.dot(&m.dot(beta)),
316            Self::KroneckerFactored { .. } => {
317                let sv = self.dot(beta);
318                beta.dot(&sv)
319            }
320            Self::Blockwise {
321                local, col_range, ..
322            } => {
323                let beta_block = beta.slice(ndarray::s![col_range.clone()]);
324                let sv = local.dot(&beta_block);
325                beta_block.dot(&sv)
326            }
327            Self::Labeled { inner, .. } | Self::Fixed { inner, .. } => inner.quadratic_form(beta),
328        }
329    }
330
331    /// Access dimensions like an Array2.
332    pub fn nrows(&self) -> usize {
333        self.dim()
334    }
335
336    pub fn ncols(&self) -> usize {
337        self.dim()
338    }
339}
340
341impl From<Array2<f64>> for PenaltyMatrix {
342    fn from(m: Array2<f64>) -> Self {
343        Self::Dense(m)
344    }
345}
346
347/// Core quadratic-form validity: square, finite, symmetric (up to a
348/// scale-relative round-off band), and positive semidefinite (eigenvalues
349/// above the relative eigensolver noise floor `p·ε·‖S‖`, the same relative
350/// classification the REML pseudo-logdet kernel uses — never an absolute
351/// floor, so validity is invariant under `S → c·S`).
352fn validate_symmetric_psd_core(matrix: &Array2<f64>, what: &str) -> Result<(), String> {
353    use gam_linalg::faer_ndarray::FaerEigh;
354
355    let (nrows, ncols) = matrix.dim();
356    if nrows != ncols {
357        return Err(format!("{what} is not square: {nrows}x{ncols}"));
358    }
359    let mut max_abs = 0.0_f64;
360    for ((row, col), &value) in matrix.indexed_iter() {
361        if !value.is_finite() {
362            return Err(format!(
363                "{what} has non-finite entry at ({row},{col}): {value}"
364            ));
365        }
366        max_abs = max_abs.max(value.abs());
367    }
368    // Symmetry: relative to the matrix scale so a legitimately large penalty
369    // is not rejected for round-off and a small one cannot hide genuine skew.
370    let sym_tol = 1e-10 * max_abs.max(1.0);
371    for row in 0..nrows {
372        for col in (row + 1)..ncols {
373            let asymmetry = (matrix[[row, col]] - matrix[[col, row]]).abs();
374            if asymmetry > sym_tol {
375                return Err(format!(
376                    "{what} is not symmetric at ({row},{col}): |S - Sᵀ| = {asymmetry:.3e}; \
377                     the gradient of βᵀSβ/2 is sym(S)β, so a skew component would make the \
378                     implemented objective and gradient describe different functions"
379                ));
380            }
381        }
382    }
383    if nrows == 0 || max_abs == 0.0 {
384        return Ok(()); // the zero penalty is trivially PSD
385    }
386    let (eigenvalues, _) = matrix
387        .eigh(faer::Side::Lower)
388        .map_err(|e| format!("{what} eigendecomposition failed during validation: {e}"))?;
389    let max_abs_eval = eigenvalues
390        .iter()
391        .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
392    let psd_tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eval;
393    if let Some(&min_eval) = eigenvalues
394        .iter()
395        .filter(|&&ev| ev < -psd_tol)
396        .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
397    {
398        return Err(format!(
399            "{what} is not positive semidefinite: min eigenvalue {min_eval:.6e} \
400             (max |eigenvalue| {max_abs_eval:.6e}); the penalized objective is unbounded \
401             below along the negative mode while rank/logdet filtering would silently \
402             drop it"
403        ));
404    }
405    Ok(())
406}
407
408/// Computes the Kronecker product A ⊗ B for penalty matrix construction.
409/// This is used to create tensor product penalties that enforce smoothness
410/// in multiple dimensions for interaction terms.
411fn kronecker_product(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
412    let (arows, a_cols) = a.dim();
413    let (brows, b_cols) = b.dim();
414    if arows == 0 || a_cols == 0 || brows == 0 || b_cols == 0 {
415        return Array2::zeros((arows * brows, a_cols * b_cols));
416    }
417    let mut result = Array2::zeros((arows * brows, a_cols * b_cols));
418
419    result
420        .axis_chunks_iter_mut(Axis(0), brows)
421        .into_par_iter()
422        .enumerate()
423        .for_each(|(i, mut row_block)| {
424            let arow = a.row(i);
425            let col_chunks = row_block.axis_chunks_iter_mut(Axis(1), b_cols);
426            for (j, mut block) in col_chunks.into_iter().enumerate() {
427                let aval = arow[j];
428                if aval == 0.0 {
429                    continue;
430                }
431                for (dest, &src) in block.iter_mut().zip(b.iter()) {
432                    *dest = aval * src;
433                }
434            }
435        });
436
437    result
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use ndarray::array;
444
445    // ── Dense variant ─────────────────────────────────────────────────────────
446
447    #[test]
448    fn dense_dim_and_shape() {
449        let m = array![[1.0, 0.0], [0.0, 2.0]];
450        let p = PenaltyMatrix::Dense(m);
451        assert_eq!(p.dim(), 2);
452        assert_eq!(p.shape(), (2, 2));
453        assert_eq!(p.nrows(), 2);
454        assert_eq!(p.ncols(), 2);
455    }
456
457    #[test]
458    fn dense_to_dense_is_clone() {
459        let m = array![[3.0, 1.0], [1.0, 4.0]];
460        let p = PenaltyMatrix::Dense(m.clone());
461        assert_eq!(p.to_dense(), m);
462    }
463
464    #[test]
465    fn dense_dot_product() {
466        // [[1, 0], [0, 2]] · [3, 5] = [3, 10]
467        let m = array![[1.0, 0.0], [0.0, 2.0]];
468        let p = PenaltyMatrix::Dense(m);
469        let v = ndarray::array![3.0, 5.0];
470        let result = p.dot(&v);
471        assert_eq!(result.as_slice().unwrap(), &[3.0, 10.0]);
472    }
473
474    #[test]
475    fn dense_quadratic_form() {
476        // beta' S beta with S=diag(1,2), beta=[3,2] → 9 + 8 = 17
477        let m = array![[1.0, 0.0], [0.0, 2.0]];
478        let p = PenaltyMatrix::Dense(m);
479        let beta = ndarray::array![3.0, 2.0];
480        assert!((p.quadratic_form(&beta) - 17.0).abs() < 1e-14);
481    }
482
483    #[test]
484    fn dense_add_scaled_to() {
485        let s = array![[1.0, 0.0], [0.0, 1.0]];
486        let p = PenaltyMatrix::Dense(s);
487        let mut acc = ndarray::Array2::<f64>::zeros((2, 2));
488        p.add_scaled_to(3.0, &mut acc);
489        assert_eq!(acc, array![[3.0, 0.0], [0.0, 3.0]]);
490    }
491
492    #[test]
493    fn dense_add_scaled_diag_to() {
494        let s = array![[2.0, 5.0], [5.0, 7.0]];
495        let p = PenaltyMatrix::Dense(s);
496        let mut diag = ndarray::array![0.0, 0.0];
497        p.add_scaled_diag_to(1.0, &mut diag);
498        // diagonal entries are 2.0 and 7.0
499        assert_eq!(diag.as_slice().unwrap(), &[2.0, 7.0]);
500    }
501
502    // ── KroneckerFactored variant ─────────────────────────────────────────────
503
504    #[test]
505    fn kronecker_dim_is_product() {
506        let left = array![[1.0, 0.0], [0.0, 1.0]]; // 2×2
507        let right = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; // 3×3
508        let p = PenaltyMatrix::KroneckerFactored { left, right };
509        assert_eq!(p.dim(), 6);
510    }
511
512    #[test]
513    fn kronecker_to_dense_identity_x_identity() {
514        // I_2 ⊗ I_2 = I_4
515        let eye2 = ndarray::Array2::<f64>::eye(2);
516        let p = PenaltyMatrix::KroneckerFactored {
517            left: eye2.clone(),
518            right: eye2,
519        };
520        let dense = p.to_dense();
521        assert_eq!(dense, ndarray::Array2::<f64>::eye(4));
522    }
523
524    #[test]
525    fn kronecker_dot_matches_dense_dot() {
526        let left = array![[2.0, 0.0], [0.0, 3.0]];
527        let right = array![[1.0, 1.0], [0.0, 1.0]];
528        let p = PenaltyMatrix::KroneckerFactored {
529            left: left.clone(),
530            right: right.clone(),
531        };
532        // Compare to materialised version
533        let dense = p.to_dense();
534        let v = ndarray::array![1.0, 2.0, 3.0, 4.0];
535        let got = p.dot(&v);
536        let expected = dense.dot(&v);
537        for (a, b) in got.iter().zip(expected.iter()) {
538            assert!((a - b).abs() < 1e-14, "got={a} expected={b}");
539        }
540    }
541
542    // ── Blockwise variant ─────────────────────────────────────────────────────
543
544    #[test]
545    fn blockwise_dim_is_total() {
546        let local = array![[1.0, 0.0], [0.0, 1.0]];
547        let p = PenaltyMatrix::Blockwise {
548            local,
549            col_range: 1..3,
550            total_dim: 5,
551        };
552        assert_eq!(p.dim(), 5);
553    }
554
555    #[test]
556    fn blockwise_to_dense_embeds_local_block() {
557        // 3×3 total with local 2×2 at cols 1..3
558        let local = array![[2.0, 1.0], [1.0, 3.0]];
559        let p = PenaltyMatrix::Blockwise {
560            local,
561            col_range: 1..3,
562            total_dim: 3,
563        };
564        let dense = p.to_dense();
565        assert_eq!(dense[[0, 0]], 0.0);
566        assert_eq!(dense[[1, 1]], 2.0);
567        assert_eq!(dense[[1, 2]], 1.0);
568        assert_eq!(dense[[2, 1]], 1.0);
569        assert_eq!(dense[[2, 2]], 3.0);
570    }
571
572    #[test]
573    fn blockwise_dot_only_touches_block() {
574        let local = array![[2.0, 0.0], [0.0, 3.0]];
575        let p = PenaltyMatrix::Blockwise {
576            local,
577            col_range: 1..3,
578            total_dim: 4,
579        };
580        let v = ndarray::array![7.0, 1.0, 2.0, 9.0];
581        let out = p.dot(&v);
582        // v[1..3] = [1,2]; local * [1,2] = [2,6]; embedded at positions 1..3
583        assert_eq!(out.as_slice().unwrap(), &[0.0, 2.0, 6.0, 0.0]);
584    }
585
586    // ── Labeled / Fixed wrappers ──────────────────────────────────────────────
587
588    #[test]
589    fn labeled_inherits_dim_and_delegates_dot() {
590        let m = array![[1.0, 0.0], [0.0, 2.0]];
591        let p = PenaltyMatrix::Dense(m).with_precision_label("smooth");
592        assert_eq!(p.dim(), 2);
593        assert_eq!(p.precision_label(), Some("smooth"));
594        let v = ndarray::array![3.0, 4.0];
595        let out = p.dot(&v);
596        assert_eq!(out.as_slice().unwrap(), &[3.0, 8.0]);
597    }
598
599    #[test]
600    fn fixed_inherits_dim_and_exposes_log_lambda() {
601        let m = array![[5.0, 0.0], [0.0, 5.0]];
602        let p = PenaltyMatrix::Dense(m).with_fixed_log_lambda(2.5);
603        assert_eq!(p.dim(), 2);
604        assert_eq!(p.fixed_log_lambda(), Some(2.5));
605    }
606
607    // ── Boundary validation ───────────────────────────────────────────────────
608
609    #[test]
610    fn shape_reports_actual_storage_not_fabricated_square() {
611        // A malformed 2x3 dense carrier must be visible as 2x3, not laundered
612        // into 2x2 through dim().
613        let p = PenaltyMatrix::Dense(Array2::<f64>::zeros((2, 3)));
614        assert_eq!(p.shape(), (2, 3));
615        assert!(p.validate(2).is_err());
616        assert!(p.validate(3).is_err());
617    }
618
619    #[test]
620    fn validate_accepts_canonical_carriers() {
621        let dense = PenaltyMatrix::Dense(array![[2.0, -1.0], [-1.0, 2.0]]);
622        assert_eq!(dense.validate(2), Ok(()));
623
624        let kron = PenaltyMatrix::KroneckerFactored {
625            left: array![[1.0, -1.0], [-1.0, 1.0]],
626            right: ndarray::Array2::<f64>::eye(3),
627        };
628        assert_eq!(kron.validate(6), Ok(()));
629
630        let blockwise = PenaltyMatrix::Blockwise {
631            local: array![[1.0, 0.0], [0.0, 1.0]],
632            col_range: 1..3,
633            total_dim: 4,
634        };
635        assert_eq!(blockwise.validate(4), Ok(()));
636    }
637
638    #[test]
639    fn validate_rejects_asymmetric_indefinite_and_nonfinite() {
640        // Nonsymmetric: gradient of βᵀSβ/2 is sym(S)β, not Sβ.
641        let skew = PenaltyMatrix::Dense(array![[1.0, 1.0], [0.0, 1.0]]);
642        assert!(skew.validate(2).unwrap_err().contains("not symmetric"));
643
644        // Indefinite: objective unbounded below along the negative mode.
645        let indefinite = PenaltyMatrix::Dense(array![[1.0, 0.0], [0.0, -1.0]]);
646        assert!(
647            indefinite
648                .validate(2)
649                .unwrap_err()
650                .contains("not positive semidefinite")
651        );
652
653        let nan = PenaltyMatrix::Dense(array![[f64::NAN, 0.0], [0.0, 1.0]]);
654        assert!(nan.validate(2).unwrap_err().contains("non-finite"));
655
656        // Fixed wrapper must carry a supported physical log-precision.
657        let bad_fixed = PenaltyMatrix::Dense(ndarray::Array2::<f64>::eye(2))
658            .with_fixed_log_lambda(f64::INFINITY);
659        assert!(
660            bad_fixed
661                .validate(2)
662                .unwrap_err()
663                .contains("must be finite")
664        );
665
666        let finite_but_out_of_domain = PenaltyMatrix::Dense(ndarray::Array2::<f64>::eye(2))
667            .with_fixed_log_lambda(crate::LOG_STRENGTH_MAX + 1.0);
668        assert!(
669            finite_but_out_of_domain
670                .validate(2)
671                .unwrap_err()
672                .contains("must be finite and in")
673        );
674    }
675
676    #[test]
677    fn validate_rejects_inconsistent_blockwise_embedding() {
678        // local width disagrees with the embedded column range.
679        let p = PenaltyMatrix::Blockwise {
680            local: ndarray::Array2::<f64>::eye(3),
681            col_range: 1..3,
682            total_dim: 4,
683        };
684        assert!(p.validate(4).is_err());
685        // range runs past total_dim.
686        let q = PenaltyMatrix::Blockwise {
687            local: ndarray::Array2::<f64>::eye(2),
688            col_range: 3..5,
689            total_dim: 4,
690        };
691        assert!(q.validate(4).is_err());
692    }
693}