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