use super::*;
pub fn periodic_bspline_first_derivative_nd(
t: ArrayView2<'_, f64>,
data_range: (f64, f64),
degree: usize,
num_basis: usize,
) -> Result<Array3<f64>, BasisError> {
if t.ncols() != 1 {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd: t must have exactly 1 column; got {}",
t.ncols()
);
}
if degree == 0 {
crate::bail_invalid_basis!("periodic_bspline_first_derivative_nd requires degree >= 1");
}
if num_basis < degree + 1 {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd requires num_basis >= degree + 1 (got num_basis={num_basis}, degree={degree})"
);
}
let (start, end) = data_range;
if !(start.is_finite() && end.is_finite()) || end <= start {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd: data_range must be finite and ordered, got {data_range:?}"
);
}
let period = end - start;
let n_rows = t.nrows();
let t_col = t.column(0);
let mut phi = vec![0.0_f64; num_basis];
let mut dphi = vec![0.0_f64; num_basis];
let mut out = Array3::<f64>::zeros((n_rows, num_basis, 1));
for row in 0..n_rows {
let xi = t_col[row];
if !xi.is_finite() {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd: non-finite latent at row {row}"
);
}
let rowsum =
fill_periodic_bspline_unnormalized_value_row(xi, start, period, degree, &mut phi);
if !rowsum.is_finite() || rowsum <= 0.0 {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd: non-positive rowsum at row {row}: {rowsum}"
);
}
let rowsum_derivative =
fill_periodic_bspline_unnormalized_derivative_row(xi, start, period, degree, &mut dphi);
if !rowsum_derivative.is_finite() {
crate::bail_invalid_basis!(
"periodic_bspline_first_derivative_nd: non-finite rowsum derivative at row {row}: {rowsum_derivative}"
);
}
let rowsum_squared = rowsum * rowsum;
for i in 0..num_basis {
out[[row, i, 0]] = dphi[i] / rowsum - phi[i] * rowsum_derivative / rowsum_squared;
}
}
Ok(out)
}
pub fn bspline_tensor_first_derivative(
t: ArrayView2<'_, f64>,
knots_per_axis: &[ArrayView1<'_, f64>],
degrees: &[usize],
) -> Result<Array3<f64>, BasisError> {
let n_axes = t.ncols();
if knots_per_axis.len() != n_axes || degrees.len() != n_axes {
crate::bail_invalid_basis!(
"bspline_tensor_first_derivative: t has {n_axes} axes but received \
{} knot vectors and {} degrees",
knots_per_axis.len(),
degrees.len(),
);
}
if n_axes == 0 {
crate::bail_invalid_basis!(
"bspline_tensor_first_derivative: t must have at least one axis".into(),
);
}
let n_rows = t.nrows();
let mut k_per_axis = Vec::<usize>::with_capacity(n_axes);
let mut total = 1usize;
for a in 0..n_axes {
let k = knots_per_axis[a]
.len()
.checked_sub(degrees[a] + 1)
.ok_or_else(|| {
BasisError::InvalidInput(format!(
"bspline_tensor_first_derivative: axis {a} knot vector too short \
for degree {}",
degrees[a]
))
})?;
k_per_axis.push(k);
total = total.checked_mul(k).ok_or_else(|| {
BasisError::InvalidInput(
"bspline_tensor_first_derivative: tensor-product basis size overflow".into(),
)
})?;
}
let mut out = Array3::<f64>::zeros((n_rows, total, n_axes));
let mut values_per_axis: Vec<Vec<f64>> = k_per_axis.iter().map(|&k| vec![0.0; k]).collect();
let mut derivs_per_axis: Vec<Vec<f64>> = k_per_axis.iter().map(|&k| vec![0.0; k]).collect();
let mut value_scratch_per_axis: Vec<internal::BsplineScratch> = degrees
.iter()
.map(|&d| internal::BsplineScratch::new(d))
.collect();
let mut lower_basis_per_axis: Vec<Vec<f64>> = knots_per_axis
.iter()
.zip(degrees.iter())
.map(|(knots, &d)| vec![0.0; knots.len().saturating_sub(d)])
.collect();
let mut lower_scratch_per_axis: Vec<internal::BsplineScratch> = degrees
.iter()
.map(|&d| internal::BsplineScratch::new(d.saturating_sub(1)))
.collect();
let mut idx = vec![0usize; n_axes];
let mut prefix = vec![1.0; n_axes + 1];
let mut suffix = vec![1.0; n_axes + 1];
for n in 0..n_rows {
for a in 0..n_axes {
internal::evaluate_splines_at_point_into(
t[[n, a]],
degrees[a],
knots_per_axis[a],
&mut values_per_axis[a],
&mut value_scratch_per_axis[a],
);
evaluate_bspline_derivative_scalar_into(
t[[n, a]],
knots_per_axis[a],
degrees[a],
&mut derivs_per_axis[a],
&mut lower_basis_per_axis[a],
&mut lower_scratch_per_axis[a],
)?;
}
for k in 0..total {
let mut rem = k;
for a in (0..n_axes).rev() {
idx[a] = rem % k_per_axis[a];
rem /= k_per_axis[a];
}
prefix[0] = 1.0;
for a in 0..n_axes {
prefix[a + 1] = prefix[a] * values_per_axis[a][idx[a]];
}
suffix[n_axes] = 1.0;
for a in (0..n_axes).rev() {
suffix[a] = suffix[a + 1] * values_per_axis[a][idx[a]];
}
for axis in 0..n_axes {
let leave_one_out = prefix[axis] * suffix[axis + 1];
out[[n, k, axis]] = derivs_per_axis[axis][idx[axis]] * leave_one_out;
}
}
}
Ok(out)
}
#[inline]
pub(crate) fn periodic_distance_1d(x: f64, c: f64, period: f64) -> f64 {
let dx = (x - c).rem_euclid(period).abs();
dx.min(period - dx).abs()
}
pub(crate) fn even_bernoulli_polynomial(degree: usize, t: f64) -> Result<f64, BasisError> {
let t2 = t * t;
match degree {
2 => Ok(t2 - t + 1.0 / 6.0),
4 => Ok(t2 * t2 - 2.0 * t2 * t + t2 - 1.0 / 30.0),
6 => {
let t4 = t2 * t2;
let t6 = t4 * t2;
Ok(t6 - 3.0 * t4 * t + 2.5 * t4 - 0.5 * t2 + 1.0 / 42.0)
}
8 => {
let t4 = t2 * t2;
let t6 = t4 * t2;
let t8 = t4 * t4;
Ok(
t8 - 4.0 * t6 * t + (14.0 / 3.0) * t6 - (7.0 / 3.0) * t4 + (2.0 / 3.0) * t2
- 1.0 / 30.0,
)
}
other => Err(BasisError::InvalidInput(format!(
"periodic Duchon Bernoulli kernel only implemented for B_{{2m}} with m ∈ {{1, 2, 3, 4}}; got degree {other}"
))),
}
}
pub(crate) fn periodic_duchon_kernel_bernoulli(
r: f64,
m: usize,
period: f64,
) -> Result<f64, BasisError> {
if !period.is_finite() || period <= 0.0 {
crate::bail_invalid_basis!(
"periodic Duchon kernel requires positive finite period; got {period}"
);
}
if m == 0 {
crate::bail_invalid_basis!("periodic Duchon order m must be at least 1");
}
let t = (r / period).rem_euclid(1.0);
let sign = if m % 2 == 1 { 1.0 } else { -1.0 };
Ok(sign * even_bernoulli_polynomial(2 * m, t)?)
}
pub(crate) fn even_bernoulli_polynomial_derivatives(
degree: usize,
s: f64,
) -> Result<(f64, f64), BasisError> {
let s2 = s * s;
match degree {
2 => Ok((2.0 * s - 1.0, 2.0)),
4 => {
let d1 = 4.0 * s2 * s - 6.0 * s2 + 2.0 * s;
let d2 = 12.0 * s2 - 12.0 * s + 2.0;
Ok((d1, d2))
}
6 => {
let s3 = s2 * s;
let s4 = s2 * s2;
let s5 = s4 * s;
let d1 = 6.0 * s5 - 15.0 * s4 + 10.0 * s3 - s;
let d2 = 30.0 * s4 - 60.0 * s3 + 30.0 * s2 - 1.0;
Ok((d1, d2))
}
8 => {
let s3 = s2 * s;
let s4 = s2 * s2;
let s5 = s4 * s;
let s6 = s4 * s2;
let s7 = s6 * s;
let d1 = 8.0 * s7 - 28.0 * s6 + 28.0 * s5 - (28.0 / 3.0) * s3 + (4.0 / 3.0) * s;
let d2 = 56.0 * s6 - 168.0 * s5 + 140.0 * s4 - 28.0 * s2 + 4.0 / 3.0;
Ok((d1, d2))
}
other => Err(BasisError::InvalidInput(format!(
"periodic Duchon Bernoulli kernel derivative only implemented for B_{{2m}} with m ∈ {{1, 2, 3, 4}}; got degree {other}"
))),
}
}
pub(crate) fn periodic_duchon_kernel_bernoulli_triplet(
r: f64,
m: usize,
period: f64,
) -> Result<(f64, f64, f64), BasisError> {
if !period.is_finite() || period <= 0.0 {
crate::bail_invalid_basis!(
"periodic Duchon kernel requires positive finite period; got {period}"
);
}
if m == 0 {
crate::bail_invalid_basis!("periodic Duchon order m must be at least 1");
}
let s = (r / period).rem_euclid(1.0);
let sign = if m % 2 == 1 { 1.0 } else { -1.0 };
let phi = sign * even_bernoulli_polynomial(2 * m, s)?;
let (b1, b2) = even_bernoulli_polynomial_derivatives(2 * m, s)?;
let dphi_dr = sign * b1 / period;
let d2phi_dr2 = sign * b2 / (period * period);
Ok((phi, dphi_dr, d2phi_dr2))
}
fn scaled_bernoulli_value_and_derivative(nu: usize, t: f64) -> Result<(f64, f64), BasisError> {
let (bv, dbv) = match nu {
0 => (1.0, 0.0),
1 => (t - 0.5, 1.0),
2 => (t * t - t + 1.0 / 6.0, 2.0 * t - 1.0),
3 => {
let t2 = t * t;
(t2 * t - 1.5 * t2 + 0.5 * t, 3.0 * t2 - 3.0 * t + 0.5)
}
4 => {
let t2 = t * t;
(
t2 * t2 - 2.0 * t2 * t + t2 - 1.0 / 30.0,
4.0 * t2 * t - 6.0 * t2 + 2.0 * t,
)
}
other => {
crate::bail_invalid_basis!(
"mixed-periodicity Sobolev kernel needs Bernoulli degree ν ≤ 4; got {other}"
)
}
};
let factorial = (1..=nu).map(|v| v as f64).product::<f64>();
Ok((bv / factorial, dbv / factorial))
}
fn nonperiodic_sobolev_kernel_1d(
x: f64,
y: f64,
m: usize,
lo: f64,
hi: f64,
) -> Result<f64, BasisError> {
if m == 0 {
crate::bail_invalid_basis!("non-periodic Sobolev kernel order m must be at least 1");
}
let span = (hi - lo).max(1e-300);
let xs = ((x - lo) / span).clamp(0.0, 1.0);
let ys = ((y - lo) / span).clamp(0.0, 1.0);
let (kx, _) = scaled_bernoulli_value_and_derivative(m, xs)?;
let (ky, _) = scaled_bernoulli_value_and_derivative(m, ys)?;
let diff = (xs - ys).abs();
let sign = if m % 2 == 1 { 1.0 } else { -1.0 };
let k2m = even_bernoulli_polynomial(2 * m, diff)? / factorial_f64(2 * m);
Ok(kx * ky + sign * k2m)
}
pub(crate) fn nonperiodic_sobolev_kernel_1d_triplet(
x: f64,
y: f64,
m: usize,
lo: f64,
hi: f64,
) -> Result<(f64, f64, f64), BasisError> {
if m == 0 {
crate::bail_invalid_basis!("non-periodic Sobolev kernel order m must be at least 1");
}
let span = (hi - lo).max(1e-300);
let xs = ((x - lo) / span).clamp(0.0, 1.0);
let ys = ((y - lo) / span).clamp(0.0, 1.0);
let (kx, dkx) = scaled_bernoulli_value_and_derivative(m, xs)?;
let (ky, _) = scaled_bernoulli_value_and_derivative(m, ys)?;
let (_, d2kx_inner) = if m >= 1 {
scaled_bernoulli_value_and_derivative(m.saturating_sub(1), xs)?
} else {
(0.0, 0.0)
};
let sign = if m % 2 == 1 { 1.0 } else { -1.0 };
let diff = xs - ys;
let adiff = diff.abs();
let (b1, b2) = even_bernoulli_polynomial_derivatives(2 * m, adiff)?;
let fac2m = factorial_f64(2 * m);
let k2m = even_bernoulli_polynomial(2 * m, adiff)? / fac2m;
let dsign = if diff >= 0.0 { 1.0 } else { -1.0 };
let value = kx * ky + sign * k2m;
let d1 = (dkx * ky / span) + sign * (b1 / fac2m) * dsign / span;
let d2 = (d2kx_inner * ky / (span * span)) + sign * (b2 / fac2m) / (span * span);
Ok((value, d1, d2))
}
#[inline]
fn factorial_f64(n: usize) -> f64 {
(1..=n).map(|v| v as f64).product::<f64>()
}
pub(crate) fn mixed_periodicity_axis_bounds(
centers: ArrayView2<'_, f64>,
periodic_per_axis: &[bool],
) -> Vec<(f64, f64)> {
let d = centers.ncols();
(0..d)
.map(|j| {
if periodic_per_axis[j] {
(0.0, 1.0)
} else {
let col = centers.column(j);
let lo = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let hi = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
(lo, hi)
}
})
.collect()
}
pub(crate) fn mixed_periodicity_additive_kernel(
x: ArrayView1<'_, f64>,
c: ArrayView1<'_, f64>,
m: usize,
periodic_per_axis: &[bool],
periods: &[f64],
axis_bounds: &[(f64, f64)],
) -> Result<f64, BasisError> {
let d = x.len();
let mut acc = 0.0_f64;
for j in 0..d {
acc += if periodic_per_axis[j] {
let r = periodic_distance_1d(x[j], c[j], periods[j]);
periodic_duchon_kernel_bernoulli(r, m, periods[j])?
} else {
let (lo, hi) = axis_bounds[j];
nonperiodic_sobolev_kernel_1d(x[j], c[j], m, lo, hi)?
};
}
Ok(acc)
}
pub(crate) fn mixed_periodicity_additive_kernel_jet(
x: ArrayView1<'_, f64>,
c: ArrayView1<'_, f64>,
m: usize,
periodic_per_axis: &[bool],
periods: &[f64],
axis_bounds: &[(f64, f64)],
) -> Result<(f64, Vec<f64>, Vec<f64>), BasisError> {
let d = x.len();
let mut value = 0.0_f64;
let mut grad = vec![0.0_f64; d];
let mut hess = vec![0.0_f64; d];
for a in 0..d {
let (v, d1, d2) = if periodic_per_axis[a] {
let p = periods[a];
let signed = {
let raw = (x[a] - c[a]).rem_euclid(p);
if raw > 0.5 * p { raw - p } else { raw }
};
let r = signed.abs();
let (phi, dphi_dr, d2phi_dr2) = periodic_duchon_kernel_bernoulli_triplet(r, m, p)?;
let dsign = if signed >= 0.0 { 1.0 } else { -1.0 };
(phi, dphi_dr * dsign, d2phi_dr2)
} else {
let (lo, hi) = axis_bounds[a];
nonperiodic_sobolev_kernel_1d_triplet(x[a], c[a], m, lo, hi)?
};
value += v;
grad[a] = d1;
hess[a] = d2;
}
Ok((value, grad, hess))
}
pub(crate) fn mixed_periodicity_nullspace_poly_block(
points: ArrayView2<'_, f64>,
m: usize,
periodic_per_axis: &[bool],
) -> Array2<f64> {
let n = points.nrows();
let nonperiodic_axes: Vec<usize> = (0..points.ncols())
.filter(|&j| !periodic_per_axis[j])
.collect();
let max_degree = m.saturating_sub(1);
let exps = monomial_exponents(nonperiodic_axes.len(), max_degree);
let mut block = Array2::<f64>::zeros((n, exps.len()));
for (col, exp) in exps.iter().enumerate() {
for row in 0..n {
let mut value = 1.0_f64;
for (local_axis, &power) in exp.iter().enumerate() {
let axis = nonperiodic_axes[local_axis];
value *= points[[row, axis]].powi(power as i32);
}
block[[row, col]] = value;
}
}
block
}
pub(crate) fn collapse_periodic_endpoint(
centers: Array2<f64>,
left: f64,
period: f64,
) -> Array2<f64> {
if period <= 0.0 || !period.is_finite() {
return centers;
}
let tol = period.max(1.0) * 1.0e-10;
let col = centers.column(0);
let n_rows = col.len();
let mut seen_left = false;
let keep: Vec<usize> = (0..n_rows)
.filter(|&i| {
if periodic_distance_1d(col[i], left, period) <= tol {
if seen_left {
return false;
}
seen_left = true;
}
true
})
.collect();
if keep.len() == n_rows {
return centers;
}
let mut trimmed = Array2::<f64>::zeros((keep.len(), centers.ncols()));
for (out_row, &src_row) in keep.iter().enumerate() {
for c in 0..centers.ncols() {
trimmed[[out_row, c]] = centers[[src_row, c]];
}
}
trimmed
}
pub(crate) fn build_periodic_duchon_basis_1d(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
centers: Array2<f64>,
workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
if data.ncols() != 1 {
crate::bail_invalid_basis!(
"periodic Duchon smooths currently require exactly one covariate"
);
}
let explicit_period = spec
.periodic
.as_ref()
.and_then(|axes| axes.first().copied().flatten());
let (centers, left, period) =
prepare_periodic_duchon_centers_1d_with_period(centers, explicit_period)?;
let user_m = duchon_p_from_nullspace_order(spec.nullspace_order);
let effective_nullspace_order = DuchonNullspaceOrder::Zero;
let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
let s_order = spec.power_as_usize();
validate_duchon_kernel_orders(spec.length_scale, p_order, s_order as f64, 1)?;
let z = kernel_constraint_nullspace(
centers.view(),
effective_nullspace_order,
&mut workspace.cache,
)?;
let kernel_cols = z.ncols();
let mut basis = Array2::<f64>::zeros((data.nrows(), kernel_cols + 1));
let coeffs = spec
.length_scale
.map(|ls| duchon_partial_fraction_coeffs(p_order, s_order, 1.0 / ls.max(1e-300)));
let pure_poly_coeff = if spec.length_scale.is_none() {
Some(PolyharmonicBlockCoeff::new(
(pure_duchon_block_order(p_order, s_order as f64)) as f64,
1,
))
} else {
None
};
let kernel_amp = duchon_kernel_amplification(
centers.view(),
spec.length_scale,
p_order,
s_order,
1,
None,
coeffs.as_ref(),
pure_poly_coeff.as_ref(),
);
let centers_col0: Vec<f64> = centers.column(0).to_vec();
let n_data = data.nrows();
let k_centers = centers_col0.len();
let len_scale = spec.length_scale;
let mut raw_kernel = Array2::<f64>::zeros((n_data, k_centers));
let err_flag = std::sync::atomic::AtomicBool::new(false);
let amp = kernel_amp;
if pure_poly_coeff.is_some() {
raw_kernel
.axis_chunks_iter_mut(ndarray::Axis(0), 1024)
.into_par_iter()
.enumerate()
.for_each(|(chunk_idx, mut block)| {
let row_offset = chunk_idx * 1024;
for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
let i = row_offset + local_i;
let x = wrap_to_period(data[[i, 0]], left, period);
for j in 0..k_centers {
let r = periodic_distance_1d(x, centers_col0[j], period);
match periodic_duchon_kernel_bernoulli(r, user_m, period) {
Ok(v) => out_row[j] = v,
Err(_) => {
err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
return;
}
}
}
}
});
} else {
raw_kernel
.axis_chunks_iter_mut(ndarray::Axis(0), 1024)
.into_par_iter()
.enumerate()
.for_each(|(chunk_idx, mut block)| {
let row_offset = chunk_idx * 1024;
for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
let i = row_offset + local_i;
let x = wrap_to_period(data[[i, 0]], left, period);
for j in 0..k_centers {
let r = periodic_distance_1d(x, centers_col0[j], period);
match duchon_matern_kernel_general_from_distance(
r,
len_scale,
p_order,
s_order,
1,
coeffs.as_ref(),
) {
Ok(v) => out_row[j] = v * amp,
Err(_) => {
err_flag.store(true, std::sync::atomic::Ordering::Relaxed);
return;
}
}
}
}
});
}
if err_flag.load(std::sync::atomic::Ordering::Relaxed) {
crate::bail_invalid_basis!("periodic Duchon kernel evaluation produced a non-finite value");
}
let design_kernel = fast_ab(&raw_kernel, &z);
basis
.slice_mut(s![.., 0..kernel_cols])
.assign(&design_kernel);
basis.column_mut(kernel_cols).fill(1.0);
let mut center_kernel = Array2::<f64>::zeros((centers.nrows(), centers.nrows()));
fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
let r = periodic_distance_1d(centers[[i, 0]], centers[[j, 0]], period);
if pure_poly_coeff.is_some() {
periodic_duchon_kernel_bernoulli(r, user_m, period)
} else {
Ok(duchon_matern_kernel_general_from_distance(
r,
spec.length_scale,
p_order,
s_order,
1,
coeffs.as_ref(),
)? * kernel_amp)
}
})?;
let omega = fast_ab(&fast_atb(&z, ¢er_kernel), &z);
let mut penalty = Array2::<f64>::zeros((basis.ncols(), basis.ncols()));
penalty
.slice_mut(s![0..kernel_cols, 0..kernel_cols])
.assign(&omega);
let base_design = DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(basis));
let identifiability_transform = spatial_identifiability_transform_from_design_matrix(
data,
&base_design,
&spec.identifiability,
"periodic Duchon",
)?;
let (design, primary) = if let Some(transform) = identifiability_transform.as_ref() {
let design = wrap_dense_design_with_transform(base_design, transform, "periodic Duchon")?;
let transformed = fast_ab(&fast_atb(transform, &penalty), transform);
(design, transformed)
} else {
(base_design, penalty)
};
let candidates = vec![normalize_penalty_candidate(
primary,
1,
PenaltySource::Primary,
)];
let (penalties, nullspace_dims, penaltyinfo, null_eigenvectors, ops) =
filter_active_penalty_candidates_with_ops(candidates)?;
Ok(BasisBuildResult {
design,
penalties,
nullspace_dims,
penaltyinfo,
ops,
null_eigenvectors,
joint_null_rotation: None,
metadata: BasisMetadata::Duchon {
centers,
length_scale: spec.length_scale,
periodic: Some(vec![Some(period)]),
power: spec.power,
nullspace_order: effective_nullspace_order,
identifiability_transform,
input_scales: None,
aniso_log_scales: None,
operator_collocation_points: None,
radial_reparam: None,
},
kronecker_factored: None,
})
}
pub(crate) fn build_duchon_basis_mixed_periodicity(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
centers: Array2<f64>,
periodic_per_axis: &[bool],
periods: &[f64],
_workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
let d = data.ncols();
if d == 0 {
crate::bail_invalid_basis!("Duchon basis requires at least one covariate dimension");
}
if periodic_per_axis.len() != d {
crate::bail_invalid_basis!(
"periodic_per_axis must have length d={d}, got {}",
periodic_per_axis.len()
);
}
if periods.len() != d {
crate::bail_invalid_basis!("periods must have length d={d}, got {}", periods.len());
}
for (j, (&per, &period)) in periodic_per_axis.iter().zip(periods.iter()).enumerate() {
if per && !(period.is_finite() && period > 0.0) {
crate::bail_invalid_basis!(
"axis {j} is periodic but period={period} is not finite & positive"
);
}
}
if centers.ncols() != d {
crate::bail_invalid_basis!(
"centers ncols={} does not match data ncols={d}",
centers.ncols()
);
}
if spec.length_scale.is_some() {
crate::bail_invalid_basis!(
"mixed-periodicity Duchon basis currently only supports the pure polyharmonic spectrum (length_scale=None)"
);
}
if spec.power != 0.0 {
crate::bail_invalid_basis!(
"mixed-periodicity Duchon basis currently requires power = 0 (pure polyharmonic); got power={}",
spec.power
);
}
let user_m = duchon_p_from_nullspace_order(spec.nullspace_order);
let s_order_int = 0usize;
validate_duchon_kernel_orders(None, user_m, s_order_int as f64, d)?;
let axis_bounds = mixed_periodicity_axis_bounds(centers.view(), periodic_per_axis);
let centers_owned = centers.clone();
let k_centers = centers_owned.nrows();
let n_data = data.nrows();
let poly_block_centers =
mixed_periodicity_nullspace_poly_block(centers_owned.view(), user_m, periodic_per_axis);
let z = kernel_constraint_nullspace_from_matrix(poly_block_centers.view())?;
let kernel_cols = z.ncols();
let n_poly = poly_block_centers.ncols();
let mut raw_kernel = Array2::<f64>::zeros((n_data, k_centers));
let kernel_err: std::sync::Mutex<Option<BasisError>> = std::sync::Mutex::new(None);
raw_kernel
.axis_chunks_iter_mut(ndarray::Axis(0), 1024)
.into_par_iter()
.enumerate()
.for_each(|(chunk_idx, mut block)| {
let row_offset = chunk_idx * 1024;
for (local_i, mut out_row) in block.outer_iter_mut().enumerate() {
let i = row_offset + local_i;
let x_row = data.row(i);
for j in 0..k_centers {
match mixed_periodicity_additive_kernel(
x_row,
centers_owned.row(j),
user_m,
periodic_per_axis,
periods,
&axis_bounds,
) {
Ok(v) => out_row[j] = v,
Err(e) => {
*kernel_err.lock().unwrap() = Some(e);
return;
}
}
}
}
});
if let Some(e) = kernel_err.into_inner().unwrap() {
return Err(e);
}
let design_kernel = fast_ab(&raw_kernel, &z);
let poly_block_data = mixed_periodicity_nullspace_poly_block(data, user_m, periodic_per_axis);
let mut basis = Array2::<f64>::zeros((n_data, kernel_cols + n_poly));
basis
.slice_mut(s![.., 0..kernel_cols])
.assign(&design_kernel);
basis
.slice_mut(s![.., kernel_cols..kernel_cols + n_poly])
.assign(&poly_block_data);
let mut center_kernel = Array2::<f64>::zeros((k_centers, k_centers));
fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
mixed_periodicity_additive_kernel(
centers_owned.row(i),
centers_owned.row(j),
user_m,
periodic_per_axis,
periods,
&axis_bounds,
)
})?;
let omega = fast_ab(&fast_atb(&z, ¢er_kernel), &z);
let mut penalty = Array2::<f64>::zeros((basis.ncols(), basis.ncols()));
penalty
.slice_mut(s![0..kernel_cols, 0..kernel_cols])
.assign(&omega);
let base_design = DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(basis));
let identifiability_transform = spatial_identifiability_transform_from_design_matrix(
data,
&base_design,
&spec.identifiability,
"mixed-periodicity Duchon",
)?;
let (design, primary) = if let Some(transform) = identifiability_transform.as_ref() {
let design =
wrap_dense_design_with_transform(base_design, transform, "mixed-periodicity Duchon")?;
let transformed = fast_ab(&fast_atb(transform, &penalty), transform);
(design, transformed)
} else {
(base_design, penalty)
};
let candidates = vec![normalize_penalty_candidate(
primary,
1,
PenaltySource::Primary,
)];
let (penalties, nullspace_dims, penaltyinfo, null_eigenvectors, ops) =
filter_active_penalty_candidates_with_ops(candidates)?;
Ok(BasisBuildResult {
design,
penalties,
nullspace_dims,
penaltyinfo,
ops,
null_eigenvectors,
joint_null_rotation: None,
metadata: BasisMetadata::Duchon {
centers: centers_owned,
length_scale: None,
periodic: Some(
periodic_per_axis
.iter()
.zip(periods.iter())
.map(|(&is_periodic, &period)| if is_periodic { Some(period) } else { None })
.collect(),
),
power: spec.power,
nullspace_order: spec.nullspace_order,
identifiability_transform,
input_scales: None,
aniso_log_scales: None,
operator_collocation_points: None,
radial_reparam: None,
},
kronecker_factored: None,
})
}
pub fn build_duchon_basis_mixed_periodicity_auto(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
periodic_per_axis: &[bool],
periods: Option<&[f64]>,
) -> Result<BasisBuildResult, BasisError> {
let mut workspace = BasisWorkspace::default();
let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
let d = data.ncols();
if periodic_per_axis.len() != d {
crate::bail_invalid_basis!(
"periodic_per_axis must have length d={d}, got {}",
periodic_per_axis.len()
);
}
let resolved_periods: Vec<f64> = match periods {
Some(p) => {
if p.len() != d {
crate::bail_invalid_basis!("periods must have length d={d}, got {}", p.len());
}
p.to_vec()
}
None => {
let mut out = vec![1.0_f64; d];
for j in 0..d {
if periodic_per_axis[j] {
let col = centers.column(j);
let left = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let right = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
if !left.is_finite() || !right.is_finite() || left >= right {
return Err(BasisError::InvalidRange(left, right));
}
out[j] = right - left;
}
}
out
}
};
if d == 1 && periodic_per_axis[0] {
let mut periodic_spec = spec.clone();
periodic_spec.periodic = Some(vec![Some(resolved_periods[0])]);
return build_periodic_duchon_basis_1d(data, &periodic_spec, centers, &mut workspace);
}
build_duchon_basis_mixed_periodicity(
data,
spec,
centers,
periodic_per_axis,
&resolved_periods,
&mut workspace,
)
}
pub fn duchon_cubic_default(dim: usize) -> (DuchonNullspaceOrder, f64) {
(DuchonNullspaceOrder::Linear, (dim as f64 - 1.0) / 2.0)
}
pub(crate) fn duchon_constrained_bending_penalty(
centers: ArrayView2<'_, f64>,
length_scale: Option<f64>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
aniso_log_scales: Option<&[f64]>,
kernel_transform: &Array2<f64>,
) -> Result<Array2<f64>, BasisError> {
let dim = centers.ncols();
if dim == 0 {
crate::bail_invalid_basis!(
"duchon_constrained_bending_penalty: centers must have at least one column"
);
}
let k = centers.nrows();
let z = kernel_transform;
let p_order = duchon_p_from_nullspace_order(nullspace_order);
let s_int = duchon_power_to_usize(power);
let pure = length_scale.is_none();
let pure_poly_coeff = if pure {
Some(PolyharmonicBlockCoeff::new(
pure_duchon_block_order(p_order, power),
dim,
))
} else {
None
};
let coeffs =
length_scale.map(|ls| duchon_partial_fraction_coeffs(p_order, s_int, 1.0 / ls.max(1e-300)));
let kernel_amp = duchon_kernel_amplification(
centers,
length_scale,
p_order,
s_int,
dim,
aniso_log_scales,
coeffs.as_ref(),
pure_poly_coeff.as_ref(),
);
let axis_scales = aniso_log_scales.map(aniso_axis_scales);
let mut center_kernel = Array2::<f64>::zeros((k, k));
fill_symmetric_from_row_kernel(&mut center_kernel, |i, j| {
let r = if let Some(scales) = axis_scales.as_deref() {
aniso_distance_rows_with_scales(centers, i, centers, j, scales)
} else {
euclidean_distance_rows(centers, i, centers, j)
};
if let Some(ppc) = pure_poly_coeff.as_ref() {
Ok(ppc.eval(r))
} else {
duchon_matern_kernel_general_from_distance(
r,
length_scale,
p_order,
s_int,
dim,
coeffs.as_ref(),
)
}
})?;
let amp2 = kernel_amp * kernel_amp;
let zt_k = fast_atb(z, ¢er_kernel);
let omega = fast_ab(&zt_k, z).mapv(|v| v * amp2);
reject_nonpsd_then_clamp_noise(&symmetrize_penalty(&omega))
}
fn reject_nonpsd_then_clamp_noise(matrix: &Array2<f64>) -> Result<Array2<f64>, BasisError> {
use crate::linalg::faer_ndarray::FaerEigh;
use faer::Side;
let sym = symmetrize_penalty(matrix);
let n = sym.nrows();
if n == 0 || n != sym.ncols() {
return Ok(sym);
}
let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).map_err(|e| {
BasisError::InvalidInput(format!("Duchon penalty PSD check failed: {e}"))
})?;
if evals.is_empty() {
return Ok(sym);
}
let max_abs_ev = evals
.iter()
.copied()
.fold(0.0_f64, |acc, v| acc.max(v.abs()));
let min_ev = evals.iter().copied().fold(f64::INFINITY, f64::min);
let tol = (n as f64) * 1e-10 * max_abs_ev;
if min_ev < -tol {
crate::bail_invalid_basis!(
"Duchon constrained penalty is not positive semidefinite: λ_min={min_ev:.6e} \
(tol=−{tol:.6e}, λ_max={max_abs_ev:.6e}). The hybrid kernel's spectral density is \
nonnegative, so a materially-negative mode indicates the kernel evaluation lost \
positive-definiteness numerically (see gam#1424)."
);
}
Ok(project_penalty_to_psd_cone(&sym))
}
pub(crate) fn duchon_native_penalty_candidates(
centers: ArrayView2<'_, f64>,
length_scale: Option<f64>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
aniso_log_scales: Option<&[f64]>,
kernel_transform: &Array2<f64>,
outer_identifiability: Option<&Array2<f64>>,
poly_cols: usize,
) -> Result<Vec<PenaltyCandidate>, BasisError> {
let dim = centers.ncols();
if dim == 0 {
crate::bail_invalid_basis!(
"duchon_native_penalty_candidates: centers must have at least one column"
);
}
let z = kernel_transform;
let n_kernel = z.ncols();
let omega = duchon_constrained_bending_penalty(
centers,
length_scale,
power,
nullspace_order,
aniso_log_scales,
z,
)?;
let n_pre = n_kernel + poly_cols;
let mut primary_pre = Array2::<f64>::zeros((n_pre, n_pre));
primary_pre
.slice_mut(s![..n_kernel, ..n_kernel])
.assign(&omega);
let primary = symmetrize(&project_penalty_matrix(&primary_pre, outer_identifiability));
let shrink = if poly_cols > 1 {
let mut shrink_pre = Array2::<f64>::zeros((n_pre, n_pre));
for col in (n_kernel + 1)..n_pre {
shrink_pre[[col, col]] = 1.0;
}
let shrink = symmetrize(&project_penalty_matrix(&shrink_pre, outer_identifiability));
Some(shrink)
} else {
None
};
let mut out = Vec::new();
out.push(normalize_penalty_candidate(
primary,
0,
PenaltySource::Primary,
));
if let Some(shrink) = shrink {
out.push(normalize_penalty_candidate(
shrink,
0,
PenaltySource::DoublePenaltyNullspace,
));
}
Ok(out)
}
pub(crate) const DUCHON_COLLOCATION_OVERSAMPLE: usize = 3;
pub(crate) fn duchon_operator_penalty_candidates(
collocation_points: ArrayView2<'_, f64>,
centers: ArrayView2<'_, f64>,
operator_penalties: &DuchonOperatorPenaltySpec,
length_scale: Option<f64>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
per_axis_relevance: bool,
identifiability_transform: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<Vec<PenaltyCandidate>, BasisError> {
let want_mass = matches!(operator_penalties.mass, OperatorPenaltySpec::Active { .. });
let mut want_tension = matches!(
operator_penalties.tension,
OperatorPenaltySpec::Active { .. }
);
let mut want_stiffness = matches!(
operator_penalties.stiffness,
OperatorPenaltySpec::Active { .. }
);
let effective_order = duchon_effective_nullspace_order(centers, nullspace_order);
let p_order = duchon_p_from_nullspace_order(effective_order);
let dim = centers.ncols();
let two_pps = 2.0 * (p_order as f64 + power);
want_tension = want_tension && two_pps > dim as f64 + 1.0;
want_stiffness = want_stiffness && two_pps > dim as f64 + 2.0;
if !want_mass && !want_tension && !want_stiffness {
return Ok(Vec::new());
}
let mut effective_spec = operator_penalties.clone();
if !want_tension {
effective_spec.tension = OperatorPenaltySpec::Disabled;
}
if !want_stiffness {
effective_spec.stiffness = OperatorPenaltySpec::Disabled;
}
let max_op = duchon_max_active_operator_derivative_order(&effective_spec);
let ops = build_duchon_collocation_operator_matriceswithworkspace(
centers,
collocation_points,
None,
length_scale,
power,
nullspace_order,
None,
identifiability_transform.map(|t| t.view()),
max_op,
workspace,
)?;
let kernel_nullspace = ops.kernel_nullspace_transform.as_ref();
let poly_cols = ops.polynomial_block_cols;
let split_tension = per_axis_relevance && want_tension;
let factory_spec = if split_tension {
let mut spec = effective_spec.clone();
spec.tension = OperatorPenaltySpec::Disabled;
spec
} else {
effective_spec
};
let mut candidates = if let Some(length_scale) = length_scale {
operator_penalty_candidates_closed_form(
centers,
&ops.d0,
&ops.d1,
&ops.d2,
&factory_spec,
p_order,
duchon_power_to_usize(power),
length_scale,
None,
kernel_nullspace,
poly_cols,
identifiability_transform,
)
} else {
operator_penalty_candidates_closed_form_pure(
centers,
&ops.d0,
&ops.d1,
&ops.d2,
&factory_spec,
p_order,
power,
None,
kernel_nullspace,
poly_cols,
identifiability_transform,
)
};
if split_tension {
for axis in 0..dim {
let d1_axis = ops.d1.slice(s![axis..; dim, ..]).to_owned();
candidates.push(normalize_penalty_candidate(
symmetrize(&fast_ata(&d1_axis)),
0,
PenaltySource::OperatorRelevance { axis },
));
}
}
Ok(candidates)
}
#[cfg(test)]
mod mixed_periodicity_psd_tests {
use super::*;
use crate::linalg::faer_ndarray::FaerEigh;
use faer::Side;
use ndarray::{Array2, array};
fn cylinder_spec() -> DuchonBasisSpec {
DuchonBasisSpec {
center_strategy: CenterStrategy::UserProvided(Array2::<f64>::zeros((0, 0))),
periodic: None,
length_scale: None,
power: 0.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::None,
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
radial_reparam: None,
}
}
fn cylinder_primary_penalty() -> (Array2<f64>, usize) {
let two_pi = std::f64::consts::TAU;
let theta = [
0.0, 0.6, 1.2, 1.9, 2.5, 3.1, 3.8, 4.4, 5.0, 5.6, two_pi,
];
let y = [
0.5, 0.1, 0.9, 0.3, 0.7, 0.2, 0.8, 0.4, 0.6, 0.15, 0.5,
];
let mut centers = Array2::<f64>::zeros((theta.len(), 2));
for i in 0..theta.len() {
centers[[i, 0]] = theta[i];
centers[[i, 1]] = y[i];
}
let mut spec = cylinder_spec();
spec.center_strategy = CenterStrategy::UserProvided(centers.clone());
let periodic_per_axis = [true, false];
let built = build_duchon_basis_mixed_periodicity_auto(
centers.view(),
&spec,
&periodic_per_axis,
None,
)
.expect("cylinder mixed-periodicity basis must build");
let idx = built
.penaltyinfo
.iter()
.position(|info| matches!(info.source, PenaltySource::Primary))
.expect("cylinder build must emit a Primary penalty");
(built.penalties[idx].clone(), centers.nrows())
}
#[test]
fn cylinder_penalty_is_symmetric_psd_gam1422() {
let (penalty, k) = cylinder_primary_penalty();
assert_eq!(penalty.nrows(), k);
assert_eq!(penalty.ncols(), k);
for i in 0..k {
for j in 0..k {
assert!(
(penalty[[i, j]] - penalty[[j, i]]).abs() <= 1e-9,
"cylinder penalty must be symmetric at ({i}, {j})"
);
}
}
let sym = symmetrize(&penalty);
let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("eigh");
let lambda_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
assert!(
lambda_min > -1e-8,
"cylinder Duchon penalty not PSD; λ_min = {lambda_min:.3e} (gam#1422)"
);
}
#[test]
fn torus_penalty_is_psd_gam1422() {
let two_pi = std::f64::consts::TAU;
let theta = [0.0, 0.7, 1.5, 2.3, 3.0, 3.9, 4.6, 5.4, two_pi];
let phi = [0.0, 1.1, 2.0, 0.4, 3.3, 4.8, 5.9, 2.7, two_pi];
let mut centers = Array2::<f64>::zeros((theta.len(), 2));
for i in 0..theta.len() {
centers[[i, 0]] = theta[i];
centers[[i, 1]] = phi[i];
}
let mut spec = cylinder_spec();
spec.center_strategy = CenterStrategy::UserProvided(centers.clone());
let periodic_per_axis = [true, true];
let built = build_duchon_basis_mixed_periodicity_auto(
centers.view(),
&spec,
&periodic_per_axis,
None,
)
.expect("torus mixed-periodicity basis must build");
let idx = built
.penaltyinfo
.iter()
.position(|info| matches!(info.source, PenaltySource::Primary))
.expect("torus build must emit a Primary penalty");
let sym = symmetrize(&built.penalties[idx]);
let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("eigh");
let lambda_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
assert!(
lambda_min > -1e-8,
"torus Duchon penalty not PSD; λ_min = {lambda_min:.3e} (gam#1422)"
);
}
#[test]
fn cylinder_nullspace_contains_one_and_y_gam1423() {
let points = array![
[0.0_f64, 0.10],
[1.0, 0.40],
[2.0, 0.70],
[3.0, 0.95],
];
let periodic_per_axis = [true, false];
let block = mixed_periodicity_nullspace_poly_block(points.view(), 2, &periodic_per_axis);
assert_eq!(
block.ncols(),
2,
"cylinder (m=2) null space must be {{1, y}} — 2 columns (gam#1423)"
);
let n = points.nrows();
let mut has_const = false;
let mut has_y = false;
let mut depends_on_theta = false;
for col in 0..block.ncols() {
let is_const = (0..n).all(|r| (block[[r, col]] - 1.0).abs() < 1e-12);
let is_y = (0..n).all(|r| (block[[r, col]] - points[[r, 1]]).abs() < 1e-12);
let is_theta = (0..n).all(|r| (block[[r, col]] - points[[r, 0]]).abs() < 1e-12);
has_const |= is_const;
has_y |= is_y;
depends_on_theta |= is_theta && !is_const;
}
assert!(has_const, "null space must include the constant 1");
assert!(has_y, "null space must include the nonperiodic coordinate y (gam#1423)");
assert!(
!depends_on_theta,
"periodic axis θ must contribute only the constant, never a linear θ column"
);
}
#[test]
fn cylinder_linear_in_y_is_unpenalised_gam1423() {
let two_pi = std::f64::consts::TAU;
let theta = [0.0, 0.7, 1.5, 2.3, 3.0, 3.9, 4.6, 5.4, two_pi];
let y = [0.0, 0.2, 0.45, 0.6, 0.3, 0.8, 0.95, 0.1, 0.5];
let mut centers = Array2::<f64>::zeros((theta.len(), 2));
for i in 0..theta.len() {
centers[[i, 0]] = theta[i];
centers[[i, 1]] = y[i];
}
let periodic_per_axis = [true, false];
let periods = [two_pi, 1.0];
let axis_bounds = mixed_periodicity_axis_bounds(centers.view(), &periodic_per_axis);
let k = centers.nrows();
let m = 2usize;
let poly_block =
mixed_periodicity_nullspace_poly_block(centers.view(), m, &periodic_per_axis);
let z = kernel_constraint_nullspace_from_matrix(poly_block.view())
.expect("null-space basis must build");
let mut k_cc = Array2::<f64>::zeros((k, k));
fill_symmetric_from_row_kernel(&mut k_cc, |i, j| {
mixed_periodicity_additive_kernel(
centers.row(i),
centers.row(j),
m,
&periodic_per_axis,
&periods,
&axis_bounds,
)
})
.expect("center kernel must build");
let omega = fast_ab(&fast_atb(&z, &k_cc), &z);
let sym = symmetrize(&omega);
let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("eigh");
let lambda_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
assert!(
lambda_min > -1e-8,
"Ω = ZᵀK_CC Z must be PSD; λ_min = {lambda_min:.3e}"
);
let ones: Vec<f64> = vec![1.0; k];
let yvec: Vec<f64> = (0..k).map(|i| centers[[i, 1]]).collect();
for (label, v) in [("constant", &ones), ("linear-in-y", &yvec)] {
let mut proj = vec![0.0f64; z.ncols()];
for c in 0..z.ncols() {
let mut acc = 0.0;
for r in 0..k {
acc += z[[r, c]] * v[r];
}
proj[c] = acc;
}
let norm: f64 = proj.iter().map(|p| p * p).sum::<f64>().sqrt();
assert!(
norm < 1e-9,
"{label} direction must lie in the unpenalised null space \
(Zᵀv = 0); got ‖Zᵀv‖ = {norm:.3e} (gam#1423)"
);
}
}
}
#[cfg(test)]
mod hybrid_high_dim_psd_tests {
use super::*;
use crate::linalg::faer_ndarray::FaerEigh;
use faer::Side;
use ndarray::Array2;
fn deterministic_centers(d: usize, k: usize) -> Array2<f64> {
let mut state: u64 = 0x9E37_79B9_7F4A_7C15u64
.wrapping_mul(d as u64 + 1)
.wrapping_add(k as u64);
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z as f64 / u64::MAX as f64) * 2.0 - 1.0
};
let mut c = Array2::<f64>::zeros((k, d));
for i in 0..k {
for j in 0..d {
c[[i, j]] = next();
}
}
c
}
fn hybrid_constrained_lambda_min(d: usize, s_order: usize) -> f64 {
let centers = deterministic_centers(d, 4 * d);
let nullspace = DuchonNullspaceOrder::Linear; let effective = duchon_effective_nullspace_order(centers.view(), nullspace);
let poly_block = polynomial_block_from_order(centers.view(), effective);
let z = kernel_constraint_nullspace_from_matrix(poly_block.view())
.expect("kernel null-space basis must build");
let omega = duchon_constrained_bending_penalty(
centers.view(),
Some(1.0),
s_order as f64,
effective,
None,
&z,
)
.expect("hybrid constrained bending penalty must build and pass the PSD check");
let sym = symmetrize(&omega);
let (evals, _) = FaerEigh::eigh(&sym, Side::Lower).expect("eigh");
let max_abs = evals.iter().copied().fold(0.0_f64, |a, v| a.max(v.abs()));
let lambda_min = evals.iter().copied().fold(f64::INFINITY, f64::min);
lambda_min / max_abs.max(f64::MIN_POSITIVE)
}
#[test]
fn hybrid_d16_m2_s7_constrained_spectrum_is_psd_gam1424() {
let d = 16;
let n = 4 * d; let tol = (n as f64) * 1e-10;
let lambda_min_rel = hybrid_constrained_lambda_min(d, 7);
assert!(
lambda_min_rel >= -tol,
"d=16, m=2, s=7 hybrid constrained spectrum not PSD: \
λ_min/λ_max = {lambda_min_rel:.6e} (tol = −{tol:.6e}) (gam#1424)"
);
}
#[test]
fn hybrid_other_high_dims_constrained_spectrum_is_psd_gam1424() {
for (d, s) in [(8usize, 3usize), (10, 4), (12, 5)] {
let n = 4 * d;
let tol = (n as f64) * 1e-10;
let lambda_min_rel = hybrid_constrained_lambda_min(d, s);
assert!(
lambda_min_rel >= -tol,
"d={d}, m=2, s={s} hybrid constrained spectrum not PSD: \
λ_min/λ_max = {lambda_min_rel:.6e} (tol = −{tol:.6e}) (gam#1424)"
);
}
}
}