use ndarray::{Array2, ArrayView1};
#[derive(Debug, Clone)]
pub struct JointPenaltySpec {
pub label: Option<String>,
pub matrix: Array2<f64>,
pub initial_log_lambda: f64,
pub nullspace_dim: usize,
pub group: Option<usize>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum JointPenaltyError {
NotSquare {
nrows: usize,
ncols: usize,
},
NonFiniteEntry {
row: usize,
col: usize,
value: f64,
},
InitialLogStrengthOutOfDomain {
value: f64,
},
NotSymmetric {
row: usize,
col: usize,
asymmetry: f64,
},
NullspaceTooLarge {
total: usize,
nullspace_dim: usize,
},
NotPositiveSemidefinite {
min_eigenvalue: f64,
max_abs_eigenvalue: f64,
},
NullspaceMismatch {
declared: usize,
numerical: usize,
},
EigendecompositionFailed {
reason: String,
},
}
impl std::fmt::Display for JointPenaltyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotSquare { nrows, ncols } => {
write!(f, "joint penalty matrix is not square: {nrows}x{ncols}")
}
Self::NonFiniteEntry { row, col, value } => write!(
f,
"joint penalty matrix has non-finite entry at ({row},{col}): {value}"
),
Self::InitialLogStrengthOutOfDomain { value } => {
write!(
f,
"joint penalty initial_log_lambda is outside the exact strength domain: {value}"
)
}
Self::NotSymmetric {
row,
col,
asymmetry,
} => write!(
f,
"joint penalty matrix is not symmetric at ({row},{col}): |S - Sᵀ|={asymmetry:.3e}"
),
Self::NullspaceTooLarge {
total,
nullspace_dim,
} => write!(
f,
"joint penalty nullspace_dim={nullspace_dim} exceeds dim={total}"
),
Self::NotPositiveSemidefinite {
min_eigenvalue,
max_abs_eigenvalue,
} => write!(
f,
"joint penalty matrix is not positive semidefinite: min eigenvalue \
{min_eigenvalue:.6e} (max |eigenvalue| {max_abs_eigenvalue:.6e}); the \
penalized objective is unbounded below along the negative mode"
),
Self::NullspaceMismatch {
declared,
numerical,
} => write!(
f,
"joint penalty declares nullspace_dim={declared} but the eigenspectrum has \
{numerical} numerical-zero direction(s); the REML pseudo-logdet rank would \
be wrong"
),
Self::EigendecompositionFailed { reason } => write!(
f,
"joint penalty eigendecomposition failed during validation: {reason}"
),
}
}
}
impl std::error::Error for JointPenaltyError {}
impl JointPenaltySpec {
const SYMMETRY_TOL: f64 = 1e-10;
#[inline]
pub fn dim(&self) -> usize {
self.matrix.nrows()
}
pub fn trace(&self) -> f64 {
self.matrix.diag().iter().copied().sum()
}
#[inline]
pub fn pseudo_rank(&self) -> usize {
self.dim().saturating_sub(self.nullspace_dim)
}
pub fn quadratic_form(&self, beta: ArrayView1<'_, f64>) -> f64 {
assert_eq!(
beta.len(),
self.dim(),
"joint penalty quadratic form: beta length {} != dim {}",
beta.len(),
self.dim()
);
beta.dot(&self.matrix.dot(&beta))
}
pub fn validated_root(&self) -> Result<Array2<f64>, JointPenaltyError> {
let (nrows, ncols) = self.matrix.dim();
if nrows != ncols {
return Err(JointPenaltyError::NotSquare { nrows, ncols });
}
if crate::validate_log_strength(self.initial_log_lambda).is_err() {
return Err(JointPenaltyError::InitialLogStrengthOutOfDomain {
value: self.initial_log_lambda,
});
}
if self.nullspace_dim > nrows {
return Err(JointPenaltyError::NullspaceTooLarge {
total: nrows,
nullspace_dim: self.nullspace_dim,
});
}
for ((row, col), &value) in self.matrix.indexed_iter() {
if !value.is_finite() {
return Err(JointPenaltyError::NonFiniteEntry { row, col, value });
}
}
for row in 0..nrows {
for col in (row + 1)..ncols {
let asymmetry = (self.matrix[[row, col]] - self.matrix[[col, row]]).abs();
if asymmetry > Self::SYMMETRY_TOL {
return Err(JointPenaltyError::NotSymmetric {
row,
col,
asymmetry,
});
}
}
}
if nrows == 0 {
return Ok(Array2::zeros((0, 0)));
}
use gam_linalg::faer_ndarray::FaerEigh;
let (eigenvalues, eigenvectors) =
FaerEigh::eigh(&self.matrix, faer::Side::Lower).map_err(|e| {
JointPenaltyError::EigendecompositionFailed {
reason: e.to_string(),
}
})?;
let max_abs_eigenvalue = eigenvalues
.iter()
.fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
let tol = 100.0 * (nrows as f64) * f64::EPSILON * max_abs_eigenvalue;
if let Some(&min_eigenvalue) = eigenvalues
.iter()
.filter(|&&ev| ev < -tol)
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
{
return Err(JointPenaltyError::NotPositiveSemidefinite {
min_eigenvalue,
max_abs_eigenvalue,
});
}
let active: Vec<usize> = eigenvalues
.iter()
.enumerate()
.filter_map(|(index, &value)| (value > tol).then_some(index))
.collect();
let numerical = nrows - active.len();
if numerical != self.nullspace_dim {
return Err(JointPenaltyError::NullspaceMismatch {
declared: self.nullspace_dim,
numerical,
});
}
let mut root = Array2::<f64>::zeros((active.len(), nrows));
for (root_row, &eigen_index) in active.iter().enumerate() {
let scale = eigenvalues[eigen_index].sqrt();
for column in 0..nrows {
root[[root_row, column]] = scale * eigenvectors[[column, eigen_index]];
}
}
Ok(root)
}
pub fn validate(&self) -> Result<(), JointPenaltyError> {
self.validated_root().map(|_| ())
}
}
#[derive(Clone, Debug)]
pub struct JointPenaltyBundle {
specs: std::sync::Arc<Vec<JointPenaltySpec>>,
roots: std::sync::Arc<Vec<Array2<f64>>>,
log_lambdas: Vec<f64>,
lambdas: Vec<f64>,
}
impl JointPenaltyBundle {
pub fn new(
specs: std::sync::Arc<Vec<JointPenaltySpec>>,
log_lambdas: Vec<f64>,
total_compiled: usize,
) -> Result<Self, String> {
let roots = specs
.iter()
.enumerate()
.map(|(index, spec)| {
spec.validated_root()
.map_err(|error| format!("joint penalty {index}: {error}"))
})
.collect::<Result<Vec<_>, _>>()?;
Self::from_validated_geometry(
specs,
std::sync::Arc::new(roots),
log_lambdas,
total_compiled,
)
}
pub fn from_validated_geometry(
specs: std::sync::Arc<Vec<JointPenaltySpec>>,
roots: std::sync::Arc<Vec<Array2<f64>>>,
log_lambdas: Vec<f64>,
total_compiled: usize,
) -> Result<Self, String> {
if specs.len() != log_lambdas.len() {
return Err(format!(
"joint penalty bundle: {} specs vs {} log_lambdas",
specs.len(),
log_lambdas.len(),
));
}
if roots.len() != specs.len() {
return Err(format!(
"joint penalty bundle: {} specs vs {} cached roots",
specs.len(),
roots.len(),
));
}
let mut lambdas = Vec::with_capacity(log_lambdas.len());
for (i, ((spec, root), &log_lambda)) in specs
.iter()
.zip(roots.iter())
.zip(log_lambdas.iter())
.enumerate()
{
if spec.dim() != total_compiled {
return Err(format!(
"joint penalty {i}: dim {} != total_compiled {}",
spec.dim(),
total_compiled,
));
}
if root.dim() != (spec.pseudo_rank(), total_compiled) {
return Err(format!(
"joint penalty {i}: cached root shape {}x{} != rank-by-dimension {}x{}",
root.nrows(),
root.ncols(),
spec.pseudo_rank(),
total_compiled,
));
}
if let Some(((row, column), &value)) =
root.indexed_iter().find(|(_, value)| !value.is_finite())
{
return Err(format!(
"joint penalty {i}: cached root has non-finite entry at ({row},{column}): {value}"
));
}
lambdas.push(
crate::checked_exp_log_strength(log_lambda)
.map_err(|error| format!("joint penalty {i} current log-precision: {error}"))?,
);
}
Ok(Self {
specs,
roots,
log_lambdas,
lambdas,
})
}
#[inline]
pub fn len(&self) -> usize {
self.specs.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
#[inline]
pub fn specs(&self) -> &[JointPenaltySpec] {
self.specs.as_slice()
}
#[inline]
pub fn roots(&self) -> &[Array2<f64>] {
self.roots.as_slice()
}
#[inline]
pub fn log_lambdas(&self) -> &[f64] {
self.log_lambdas.as_slice()
}
#[inline]
pub fn lambdas(&self) -> &[f64] {
self.lambdas.as_slice()
}
pub fn quadratic(&self, beta: ArrayView1<'_, f64>) -> f64 {
let mut total = 0.0;
for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
total += 0.5 * lam * spec.quadratic_form(beta);
}
total
}
pub fn add_apply_into(&self, vector: ArrayView1<'_, f64>, out: &mut ndarray::Array1<f64>) {
assert_eq!(out.len(), vector.len());
for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
let sv = spec.matrix.dot(&vector);
out.scaled_add(lam, &sv);
}
}
pub fn add_diag(&self, diag: &mut ndarray::Array1<f64>) {
for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
for (i, value) in spec.matrix.diag().iter().enumerate() {
diag[i] += lam * *value;
}
}
}
pub fn add_to_matrix(&self, matrix: &mut Array2<f64>) {
assert_eq!(matrix.nrows(), matrix.ncols());
for (spec, &lam) in self.specs.iter().zip(self.lambdas.iter()) {
matrix.scaled_add(lam, &spec.matrix);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2, array};
fn cross_block_spec() -> JointPenaltySpec {
let v: Array1<f64> = array![1.0, 0.0, -1.0, 0.0];
let w: Array1<f64> = array![0.0, 1.0, 0.0, -1.0];
let mut matrix: Array2<f64> = Array2::zeros((4, 4));
for i in 0..4 {
for j in 0..4 {
matrix[[i, j]] = v[i] * v[j] + w[i] * w[j];
}
}
JointPenaltySpec {
label: Some("cross_block_pullback".to_string()),
matrix,
initial_log_lambda: -1.5,
nullspace_dim: 2,
group: None,
}
}
#[test]
fn cross_block_dense_validates() {
let result = cross_block_spec().validate();
assert!(
result.is_ok(),
"valid cross-block spec rejected: {result:?}"
);
}
#[test]
fn trace_matches_diagonal_sum() {
let spec = cross_block_spec();
assert!((spec.trace() - 4.0).abs() < 1e-12);
}
#[test]
fn pseudo_rank_uses_declared_nullspace() {
let spec = cross_block_spec();
assert_eq!(spec.dim(), 4);
assert_eq!(spec.pseudo_rank(), 2);
}
#[test]
fn quadratic_form_matches_explicit_mat_vec() {
let spec = cross_block_spec();
let beta: Array1<f64> = array![0.5, -0.25, 1.0, 0.75];
let q = spec.quadratic_form(beta.view());
assert!((q - 1.25).abs() < 1e-12, "got {q}");
}
#[test]
fn determinant_zero_for_rank_deficient_matches_nullspace() {
use gam_linalg::faer_ndarray::FaerEigh;
let spec = cross_block_spec();
let (eigvals, _) =
FaerEigh::eigh(&spec.matrix, faer::Side::Lower).expect("symmetric eigh succeeds");
let mut sorted: Vec<f64> = eigvals.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-10).count();
assert_eq!(
zeros, spec.nullspace_dim,
"spectrum {sorted:?} should have {} near-zeros",
spec.nullspace_dim
);
let det: f64 = sorted.iter().product();
assert!(det.abs() < 1e-10, "expected ~0 determinant, got {det}");
}
#[test]
fn validate_rejects_non_square() {
let spec = JointPenaltySpec {
label: None,
matrix: Array2::zeros((3, 4)),
initial_log_lambda: 0.0,
nullspace_dim: 0,
group: None,
};
assert!(matches!(
spec.validate(),
Err(JointPenaltyError::NotSquare { nrows: 3, ncols: 4 })
));
}
#[test]
fn validate_rejects_non_symmetric() {
let mut matrix = Array2::<f64>::zeros((3, 3));
matrix[[0, 1]] = 1.0;
matrix[[1, 0]] = -1.0;
let spec = JointPenaltySpec {
label: None,
matrix,
initial_log_lambda: 0.0,
nullspace_dim: 0,
group: None,
};
assert!(matches!(
spec.validate(),
Err(JointPenaltyError::NotSymmetric { .. })
));
}
#[test]
fn validate_rejects_oversized_nullspace() {
let spec = JointPenaltySpec {
label: None,
matrix: Array2::zeros((3, 3)),
initial_log_lambda: 0.0,
nullspace_dim: 4,
group: None,
};
assert!(matches!(
spec.validate(),
Err(JointPenaltyError::NullspaceTooLarge {
total: 3,
nullspace_dim: 4
})
));
}
#[test]
fn validate_rejects_initial_log_strength_outside_exact_domain() {
let spec = JointPenaltySpec {
label: None,
matrix: Array2::zeros((2, 2)),
initial_log_lambda: f64::NAN,
nullspace_dim: 0,
group: None,
};
assert!(matches!(
spec.validate(),
Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
));
let mut finite_but_too_large = cross_block_spec();
finite_but_too_large.initial_log_lambda = crate::LOG_STRENGTH_MAX + 1.0;
assert!(matches!(
finite_but_too_large.validate(),
Err(JointPenaltyError::InitialLogStrengthOutOfDomain { .. })
));
}
#[test]
fn bundle_construction_is_atomic_at_exact_log_strength_faces() {
let specs = std::sync::Arc::new(vec![cross_block_spec(), cross_block_spec()]);
let bundle = JointPenaltyBundle::new(
specs.clone(),
vec![crate::LOG_STRENGTH_MIN, crate::LOG_STRENGTH_MAX],
4,
)
.expect("closed endpoints");
for ((&actual, &log_strength), expected) in bundle
.lambdas()
.iter()
.zip(bundle.log_lambdas())
.zip([crate::LOG_STRENGTH_MIN.exp(), crate::LOG_STRENGTH_MAX.exp()])
{
assert_eq!(actual.to_bits(), expected.to_bits());
assert_eq!(actual.to_bits(), log_strength.exp().to_bits());
}
let error = JointPenaltyBundle::new(specs, vec![0.0, crate::LOG_STRENGTH_MAX + 1.0], 4)
.expect_err("one invalid coordinate refuses the whole bundle");
assert!(error.contains("joint penalty 1 current log-precision"));
}
#[test]
fn bundle_rejects_dim_mismatch() {
let spec = JointPenaltySpec {
label: None,
matrix: Array2::<f64>::eye(3),
initial_log_lambda: 0.0,
nullspace_dim: 0,
group: None,
};
let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![0.0], 4)
.expect_err("dim mismatch must reject");
assert!(err.contains("total_compiled"));
}
#[test]
fn bundle_rejects_lambda_count_mismatch() {
let spec = JointPenaltySpec {
label: None,
matrix: Array2::<f64>::eye(2),
initial_log_lambda: 0.0,
nullspace_dim: 0,
group: None,
};
let err = JointPenaltyBundle::new(std::sync::Arc::new(vec![spec]), vec![], 2)
.expect_err("count mismatch must reject");
assert!(err.contains("specs vs"));
}
}