use super::*;
use super::invariant_tie_break::resolve_sorted_profile_tie;
use gam_linalg::lanczos::{SymmetricExtremeLanczosOptions, symmetric_extreme_lanczos_eigenpairs};
type DuchonBasisCacheKey = (u64, u64);
#[derive(Clone)]
struct CachedDuchonBasis {
result: BasisBuildResult,
route_lazy: bool,
}
impl gam_runtime::resource::ResidentBytes for CachedDuchonBasis {
fn resident_bytes(&self) -> usize {
let design_bytes = self
.result
.design
.nrows()
.saturating_mul(self.result.design.ncols())
.saturating_mul(std::mem::size_of::<f64>());
let penalty_bytes: usize = self
.result
.active_penalties
.iter()
.map(|penalty| {
penalty
.matrix
.len()
.saturating_mul(std::mem::size_of::<f64>())
})
.sum();
design_bytes
.saturating_add(penalty_bytes)
.saturating_add(4096)
}
}
fn duchon_basis_cache()
-> &'static gam_runtime::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis> {
static CACHE: std::sync::OnceLock<
gam_runtime::resource::ByteLruCache<DuchonBasisCacheKey, CachedDuchonBasis>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| gam_runtime::resource::ByteLruCache::new(1 << 30))
}
fn duchon_basis_fingerprint(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
policy: &gam_runtime::resource::ResourcePolicy,
) -> Option<DuchonBasisCacheKey> {
let spec_bytes = serde_json::to_vec(spec).ok()?;
let mut lo = DefaultHasher::new();
let mut hi = DefaultHasher::new();
0x9E37_79B9_7F4A_7C15u64.hash(&mut hi);
let (nrows, ncols) = data.dim();
for h in [&mut lo, &mut hi] {
nrows.hash(h);
ncols.hash(h);
}
for row in data.rows() {
for &v in row {
let bits = v.to_bits();
bits.hash(&mut lo);
bits.hash(&mut hi);
}
}
for h in [&mut lo, &mut hi] {
spec_bytes.len().hash(h);
spec_bytes.hash(h);
let storage_mode = match policy.derivative_storage_mode {
gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => 0_u8,
gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall => 1_u8,
gam_runtime::resource::DerivativeStorageMode::DiagnosticsOnly => 2_u8,
};
storage_mode.hash(h);
}
Some((lo.finish(), hi.finish()))
}
fn realized_route_is_lazy(
result: &BasisBuildResult,
policy: &gam_runtime::resource::ResourcePolicy,
) -> bool {
should_use_lazy_spatial_design(result.design.nrows(), result.design.ncols(), policy)
}
fn route_matches_policy(
cached: &CachedDuchonBasis,
policy: &gam_runtime::resource::ResourcePolicy,
) -> bool {
realized_route_is_lazy(&cached.result, policy) == cached.route_lazy
}
pub fn build_duchon_basiswithworkspace(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
if let Some(key) = duchon_basis_fingerprint(data, spec, workspace.policy()) {
if let Some(hit) = duchon_basis_cache().get(&key) {
if route_matches_policy(&hit, workspace.policy()) {
return Ok(hit.result);
}
}
let result = build_duchon_basis_uncached(data, spec, workspace)?;
let route_lazy = realized_route_is_lazy(&result, workspace.policy());
duchon_basis_cache().insert(
key,
CachedDuchonBasis {
result: result.clone(),
route_lazy,
},
);
return Ok(result);
}
build_duchon_basis_uncached(data, spec, workspace)
}
pub fn build_duchon_basis_spec_chart(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
) -> Result<BasisBuildResult, BasisError> {
if center_strategy_spectral_basis(&spec.center_strategy).is_some()
|| spec.radial_reparam.is_some()
|| spec.periodic.is_some()
|| spec.boundary.period().is_some()
{
return build_duchon_basis(data, spec);
}
let mut workspace = BasisWorkspace::default();
let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
let effective_nullspace_order =
duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
let kernel_transform = kernel_constraint_nullspace(
centers.view(),
effective_nullspace_order,
&mut workspace.cache,
)?;
if kernel_transform.ncols() == 0 {
return build_duchon_basis(data, spec);
}
let omega_constrained = duchon_constrained_bending_penalty(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
)?;
let (v, _mu) = thin_plate_radial_reparam_from_constrained_penalty(&omega_constrained)?;
if v.ncols() == 0 {
return build_duchon_basis(data, spec);
}
let mut spec_chart = spec.clone();
spec_chart.radial_reparam = Some(v);
build_duchon_basis(data, &spec_chart)
}
struct DuchonSpectralKernelChart {
kernel_transform: Array2<f64>,
bending_penalty: Array2<f64>,
}
fn duchon_spectral_kernel_chart(
centers: ArrayView2<'_, f64>,
length_scale: Option<f64>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
aniso_log_scales: Option<&[f64]>,
rank: usize,
) -> Result<DuchonSpectralKernelChart, BasisError> {
let (center_kernel, kernel_amp) = duchon_center_kernel_value_matrix(
centers,
length_scale,
power,
nullspace_order,
aniso_log_scales,
)?;
let dim = center_kernel.nrows();
let mut state = 1_u64;
let mut start = vec![0.0_f64; dim];
for value in &mut start {
state = (state * 106 + 1283) % 6075;
*value = state as f64 / 6075.0 - 0.5;
}
let check_every = (rank / 2).max(10).min((dim / 10).max(1));
let pairs = symmetric_extreme_lanczos_eigenpairs(
dim,
&start,
SymmetricExtremeLanczosOptions {
target_rank: rank,
max_steps: 128,
check_every,
relative_residual_tol: f64::EPSILON.sqrt(),
breakdown_tol: 1e-14,
},
|q, image| gam_linalg::faer_ndarray::symmetric_matvec_into(¢er_kernel, q, image),
)
.map_err(BasisError::InvalidInput)?;
let selected = pairs.eigenvectors;
let mut centers_centered = centers.to_owned();
for axis in 0..centers.ncols() {
let mean = centers.column(axis).sum() / centers.nrows() as f64;
centers_centered
.column_mut(axis)
.mapv_inplace(|value| value - mean);
}
let polynomial = polynomial_block_from_order(centers_centered.view(), nullspace_order);
let polynomial_in_eigenspace = fast_atb(&selected, &polynomial);
let spectral_constraint =
kernel_constraint_nullspace_from_matrix(polynomial_in_eigenspace.view())?;
let kernel_transform = fast_ab(&selected, &spectral_constraint);
let mut diagonal = Array2::<f64>::zeros((rank, rank));
for (index, &eigenvalue) in pairs.eigenvalues.iter().enumerate() {
diagonal[[index, index]] = eigenvalue;
}
let reduced = fast_ab(
&fast_atb(&spectral_constraint, &diagonal),
&spectral_constraint,
)
.mapv(|value| value * kernel_amp * kernel_amp);
Ok(DuchonSpectralKernelChart {
kernel_transform,
bending_penalty: symmetrize_penalty(&reduced),
})
}
#[cfg(test)]
mod duchon_spectral_basis_tests {
use super::*;
fn asymmetric_centers() -> Array2<f64> {
Array2::from_shape_fn((24, 4), |(row, axis)| {
let x = (row + 1) as f64;
let a = (axis + 2) as f64;
(x * a.sqrt()).sin() + (x / (a + 0.5)).cos() + 0.01 * x * a
})
}
#[test]
fn spectral_transform_has_requested_rank_is_deterministic_and_obeys_side_condition() {
let centers = asymmetric_centers();
let rank = 6;
let chart = duchon_spectral_kernel_chart(
centers.view(),
None,
1.5,
DuchonNullspaceOrder::Zero,
None,
rank,
)
.expect("small asymmetric cloud has a certifiable spectral basis");
let replay = duchon_spectral_kernel_chart(
centers.view(),
None,
1.5,
DuchonNullspaceOrder::Zero,
None,
rank,
)
.expect("deterministic replay");
assert_eq!(chart.kernel_transform.dim(), (centers.nrows(), rank - 1));
assert_eq!(chart.kernel_transform, replay.kernel_transform);
assert_eq!(chart.bending_penalty, replay.bending_penalty);
let gram = fast_atb(&chart.kernel_transform, &chart.kernel_transform);
for i in 0..gram.nrows() {
for j in 0..gram.ncols() {
let target = if i == j { 1.0 } else { 0.0 };
assert!(
(gram[[i, j]] - target).abs() <= 2e-10,
"spectral transform is not orthonormal at ({i}, {j}): {}",
gram[[i, j]]
);
}
}
for column in chart.kernel_transform.columns() {
assert!(
column.sum().abs() <= 2e-10,
"constant polynomial side condition was not removed: sum={}",
column.sum()
);
}
}
}
fn build_duchon_basis_uncached(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisBuildResult, BasisError> {
if let Some((_start, _end, period)) = spec.boundary.period() {
if data.ncols() != 1 {
crate::bail_invalid_basis!(
"cyclic-boundary Duchon smooths require exactly one covariate"
);
}
let mut spec_periodic = spec.clone();
spec_periodic.boundary = crate::basis::OneDimensionalBoundary::Open;
spec_periodic.periodic = Some(vec![Some(period)]);
let centers = select_centers_by_strategy(data, &spec_periodic.center_strategy)?;
assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
return build_periodic_duchon_basis_1d(data, &spec_periodic, centers, workspace);
}
let centers = select_centers_by_strategy(data, &spec.center_strategy)?;
assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
if let Some(periodic) = spec.periodic.as_ref() {
if periodic.len() != data.ncols() {
crate::bail_invalid_basis!(
"periodic must have length d={}, got {}",
data.ncols(),
periodic.len()
);
}
if data.ncols() > 1 && periodic.iter().any(Option::is_some) {
let flags = periodic.iter().map(Option::is_some).collect::<Vec<_>>();
let periods = periodic
.iter()
.map(|axis| axis.unwrap_or(1.0))
.collect::<Vec<_>>();
return build_duchon_basis_mixed_periodicity_auto(data, spec, &flags, Some(&periods));
}
return build_periodic_duchon_basis_1d(data, spec, centers, workspace);
}
let effective_nullspace_order =
duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
let validation_power = if spec.length_scale.is_some() {
spec.power_as_usize() as f64
} else {
spec.power
};
validate_duchon_kernel_orders(spec.length_scale, p_order, validation_power, data.ncols())?;
let poly_cols = polynomial_block_from_order(data, effective_nullspace_order).ncols();
let spectral_basis = center_strategy_spectral_basis(&spec.center_strategy);
if let Some(spectral) = spectral_basis {
let rank = spectral.rank();
if rank <= poly_cols || rank > centers.nrows() {
crate::bail_invalid_basis!(
"Duchon spectral rank must satisfy polynomial_columns < rank <= centers: \
polynomial_columns={poly_cols}, rank={rank}, centers={}",
centers.nrows()
);
}
if spec.radial_reparam.is_some() {
crate::bail_invalid_basis!(
"Duchon spectral basis and landmark data-metric radial reparameterization \
are mutually exclusive"
);
}
if spec.length_scale.is_some() {
crate::bail_invalid_basis!(
"Duchon spectral reduction currently requires the scale-free kernel; \
a moving hybrid range would change the retained eigenspace"
);
}
}
let mut realized_spectral_basis = None;
let mut spectral_bending_penalty = None;
let mut kernel_transform = if let Some(spectral) = spectral_basis {
let rank = spectral.rank();
let chart = match (spectral.kernel_transform(), spectral.bending_penalty()) {
(Some(frozen), Some(frozen_penalty)) => {
if frozen.nrows() != centers.nrows()
|| frozen.ncols() != rank.saturating_sub(poly_cols)
{
crate::bail_dim_basis!(
"Duchon frozen spectral transform has shape {:?}; expected ({}, {})",
frozen.dim(),
centers.nrows(),
rank.saturating_sub(poly_cols)
);
}
if frozen_penalty.dim() != (frozen.ncols(), frozen.ncols()) {
crate::bail_dim_basis!(
"Duchon frozen spectral bending penalty has shape {:?}; expected ({}, {})",
frozen_penalty.dim(),
frozen.ncols(),
frozen.ncols()
);
}
DuchonSpectralKernelChart {
kernel_transform: frozen.clone(),
bending_penalty: frozen_penalty.clone(),
}
}
(None, None) => duchon_spectral_kernel_chart(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
rank,
)?,
_ => crate::bail_invalid_basis!(
"Duchon frozen spectral state must contain both transform and bending penalty"
),
};
spectral_bending_penalty = Some(chart.bending_penalty.clone());
realized_spectral_basis = Some(DuchonSpectralBasis::Frozen {
rank,
kernel_transform: chart.kernel_transform.clone(),
bending_penalty: chart.bending_penalty,
});
chart.kernel_transform
} else {
kernel_constraint_nullspace(
centers.view(),
effective_nullspace_order,
&mut workspace.cache,
)?
};
let base_cols = kernel_transform.ncols() + poly_cols;
let dense_bytes = dense_design_bytes(data.nrows(), base_cols);
let use_lazy = should_use_lazy_spatial_design(data.nrows(), base_cols, workspace.policy());
let mut frozen_radial_reparam: Option<Array2<f64>> = None;
if let Some(v) = spec.radial_reparam.as_ref() {
if v.nrows() != kernel_transform.ncols() {
crate::bail_dim_basis!(
"Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
v.dim(),
kernel_transform.ncols()
);
}
kernel_transform = fast_ab(&kernel_transform, v);
frozen_radial_reparam = Some(v.clone());
}
let (design, identifiability_transform) = if use_lazy {
log::info!(
"Duchon basis switching to lazy chunked design: n={} p={} ({:.1} MiB dense)",
data.nrows(),
base_cols,
dense_bytes as f64 / (1024.0 * 1024.0),
);
let d = data.ncols();
let shared_data = shared_owned_data_matrix(data, &workspace.cache);
let p_order = duchon_p_from_nullspace_order(effective_nullspace_order);
let s_order: f64 = spec.power;
let length_scale = spec.length_scale;
let s_order_int = length_scale.map(|_| duchon_power_to_usize(s_order));
let coeffs = length_scale.map(|ls| {
duchon_partial_fraction_coeffs(
p_order,
s_order_int.expect("hybrid Duchon requires integer power"),
1.0 / ls.max(1e-300),
)
});
let pure_poly_coeff = if length_scale.is_none() {
Some(PolyharmonicBlockCoeff::new(
pure_duchon_block_order(p_order, s_order),
d,
))
} else {
None
};
let center_mean: Vec<f64> = (0..d)
.map(|c| centers.column(c).sum() / (centers.nrows().max(1) as f64))
.collect();
let mut data_centered = data.to_owned();
for c in 0..d {
let mu = center_mean[c];
data_centered.column_mut(c).mapv_inplace(|v| v - mu);
}
let poly_block =
polynomial_block_from_order(data_centered.view(), effective_nullspace_order);
let kernel_amp = duchon_kernel_amplification(
centers.view(),
length_scale,
p_order,
duchon_power_to_usize(s_order),
d,
aniso.as_deref(),
coeffs.as_ref(),
pure_poly_coeff.as_ref(),
);
let make_kernel = || {
let coeffs = coeffs.clone();
let pure_poly_coeff = pure_poly_coeff;
let metric_weights = aniso.as_ref().map(|eta| {
eta.iter()
.map(|&value| (2.0 * value).exp())
.collect::<Vec<_>>()
});
Arc::new(move |data_row: &[f64], center_row: &[f64]| -> f64 {
let r = if let Some(weights) = metric_weights.as_ref() {
let mut squared_radius = 0.0_f64;
for axis in 0..data_row.len() {
let delta = data_row[axis] - center_row[axis];
squared_radius += weights[axis] * delta * delta;
}
squared_radius.sqrt()
} else {
stable_euclidean_norm((0..d).map(|axis| data_row[axis] - center_row[axis]))
};
let raw = if let Some(ppc) = pure_poly_coeff {
ppc.eval(r)
} else {
duchon_matern_kernel_general_from_distance(
r,
length_scale,
p_order,
s_order_int.expect("hybrid Duchon requires integer power"),
d,
coeffs.as_ref(),
)
.expect("validated Duchon inputs should not fail")
};
raw * kernel_amp
}) as Arc<dyn crate::chunked_kernel_design::SpatialKernelEvaluator>
};
if spectral_basis.is_none() && frozen_radial_reparam.is_none() {
let raw_gauge = Arc::new(gam_problem::Gauge::from_block_transforms(&[
kernel_transform.clone(),
]));
let raw_op = ChunkedKernelDesignOperator::new(
shared_data.clone(),
Arc::new(centers.clone()),
make_kernel(),
Some(raw_gauge),
Some(Arc::new(poly_block.clone())),
workspace.policy().material_policy(),
)
.map_err(BasisError::InvalidInput)?;
let ones = Array1::<f64>::ones(raw_op.nrows());
let raw_gram = raw_op.diag_xtw_x(&ones).map_err(BasisError::InvalidInput)?;
let kernel_cols = kernel_transform.ncols();
let design_gram =
symmetrize_penalty(&raw_gram.slice(s![..kernel_cols, ..kernel_cols]).to_owned());
let omega_constrained = duchon_constrained_bending_penalty(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
)?;
let (v, _mu) = thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?;
if v.ncols() > 0 {
kernel_transform = fast_ab(&kernel_transform, &v);
frozen_radial_reparam = Some(v);
}
}
let kernel_gauge = Arc::new(gam_problem::Gauge::from_block_transforms(&[
kernel_transform.clone(),
]));
let base_op = ChunkedKernelDesignOperator::new(
shared_data,
Arc::new(centers.clone()),
make_kernel(),
Some(kernel_gauge),
Some(Arc::new(poly_block)),
workspace.policy().material_policy(),
)
.map_err(BasisError::InvalidInput)?;
let base_design = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
Arc::new(base_op),
));
let identifiability_transform = spatial_identifiability_transform_from_design_matrix(
data,
&base_design,
&spec.identifiability,
"Duchon",
)?;
let design = if let Some(transform) = identifiability_transform.as_ref() {
wrap_dense_design_with_transform(base_design, transform, "Duchon")?
} else {
base_design
};
(design, identifiability_transform)
} else {
let mut prebuilt_raw_basis: Option<Array2<f64>> = None;
if spectral_basis.is_none()
&& frozen_radial_reparam.is_none()
&& kernel_transform.ncols() > 0
{
let resolved = duchon_resolve_radial_chart(
data,
centers.view(),
spec,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
workspace,
)?;
if let Some(v) = resolved.reparam {
kernel_transform = fast_ab(&kernel_transform, &v);
frozen_radial_reparam = Some(v);
}
prebuilt_raw_basis = Some(resolved.basis);
}
let basis = if let Some(basis) = prebuilt_raw_basis {
basis
} else {
build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
frozen_radial_reparam.as_ref(),
realized_spectral_basis
.as_ref()
.and_then(DuchonSpectralBasis::kernel_transform),
workspace,
)?
.basis
};
let identifiability_transform = spatial_identifiability_transform_from_design(
data,
basis.view(),
&spec.identifiability,
"Duchon",
)?;
let design = if let Some(z) = identifiability_transform.as_ref() {
DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(fast_ab(
&basis, z,
)))
} else {
DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(basis))
};
(design, identifiability_transform)
};
let operator_collocation_points = {
let any_operator = matches!(
spec.operator_penalties.mass,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.tension,
OperatorPenaltySpec::Active { .. }
) || matches!(
spec.operator_penalties.stiffness,
OperatorPenaltySpec::Active { .. }
);
if any_operator {
let m = (DUCHON_COLLOCATION_OVERSAMPLE * centers.nrows()).min(data.nrows());
Some(select_thin_plate_knots(data, m)?)
} else {
None
}
};
let mut candidates = duchon_native_penalty_candidates_with_curvature(
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
identifiability_transform.as_ref(),
spectral_bending_penalty.as_ref(),
)?;
if let Some(points) = operator_collocation_points.as_ref() {
candidates.extend(duchon_operator_penalty_candidates(
points.view(),
centers.view(),
&spec.operator_penalties,
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.is_some(),
identifiability_transform.as_ref(),
frozen_radial_reparam.as_ref(),
workspace,
)?);
}
let filtered = filter_penalty_candidates(candidates)?;
Ok(BasisBuildResult {
design,
affine_offset: None,
active_penalties: filtered.active,
dropped_penalties: filtered.dropped,
joint_null_rotation: None,
metadata: BasisMetadata::Duchon {
centers,
length_scale: spec.length_scale.map(crate::OriginalUnits::new),
periodic: spec.periodic.clone(),
power: spec.power,
nullspace_order: effective_nullspace_order,
identifiability_transform,
input_scale: crate::IsotropicScale::ONE,
aniso_log_scales: aniso,
operator_collocation_points,
radial_reparam: frozen_radial_reparam,
spectral_basis: realized_spectral_basis,
},
kronecker_factored: None,
})
}
pub fn duchon_penalties_at_length_scale(
centers: ArrayView2<'_, f64>,
identifiability_transform: Option<&Array2<f64>>,
operator_collocation_points: Option<ArrayView2<'_, f64>>,
operator_penalties: &DuchonOperatorPenaltySpec,
power: f64,
nullspace_order: DuchonNullspaceOrder,
aniso_log_scales: Option<&[f64]>,
radial_reparam: Option<&Array2<f64>>,
length_scale: Option<f64>,
workspace: &mut BasisWorkspace,
) -> Result<(Vec<Array2<f64>>, Vec<usize>), BasisError> {
let effective_nullspace_order = duchon_effective_nullspace_order(centers, nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers, aniso_log_scales);
let mut kernel_transform =
kernel_constraint_nullspace(centers, effective_nullspace_order, &mut workspace.cache)?;
if let Some(v) = radial_reparam {
if v.nrows() != kernel_transform.ncols() {
crate::bail_dim_basis!(
"Duchon frozen radial reparam shape {:?} does not match constrained kernel dimension {}",
v.dim(),
kernel_transform.ncols()
);
}
kernel_transform = fast_ab(&kernel_transform, v);
}
let mut candidates = duchon_native_penalty_candidates(
centers,
length_scale,
power,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
identifiability_transform,
)?;
if let Some(points) = operator_collocation_points {
candidates.extend(duchon_operator_penalty_candidates(
points,
centers,
operator_penalties,
length_scale,
power,
effective_nullspace_order,
aniso.is_some(),
identifiability_transform,
radial_reparam,
workspace,
)?);
}
let filtered = filter_penalty_candidates(candidates)?;
Ok((
filtered
.active
.iter()
.map(|penalty| penalty.matrix.clone())
.collect(),
filtered
.active
.iter()
.map(|penalty| penalty.nullity)
.collect(),
))
}
pub(crate) fn polynomial_block_from_order(
points: ArrayView2<'_, f64>,
order: DuchonNullspaceOrder,
) -> Array2<f64> {
let n = points.nrows();
let d = points.ncols();
match order {
DuchonNullspaceOrder::Zero => Array2::<f64>::ones((n, 1)),
DuchonNullspaceOrder::Linear => {
let mut poly = Array2::<f64>::zeros((n, d + 1));
poly.column_mut(0).fill(1.0);
for c in 0..d {
poly.column_mut(c + 1).assign(&points.column(c));
}
poly
}
DuchonNullspaceOrder::Degree(degree) => monomial_basis_block(points, degree),
}
}
const RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF: f64 = 100.0;
pub(crate) fn duchon_range_floor_curvature(
omega: &Array2<f64>,
embedded_penalty_dim: usize,
) -> Result<Array2<f64>, BasisError> {
let n = omega.nrows();
if n == 0 {
return Ok(omega.clone());
}
let sym = symmetrize_penalty(omega);
let (mut evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
let lam_max = evals.iter().copied().fold(0.0_f64, |a, v| a.max(v.abs()));
if !lam_max.is_finite() || lam_max <= 0.0 {
return Ok(sym);
}
let floor = RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF
* spectral_tolerance_for_dim(embedded_penalty_dim.max(n), &evals);
let mut floored = false;
for v in evals.iter_mut() {
if v.is_finite() && *v < floor {
*v = floor;
floored = true;
}
}
if !floored {
return Ok(sym);
}
let mut out = Array2::<f64>::zeros((n, n));
for j in 0..n {
let lam = evals[j];
for a in 0..n {
let ua = evecs[[a, j]];
if ua == 0.0 {
continue;
}
for b in 0..n {
out[[a, b]] += ua * lam * evecs[[b, j]];
}
}
}
Ok(symmetrize_penalty(&out))
}
pub(crate) struct RangeFloorPsiJet {
pub value: Array2<f64>,
pub first: Array2<f64>,
pub second: Array2<f64>,
}
pub(crate) fn duchon_range_floor_curvature_psi_jet(
omega: &Array2<f64>,
omega_psi: &Array2<f64>,
omega_psi_psi: &Array2<f64>,
embedded_penalty_dim: usize,
) -> Result<RangeFloorPsiJet, BasisError> {
let n = omega.nrows();
let sym = symmetrize_penalty(omega);
let sym_psi = symmetrize_penalty(omega_psi);
let sym_psi_psi = symmetrize_penalty(omega_psi_psi);
let passthrough = || RangeFloorPsiJet {
value: sym.clone(),
first: sym_psi.clone(),
second: sym_psi_psi.clone(),
};
if n == 0 {
return Ok(passthrough());
}
let (evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
let mut imax = 0usize;
let mut lam_max = 0.0_f64;
for (i, &v) in evals.iter().enumerate() {
if v.abs() > lam_max {
lam_max = v.abs();
imax = i;
}
}
if !lam_max.is_finite() || lam_max <= 0.0 {
return Ok(passthrough());
}
let c = (embedded_penalty_dim.max(n) as f64) * 1e-8;
let floor = c * lam_max;
if !evals.iter().any(|&v| v.is_finite() && v < floor) {
return Ok(passthrough());
}
let u = &evecs;
let a = u.t().dot(&sym_psi).dot(u);
let a2 = u.t().dot(&sym_psi_psi).dot(u);
let sign = if evals[imax] >= 0.0 { 1.0 } else { -1.0 };
let tol = 1e-9 * lam_max;
let lam_max_prime = sign * a[[imax, imax]];
let mut lam_max_pp = a2[[imax, imax]];
for k in 0..n {
if k == imax {
continue;
}
let denom = evals[imax] - evals[k];
if denom.abs() > tol {
lam_max_pp += 2.0 * a[[imax, k]] * a[[imax, k]] / denom;
}
}
let lam_max_pp = sign * lam_max_pp;
let floor_prime = c * lam_max_prime;
let floor_pp = c * lam_max_pp;
let g = |lam: f64| lam.max(floor);
let gprime = |lam: f64| if lam > floor { 1.0 } else { 0.0 };
let clamped = |lam: f64| if lam <= floor { 1.0 } else { 0.0 };
let fdd = |la: f64, lb: f64| -> f64 {
if (la - lb).abs() > tol {
(g(la) - g(lb)) / (la - lb)
} else {
gprime(0.5 * (la + lb))
}
};
let fdd_clamp = |la: f64, lb: f64| -> f64 {
if (la - lb).abs() > tol {
(clamped(la) - clamped(lb)) / (la - lb)
} else {
0.0
}
};
let sdd = |la: f64, lb: f64, lc: f64| -> f64 {
if (la - lc).abs() > tol {
(fdd(la, lb) - fdd(lb, lc)) / (la - lc)
} else if (la - lb).abs() > tol {
(gprime(la) * (la - lb) - (g(la) - g(lb))) / ((la - lb) * (la - lb))
} else {
0.0
}
};
let mut gam1 = Array2::<f64>::zeros((n, n)); let mut gam_clamp = Array2::<f64>::zeros((n, n)); for i in 0..n {
for j in 0..n {
gam1[[i, j]] = fdd(evals[i], evals[j]);
gam_clamp[[i, j]] = fdd_clamp(evals[i], evals[j]);
}
}
let clamp_diag: Vec<f64> = evals.iter().map(|&l| clamped(l)).collect();
let mut b1 = &gam1 * &a;
for i in 0..n {
b1[[i, i]] += floor_prime * clamp_diag[i];
}
let mut b2 = &gam1 * &a2;
for i in 0..n {
for j in 0..n {
let mut acc = 0.0;
for k in 0..n {
acc += sdd(evals[i], evals[k], evals[j]) * a[[i, k]] * a[[k, j]];
}
b2[[i, j]] += 2.0 * acc;
}
}
let cross = (&gam_clamp * &a).mapv(|v| 2.0 * floor_prime * v);
b2 = b2 + cross;
for i in 0..n {
b2[[i, i]] += floor_pp * clamp_diag[i];
}
let mut value_eig = Array2::<f64>::zeros((n, n));
for i in 0..n {
value_eig[[i, i]] = g(evals[i]);
}
let rotate = |m: &Array2<f64>| symmetrize_penalty(&u.dot(m).dot(&u.t()));
Ok(RangeFloorPsiJet {
value: rotate(&value_eig),
first: rotate(&b1),
second: rotate(&b2),
})
}
pub fn monomial_exponents(dimension: usize, max_total_degree: usize) -> Vec<Vec<usize>> {
fn recurse(
axis: usize,
remaining_degree: usize,
current: &mut [usize],
out: &mut Vec<Vec<usize>>,
) {
if axis + 1 == current.len() {
current[axis] = remaining_degree;
out.push(current.to_vec());
return;
}
for exponent in (0..=remaining_degree).rev() {
current[axis] = exponent;
recurse(axis + 1, remaining_degree - exponent, current, out);
}
}
if dimension == 0 {
return vec![Vec::new()];
}
let mut out = Vec::new();
let mut current = vec![0usize; dimension];
for total_degree in 0..=max_total_degree {
recurse(0, total_degree, &mut current, &mut out);
}
out
}
pub fn duchon_nullspace_dimension(dimension: usize, max_total_degree: usize) -> usize {
monomial_exponents(dimension, max_total_degree).len()
}
pub(crate) fn monomial_basis_block(
points: ArrayView2<'_, f64>,
max_total_degree: usize,
) -> Array2<f64> {
let n = points.nrows();
let exponents = monomial_exponents(points.ncols(), max_total_degree);
let mut block = Array2::<f64>::zeros((n, exponents.len()));
for (col, exponents) in exponents.iter().enumerate() {
for row in 0..n {
let mut value = 1.0;
for axis in 0..points.ncols() {
let exponent = exponents[axis];
if exponent != 0 {
value *= points[[row, axis]].powi(exponent as i32);
}
}
block[[row, col]] = value;
}
}
block
}
#[inline(always)]
pub(crate) fn thin_plate_polynomial_degree(dimension: usize) -> usize {
thin_plate_penalty_order(dimension).saturating_sub(1)
}
pub(crate) fn thin_plate_polynomial_block(points: ArrayView2<'_, f64>) -> Array2<f64> {
monomial_basis_block(points, thin_plate_polynomial_degree(points.ncols()))
}
pub fn thin_plate_polynomial_basis_dimension(dimension: usize) -> usize {
monomial_exponents(dimension, thin_plate_polynomial_degree(dimension)).len()
}
fn data_metric_design_gram(kernel_block: ArrayView2<'_, f64>) -> Array2<f64> {
let n = kernel_block.nrows();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
for c in 0..kernel_block.ncols() {
match kernel_block[[a, c]].total_cmp(&kernel_block[[b, c]]) {
std::cmp::Ordering::Equal => {}
ord => return ord,
}
}
std::cmp::Ordering::Equal
});
let sorted = kernel_block.select(Axis(0), &order);
symmetrize_penalty(&fast_atb(&sorted, &sorted))
}
fn thin_plate_retained_radial_indices(evals: &Array1<f64>) -> Vec<usize> {
let k = evals.len();
if k == 0 {
return Vec::new();
}
let max_eval = evals
.iter()
.copied()
.fold(0.0_f64, |acc, value| acc.max(value.abs()));
if !max_eval.is_finite() || max_eval <= 0.0 {
return Vec::new();
}
let num_floor = (k as f64) * f64::EPSILON * max_eval;
evals
.iter()
.enumerate()
.filter_map(|(idx, &value)| (value.abs() > num_floor).then_some(idx))
.collect()
}
pub(crate) fn thin_plate_radial_reparam_from_constrained_penalty(
omega_constrained: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let kernel_cols = omega_constrained.nrows();
if kernel_cols != omega_constrained.ncols() {
crate::bail_dim_basis!(
"thin-plate constrained radial penalty must be square: got {:?}",
omega_constrained.dim()
);
}
if kernel_cols == 0 {
return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
}
let sym = symmetrize_penalty(omega_constrained);
let (mut evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
for value in evals.iter_mut() {
if *value < 0.0 {
*value = 0.0;
}
}
let keep = thin_plate_retained_radial_indices(&evals);
Ok((evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep)))
}
pub(crate) fn thin_plate_radial_reparam_data_metric(
omega_constrained: &Array2<f64>,
design_gram: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let k = omega_constrained.nrows();
if k != omega_constrained.ncols() || design_gram.nrows() != k || design_gram.ncols() != k {
crate::bail_dim_basis!(
"thin-plate data-metric reparam requires square k×k Ω_c and G_c: Ω_c={:?}, G_c={:?}",
omega_constrained.dim(),
design_gram.dim()
);
}
if k == 0 {
return Ok((Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0)));
}
let g_sym = symmetrize_penalty(design_gram);
let (g_evals, g_evecs) =
FaerEigh::eigh(&g_sym, Side::Lower).map_err(BasisError::LinalgError)?;
let gmax = g_evals.iter().copied().fold(0.0_f64, |a, b| a.max(b.abs()));
if !gmax.is_finite() || gmax <= 0.0 {
return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
}
let g_floor = (k as f64) * f64::EPSILON * gmax;
let mut cols: Vec<usize> = Vec::with_capacity(k);
for j in 0..k {
if g_evals[j] > g_floor {
cols.push(j);
}
}
let m = cols.len();
if m == 0 {
return thin_plate_radial_reparam_from_constrained_penalty(omega_constrained);
}
let mut w = Array2::<f64>::zeros((k, m));
for (c, &j) in cols.iter().enumerate() {
let inv_sqrt = 1.0 / g_evals[j].sqrt();
for i in 0..k {
w[[i, c]] = g_evecs[[i, j]] * inv_sqrt;
}
}
let omega_sym = symmetrize_penalty(omega_constrained);
let wt_omega = fast_atb(&w, &omega_sym);
let m_mat = symmetrize_penalty(&fast_ab(&wt_omega, &w));
let (mut mu, p_mat) = FaerEigh::eigh(&m_mat, Side::Lower).map_err(BasisError::LinalgError)?;
for value in mu.iter_mut() {
if *value < 0.0 {
*value = 0.0;
}
}
let v_full = fast_ab(&w, &p_mat); let keep = thin_plate_retained_radial_indices(&mu);
Ok((v_full.select(Axis(1), &keep), mu.select(Axis(0), &keep)))
}
pub(crate) struct DuchonResolvedRadialChart {
pub(crate) reparam: Option<Array2<f64>>,
pub(crate) basis: Array2<f64>,
}
pub(crate) fn duchon_resolve_radial_chart(
data: ArrayView2<'_, f64>,
centers: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
effective_nullspace_order: DuchonNullspaceOrder,
aniso: Option<&[f64]>,
kernel_transform: &Array2<f64>,
workspace: &mut BasisWorkspace,
) -> Result<DuchonResolvedRadialChart, BasisError> {
let raw = build_duchon_basis_designwithworkspace(
data,
centers,
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso,
None,
None,
workspace,
)?;
let kernel_cols = kernel_transform.ncols();
if kernel_cols == 0 {
return Ok(DuchonResolvedRadialChart {
reparam: None,
basis: raw.basis,
});
}
let kernel_block = raw.basis.slice(s![.., 0..kernel_cols]);
let design_gram = data_metric_design_gram(kernel_block);
let omega_constrained = duchon_constrained_bending_penalty(
centers,
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso,
kernel_transform,
)?;
let (v, _mu) = thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?;
if v.ncols() == 0 {
return Ok(DuchonResolvedRadialChart {
reparam: None,
basis: raw.basis,
});
}
let rotated_kernel = fast_ab(&raw.basis.slice(s![.., 0..kernel_cols]), &v);
let poly_block = raw.basis.slice(s![.., kernel_cols..]);
let mut fused = Array2::<f64>::zeros((
raw.basis.nrows(),
rotated_kernel.ncols() + poly_block.ncols(),
));
fused
.slice_mut(s![.., 0..rotated_kernel.ncols()])
.assign(&rotated_kernel);
if poly_block.ncols() > 0 {
fused
.slice_mut(s![.., rotated_kernel.ncols()..])
.assign(&poly_block);
}
Ok(DuchonResolvedRadialChart {
reparam: Some(v),
basis: fused,
})
}
#[derive(Clone, Debug)]
pub struct ResolvedDuchonChart {
pub spec: DuchonBasisSpec,
pub centers: Array2<f64>,
pub identifiability_transform: Option<Array2<f64>>,
}
pub fn duchon_resolve_chart(
data: ArrayView2<'_, f64>,
spec: &DuchonBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<ResolvedDuchonChart, BasisError> {
let original_centers = select_centers_by_strategy(data, &spec.center_strategy)?;
let centers = expand_periodic_centers(&original_centers, spec.periodic.as_deref())?;
assert_spatial_centers_below_large_scale_cap(data.ncols(), centers.view())?;
let effective_nullspace_order =
duchon_effective_nullspace_order(centers.view(), spec.nullspace_order);
let aniso = auto_seed_aniso_contrasts(centers.view(), spec.aniso_log_scales.as_deref());
let mut resolved = spec.clone();
resolved.center_strategy = CenterStrategy::UserProvided(centers.clone());
resolved.nullspace_order = effective_nullspace_order;
resolved.aniso_log_scales = aniso.clone();
let basis = match spec.radial_reparam.as_ref() {
Some(v) => {
build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
Some(v),
None,
workspace,
)?
.basis
}
None => {
let kernel_transform = kernel_constraint_nullspace(
centers.view(),
effective_nullspace_order,
&mut workspace.cache,
)?;
if kernel_transform.ncols() == 0 {
build_duchon_basis_designwithworkspace(
data,
centers.view(),
spec.length_scale,
spec.power,
effective_nullspace_order,
aniso.as_deref(),
None,
None,
workspace,
)?
.basis
} else {
let chart = duchon_resolve_radial_chart(
data,
centers.view(),
spec,
effective_nullspace_order,
aniso.as_deref(),
&kernel_transform,
workspace,
)?;
resolved.radial_reparam = chart.reparam;
chart.basis
}
}
};
let identifiability_transform = spatial_identifiability_transform_from_design(
data,
basis.view(),
&spec.identifiability,
"Duchon",
)?;
resolved.identifiability = match identifiability_transform.as_ref() {
Some(transform) => SpatialIdentifiability::FrozenTransform {
transform: transform.clone(),
},
None => SpatialIdentifiability::None,
};
Ok(ResolvedDuchonChart {
spec: resolved,
centers,
identifiability_transform,
})
}
pub(crate) fn thin_plate_radial_reparam_from_centers(
centers: ArrayView2<'_, f64>,
length_scale: f64,
kernel_transform: &Array2<f64>,
) -> Result<(Array2<f64>, Array1<f64>), BasisError> {
let k = centers.nrows();
let d = centers.ncols();
let mut omega = Array2::<f64>::zeros((k, k));
let length_scale_sq = length_scale * length_scale;
fill_symmetric_from_row_kernel(&mut omega, |i, j| {
let mut dist2 = 0.0;
for c in 0..d {
let delta = centers[[i, c]] - centers[[j, c]];
dist2 += delta * delta;
}
thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
})?;
let kernel_gauge = gam_problem::Gauge::from_block_transforms(&[kernel_transform.clone()]);
let omega_constrained = symmetrize_penalty(&kernel_gauge.restrict_penalty(&omega));
thin_plate_radial_reparam_from_constrained_penalty(&omega_constrained)
}
pub(crate) fn kernel_constraint_nullspace_from_matrix(
constraint_matrix: ArrayView2<'_, f64>,
) -> Result<Array2<f64>, BasisError> {
let k = constraint_matrix.nrows();
let q = constraint_matrix.ncols();
if q == 0 {
return Ok(Array2::<f64>::eye(k));
}
let (z, _) = rrqr_nullspace_basis(&constraint_matrix, default_rrqr_rank_alpha())
.map_err(BasisError::LinalgError)?;
Ok(z)
}
const KNOT_MAXIMIN_TIE_REL_TOL: f64 = 1e-9;
pub fn select_thin_plate_knots(
data: ArrayView2<f64>,
num_knots: usize,
) -> Result<Array2<f64>, BasisError> {
let d = data.ncols();
let (selected, _profile_builds) = select_thin_plate_knot_rows(data, num_knots)?;
let mut knots = Array2::<f64>::zeros((selected.len(), d));
for (r, &idx) in selected.iter().enumerate() {
knots.row_mut(r).assign(&data.row(idx));
}
Ok(knots)
}
fn select_thin_plate_knot_rows(
data: ArrayView2<f64>,
num_knots: usize,
) -> Result<(Vec<usize>, usize), BasisError> {
let mut profile_builds = 0usize;
let n = data.nrows();
let d = data.ncols();
if d == 0 {
crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
}
if n == 0 {
crate::bail_invalid_basis!("cannot select thin-plate knots from empty data");
}
if data.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("thin-plate spline knot selection requires finite data");
}
if num_knots == 0 {
crate::bail_invalid_basis!("thin-plate spline knot count must be positive");
}
if num_knots > n {
crate::bail_invalid_basis!(
"requested {} knots but only {} rows are available",
num_knots,
n
);
}
let centroid: Vec<f64> = (0..d)
.map(|c| {
let mut col: Vec<f64> = (0..n).map(|i| data[[i, c]]).collect();
col.sort_by(|a, b| a.total_cmp(b));
let s: f64 = col.iter().sum();
s / n as f64
})
.collect();
let dist2_to_centroid: Vec<f64> = (0..n)
.into_par_iter()
.map(|i| {
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - centroid[c];
d2 += delta * delta;
}
d2
})
.collect();
let pair_dist2 = |i: usize, j: usize| -> f64 {
let mut distance2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - data[[j, c]];
distance2 += delta * delta;
}
distance2
};
let resolve_profile_tie = |tied: &[usize], builds: &mut usize| -> Vec<usize> {
resolve_sorted_profile_tie(n, tied, &pair_dist2, &mut |built: usize| *builds += built)
};
let distinct_orbit = |candidates: &[usize], already_selected: &[usize]| -> Vec<usize> {
let mut distinct = Vec::with_capacity(candidates.len());
'candidate: for &candidate in candidates {
for &selected in already_selected.iter().chain(distinct.iter()) {
let mut distance2 = 0.0;
for c in 0..d {
let delta = data[[candidate, c]] - data[[selected, c]];
distance2 += delta * delta;
}
if distance2 == 0.0 {
continue 'candidate;
}
}
distinct.push(candidate);
}
distinct
};
let knot_scale2 = dist2_to_centroid.iter().copied().fold(0.0_f64, f64::max);
let tie_tol = KNOT_MAXIMIN_TIE_REL_TOL * knot_scale2;
let seed_min = dist2_to_centroid
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let seed_tied: Vec<usize> = (0..n)
.filter(|&i| dist2_to_centroid[i] <= seed_min + tie_tol)
.collect();
let seed_class = resolve_profile_tie(&seed_tied, &mut profile_builds);
let seed_orbit: Vec<usize> = distinct_orbit(&seed_class, &[])
.into_iter()
.take(num_knots)
.collect();
let mut selected = Vec::with_capacity(num_knots);
let mut chosen = vec![false; n];
let mut min_dist2 = vec![f64::INFINITY; n];
for &i in &seed_class {
chosen[i] = true;
}
selected.extend(seed_orbit.iter().copied());
min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
*slot = seed_orbit
.iter()
.map(|¢er| {
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - data[[center, c]];
d2 += delta * delta;
}
d2
})
.fold(f64::INFINITY, f64::min);
});
for &i in &seed_class {
min_dist2[i] = 0.0;
}
while selected.len() < num_knots {
let max_val = min_dist2
.par_iter()
.enumerate()
.filter(|(i, _)| !chosen[*i])
.map(|(_, &cand)| cand)
.reduce(|| f64::NEG_INFINITY, f64::max);
if !max_val.is_finite() {
break;
}
let mut candidates: Vec<usize> = (0..n)
.into_par_iter()
.filter(|&i| !chosen[i] && min_dist2[i] >= max_val - tie_tol)
.collect();
if candidates.is_empty() {
break;
}
let cand_max_centroid = candidates
.iter()
.map(|&i| dist2_to_centroid[i])
.fold(f64::NEG_INFINITY, f64::max);
candidates.retain(|&i| dist2_to_centroid[i] >= cand_max_centroid - tie_tol);
let candidates = resolve_profile_tie(&candidates, &mut profile_builds);
let remaining = num_knots - selected.len();
let orbit: Vec<usize> = distinct_orbit(&candidates, &selected)
.into_iter()
.take(remaining)
.collect();
for &i in &candidates {
chosen[i] = true;
min_dist2[i] = 0.0;
}
if orbit.is_empty() {
continue;
}
selected.extend(orbit.iter().copied());
min_dist2.par_iter_mut().enumerate().for_each(|(i, slot)| {
if chosen[i] {
return;
}
for ¢er in &orbit {
let mut d2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - data[[center, c]];
d2 += delta * delta;
}
if d2 < *slot {
*slot = d2;
}
}
});
}
if selected.is_empty() {
crate::bail_invalid_basis!(
"thin-plate knot selection found no geometrically distinct selectable points in {} rows",
data.nrows()
);
}
if selected.len() < num_knots {
log::warn!(
"[thin-plate] requested {num_knots} distinct knots but the data contain only {} \
geometrically distinct selectable points; reducing the basis to {} knots",
selected.len(),
selected.len()
);
}
Ok((selected, profile_builds))
}
#[inline(always)]
pub(crate) fn thin_plate_kernel_from_dist2(
dist2: f64,
dimension: usize,
) -> Result<f64, BasisError> {
if !dist2.is_finite() || dist2 < 0.0 {
crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
}
if dist2 == 0.0 {
return Ok(0.0);
}
match dimension {
1 => Ok(dist2 * dist2.sqrt()),
2 => Ok(0.5 * dist2 * dist2.ln()),
3 => Ok(-dist2.sqrt()),
_ => {
let m = dimension / 2 + 1;
let r = dist2.sqrt();
Ok(polyharmonic_kernel(r, (m) as f64, dimension))
}
}
}
#[inline(always)]
pub(crate) fn thin_plate_penalty_order(dimension: usize) -> usize {
match dimension {
1..=3 => 2,
_ => dimension / 2 + 1,
}
}
#[inline(always)]
pub(crate) fn d_canonical_tps_infeasible(dimension: usize, num_centers: usize) -> bool {
num_centers < thin_plate_polynomial_basis_dimension(dimension)
}
pub(crate) fn thin_plate_canonical_infeasible_at_centers(centers: ArrayView2<'_, f64>) -> bool {
let dimension = centers.ncols();
if d_canonical_tps_infeasible(dimension, centers.nrows()) {
return true;
}
let poly_block = thin_plate_polynomial_block(centers);
let poly_cols = poly_block.ncols();
match rrqr_nullspace_basis(&poly_block, default_rrqr_rank_alpha()) {
Ok((_, rank)) => rank < poly_cols,
Err(_) => false,
}
}
pub(crate) fn duchon_thin_plate_fallback_params(
dimension: usize,
num_centers: usize,
) -> Option<(DuchonNullspaceOrder, usize)> {
let d = dimension;
let max_op = 2usize; for (order, p, m_poly) in [
(DuchonNullspaceOrder::Linear, 2usize, d + 1),
(DuchonNullspaceOrder::Zero, 1usize, 1usize),
] {
if num_centers < m_poly {
continue;
}
let target = d + max_op;
let s_min = if 2 * p > target {
0
} else {
(target - 2 * p) / 2 + 1
};
return Some((order, s_min));
}
None
}
pub(crate) fn hybrid_duchon_promotion_length_scale(
centers: ArrayView2<'_, f64>,
requested_length_scale: f64,
) -> f64 {
match pairwise_distance_bounds_sampled(centers) {
Some((r_min, r_max)) => {
(r_min * r_max).sqrt()
}
None => {
if requested_length_scale.is_finite() && requested_length_scale > 0.0 {
requested_length_scale
} else {
1.0
}
}
}
}
#[inline(always)]
pub(crate) fn thin_plate_kernel_triplet_from_scaled_distance(
scaled_distance: f64,
dimension: usize,
) -> Result<(f64, f64, f64), BasisError> {
if !scaled_distance.is_finite() || scaled_distance < 0.0 {
crate::bail_invalid_basis!("thin-plate scaled distance must be finite and non-negative");
}
if scaled_distance == 0.0 {
return Ok((0.0, 0.0, 0.0));
}
match dimension {
1 => {
let value = scaled_distance.powi(3);
let first = 3.0 * scaled_distance.powi(2);
let second = 6.0 * scaled_distance;
Ok((value, first, second))
}
2 => {
let log_r = scaled_distance.max(1e-300).ln();
let value = scaled_distance.powi(2) * log_r;
let first = 2.0 * scaled_distance * log_r + scaled_distance;
let second = 2.0 * log_r + 3.0;
Ok((value, first, second))
}
3 => Ok((-scaled_distance, -1.0, 0.0)),
_ => polyharmonic_kernel_triplet(
scaled_distance,
thin_plate_penalty_order(dimension) as f64,
dimension,
),
}
}
#[inline(always)]
pub(crate) fn thin_plate_kernel_psi_triplet_from_distance(
distance: f64,
length_scale: f64,
dimension: usize,
) -> Result<(f64, f64, f64), BasisError> {
if !distance.is_finite() || distance < 0.0 {
crate::bail_invalid_basis!("thin-plate kernel distance must be finite and non-negative");
}
if !length_scale.is_finite() || length_scale <= 0.0 {
crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
}
let scaled_distance = distance / length_scale;
let (value, radial_first, radial_second) =
thin_plate_kernel_triplet_from_scaled_distance(scaled_distance, dimension)?;
let psi = radial_first * scaled_distance;
let psi_psi = radial_second * scaled_distance * scaled_distance + psi;
Ok((value, psi, psi_psi))
}
pub fn create_thin_plate_spline_basis(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
) -> Result<ThinPlateSplineBasis, BasisError> {
let mut workspace = BasisWorkspace::default();
create_thin_plate_spline_basiswithworkspace(data, knots, &mut workspace)
}
pub fn create_thin_plate_spline_basiswithworkspace(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
workspace: &mut BasisWorkspace,
) -> Result<ThinPlateSplineBasis, BasisError> {
create_thin_plate_spline_basis_scaledwithworkspace(data, knots, 1.0, None, workspace)
}
pub fn create_thin_plate_spline_basis_in_chart(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
radial_reparam: &Array2<f64>,
) -> Result<ThinPlateSplineBasis, BasisError> {
let mut workspace = BasisWorkspace::default();
create_thin_plate_spline_basis_scaledwithworkspace(
data,
knots,
1.0,
Some(radial_reparam),
&mut workspace,
)
}
pub(crate) fn create_thin_plate_spline_basis_scaledwithworkspace(
data: ArrayView2<f64>,
knots: ArrayView2<f64>,
length_scale: f64,
frozen_radial_reparam: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<ThinPlateSplineBasis, BasisError> {
let n = data.nrows();
let k = knots.nrows();
let d = data.ncols();
if d == 0 {
crate::bail_invalid_basis!("thin-plate spline requires at least one covariate dimension");
}
if d != knots.ncols() {
crate::bail_dim_basis!(
"thin-plate spline dimension mismatch: data has {} columns, knots have {} columns",
d,
knots.ncols()
);
}
let poly_cols = thin_plate_polynomial_basis_dimension(d);
if k < poly_cols {
crate::bail_invalid_basis!(
"thin-plate spline requires at least {} knots to span the degree-{} polynomial null space in dimension {}; got {}",
poly_cols,
thin_plate_polynomial_degree(d),
d,
k
);
}
if data.iter().any(|v| !v.is_finite()) || knots.iter().any(|v| !v.is_finite()) {
crate::bail_invalid_basis!("thin-plate spline requires finite data and knot values");
}
if !length_scale.is_finite() || length_scale <= 0.0 {
crate::bail_invalid_basis!("thin-plate length_scale must be finite and positive");
}
let knot_mean: Vec<f64> = (0..d)
.map(|c| knots.column(c).sum() / (k.max(1) as f64))
.collect();
let mut data_centered = data.to_owned();
let mut knots_centered = knots.to_owned();
for c in 0..d {
let mu = knot_mean[c];
data_centered.column_mut(c).mapv_inplace(|v| v - mu);
knots_centered.column_mut(c).mapv_inplace(|v| v - mu);
}
let data = data_centered.view();
let knots = knots_centered.view();
let mut kernel_block = Array2::<f64>::zeros((n, k));
let kernel_result: Result<(), BasisError> = kernel_block
.axis_iter_mut(Axis(0))
.into_par_iter()
.enumerate()
.try_for_each(|(i, mut row)| {
for j in 0..k {
let mut dist2 = 0.0;
for c in 0..d {
let delta = data[[i, c]] - knots[[j, c]];
dist2 += delta * delta;
}
row[j] = thin_plate_kernel_from_dist2(dist2 / (length_scale * length_scale), d)?;
}
Ok(())
});
kernel_result?;
let poly_block = thin_plate_polynomial_block(data);
let mut omega = Array2::<f64>::zeros((k, k));
let length_scale_sq = length_scale * length_scale;
fill_symmetric_from_row_kernel(&mut omega, |i, j| {
let mut dist2 = 0.0;
for c in 0..d {
let delta = knots[[i, c]] - knots[[j, c]];
dist2 += delta * delta;
}
thin_plate_kernel_from_dist2(dist2 / length_scale_sq, d)
})?;
let z = thin_plate_kernel_constraint_nullspace(knots, &mut workspace.cache)?;
let kernel_constrained = fast_ab(&kernel_block, &z);
let omega_constrained = {
let zt_o = fast_atb(&z, &omega);
symmetrize_penalty(&fast_ab(&zt_o, &z))
};
let omega_psd = validate_psd_penalty(
&omega_constrained,
&format!("thin_plate bending penalty (dimension={d})"),
"thin-plate kernel and side-constraint assembly must yield a PSD penalty on the constrained subspace",
)?;
assert!(
omega_psd.min_eigenvalue >= -omega_psd.tolerance,
"thin-plate constrained penalty PSD validation violated tolerance after validation: min_eigenvalue={}, tolerance={}",
omega_psd.min_eigenvalue,
omega_psd.tolerance
);
assert!(
omega_psd.max_abs_eigenvalue.is_finite(),
"thin-plate constrained penalty has non-finite max eigenvalue after validation: max_abs_eigenvalue={}",
omega_psd.max_abs_eigenvalue
);
assert!(
omega_psd.effective_rank <= omega_constrained.nrows(),
"thin-plate constrained penalty rank exceeds constrained rows: effective_rank={}, rows={}",
omega_psd.effective_rank,
omega_constrained.nrows()
);
let constrained_kernel_cols = kernel_constrained.ncols();
let (radial_reparam, radial_eigvals): (Array2<f64>, Array1<f64>) = if let Some(frozen) =
frozen_radial_reparam
{
if frozen.nrows() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
frozen.dim(),
constrained_kernel_cols
);
}
let v = frozen.to_owned();
let vt_omega_v = fast_atb(&v, &omega_constrained);
let lambda_diag = fast_ab(&vt_omega_v, &v);
let mut evals = Array1::<f64>::zeros(v.ncols());
for i in 0..v.ncols() {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v, evals)
} else if constrained_kernel_cols == 0 {
(Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
} else {
let design_gram = data_metric_design_gram(kernel_constrained.view());
thin_plate_radial_reparam_data_metric(&omega_constrained, &design_gram)?
};
let kernel_cols = radial_eigvals.len();
let total_cols = kernel_cols + poly_cols;
let kernel_rotated = if kernel_cols == 0 {
Array2::<f64>::zeros((n, 0))
} else {
fast_ab(&kernel_constrained, &radial_reparam)
};
let mut basis = Array2::<f64>::zeros((n, total_cols));
basis
.slice_mut(s![.., 0..kernel_cols])
.assign(&kernel_rotated);
basis.slice_mut(s![.., kernel_cols..]).assign(&poly_block);
let mut penalty_bending = Array2::<f64>::zeros((total_cols, total_cols));
for i in 0..kernel_cols {
penalty_bending[[i, i]] = radial_eigvals[i];
}
let center_kernel_rotated = if kernel_cols == 0 {
Array2::<f64>::zeros((k, 0))
} else {
fast_ab(&fast_ab(&omega, &z), &radial_reparam)
};
let center_poly = thin_plate_polynomial_block(knots);
let mut center_design = Array2::<f64>::zeros((k, total_cols));
center_design
.slice_mut(s![.., 0..kernel_cols])
.assign(¢er_kernel_rotated);
center_design
.slice_mut(s![.., kernel_cols..])
.assign(¢er_poly);
let function_gram = symmetrize_penalty(&fast_ata(¢er_design));
let penalty_ridge = function_space_nullspace_shrinkage(&penalty_bending, &function_gram)?
.unwrap_or_else(|| Array2::<f64>::zeros((total_cols, total_cols)));
Ok(ThinPlateSplineBasis {
basis,
penalty_bending,
penalty_ridge,
num_kernel_basis: kernel_cols,
num_polynomial_basis: poly_cols,
dimension: d,
radial_reparam,
})
}
pub(crate) fn active_thin_plate_penalty_derivatives(
penalties: &[ActivePenalty],
primary_derivative: &Array2<f64>,
nullspace_derivative: &Array2<f64>,
) -> Result<Vec<Array2<f64>>, BasisError> {
penalties
.iter()
.map(|penalty| match &penalty.info.source {
PenaltySource::Primary => Ok(primary_derivative.clone()),
PenaltySource::DoublePenaltyNullspace => Ok(nullspace_derivative.clone()),
other => Err(BasisError::InvalidInput(format!(
"unexpected ThinPlate penalty source in psi-derivative path: {other:?}"
))),
})
.collect()
}
pub fn build_thin_plate_penalty_psi_derivativeswithworkspace(
centers: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
identifiability_transform: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>, Array2<f64>), BasisError> {
let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
let constrained_kernel_cols = z_kernel.ncols();
let poly_cols = thin_plate_polynomial_basis_dimension(centers.ncols());
let k = centers.nrows();
let d = centers.ncols();
let mut omega = Array2::<f64>::zeros((k, k));
let mut omega_psi = Array2::<f64>::zeros((k, k));
let mut omega_psi_psi = Array2::<f64>::zeros((k, k));
struct ThinPlatePsiTileEntry {
pub(crate) i: usize,
pub(crate) j: usize,
pub(crate) phi: f64,
pub(crate) phi_psi: f64,
pub(crate) phi_psi_psi: f64,
}
let n_tiles = k.div_ceil(THIN_PLATE_PENALTY_PSI_TILE_ROWS);
let omega_tiles: Result<Vec<Vec<ThinPlatePsiTileEntry>>, BasisError> = (0..n_tiles)
.into_par_iter()
.map(|tile_idx| {
let row_start = tile_idx * THIN_PLATE_PENALTY_PSI_TILE_ROWS;
let row_end = (row_start + THIN_PLATE_PENALTY_PSI_TILE_ROWS).min(k);
let tile_pairs = (row_start..row_end).map(|i| i + 1).sum::<usize>();
let mut entries = Vec::with_capacity(tile_pairs);
for i in row_start..row_end {
for j in 0..=i {
let mut dist2 = 0.0;
for axis in 0..d {
let delta = centers[[i, axis]] - centers[[j, axis]];
dist2 += delta * delta;
}
let (phi, phi_psi, phi_psi_psi) = thin_plate_kernel_psi_triplet_from_distance(
dist2.sqrt(),
spec.length_scale,
d,
)?;
entries.push(ThinPlatePsiTileEntry {
i,
j,
phi,
phi_psi,
phi_psi_psi,
});
}
}
Ok(entries)
})
.collect();
for tile in omega_tiles? {
for entry in tile {
omega[[entry.i, entry.j]] = entry.phi;
omega_psi[[entry.i, entry.j]] = entry.phi_psi;
omega_psi_psi[[entry.i, entry.j]] = entry.phi_psi_psi;
if entry.i != entry.j {
omega[[entry.j, entry.i]] = entry.phi;
omega_psi[[entry.j, entry.i]] = entry.phi_psi;
omega_psi_psi[[entry.j, entry.i]] = entry.phi_psi_psi;
}
}
}
let m_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega).dot(&z_kernel));
let m_psi_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi).dot(&z_kernel));
let m_pp_constrained = symmetrize_penalty(&z_kernel.t().dot(&omega_psi_psi).dot(&z_kernel));
let (v, lambda) = if let Some(frozen) = spec.radial_reparam.as_ref() {
if frozen.nrows() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate frozen radial reparam shape {:?} does not match constrained radial dimension {}",
frozen.dim(),
constrained_kernel_cols
);
}
let v_owned = frozen.to_owned();
let lambda_diag = fast_ab(&fast_atb(&v_owned, &m_constrained), &v_owned);
let mut evals = Array1::<f64>::zeros(v_owned.ncols());
for i in 0..v_owned.ncols() {
evals[i] = lambda_diag[[i, i]].max(0.0);
}
(v_owned, evals)
} else if constrained_kernel_cols == 0 {
(Array2::<f64>::zeros((0, 0)), Array1::<f64>::zeros(0))
} else {
let (mut evals, evecs) =
FaerEigh::eigh(&m_constrained, Side::Lower).map_err(BasisError::LinalgError)?;
for ev in evals.iter_mut() {
if *ev < 0.0 {
*ev = 0.0;
}
}
let keep = thin_plate_retained_radial_indices(&evals);
(evecs.select(Axis(1), &keep), evals.select(Axis(0), &keep))
};
let kernel_cols = lambda.len();
let total_cols = kernel_cols + poly_cols;
let v_is_frozen = spec.radial_reparam.is_some();
let a_psi = if kernel_cols > 0 {
v.t().dot(&m_psi_constrained).dot(&v)
} else {
Array2::<f64>::zeros((0, 0))
};
let a_pp = if kernel_cols > 0 {
v.t().dot(&m_pp_constrained).dot(&v)
} else {
Array2::<f64>::zeros((0, 0))
};
let s_raw_kernel = Array2::from_diag(&lambda);
let s_raw_psi_kernel = if v_is_frozen {
a_psi.clone()
} else {
let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
for i in 0..kernel_cols {
diag[[i, i]] = a_psi[[i, i]];
}
diag
};
let s_raw_pp_kernel = if v_is_frozen {
a_pp.clone()
} else {
let mut diag = Array2::<f64>::zeros((kernel_cols, kernel_cols));
for i in 0..kernel_cols {
let mut acc = a_pp[[i, i]];
for k_idx in 0..kernel_cols {
if k_idx == i {
continue;
}
let denom = lambda[i] - lambda[k_idx];
if denom.abs() > 1e-14 {
acc += 2.0 * a_psi[[i, k_idx]].powi(2) / denom;
}
}
diag[[i, i]] = acc;
}
diag
};
let pad = |kernel_block: &Array2<f64>| -> Array2<f64> {
let mut s = Array2::<f64>::zeros((total_cols, total_cols));
if kernel_cols > 0 {
s.slice_mut(s![0..kernel_cols, 0..kernel_cols])
.assign(kernel_block);
}
s
};
let s_raw = pad(&s_raw_kernel);
let s_raw_psi = pad(&s_raw_psi_kernel);
let s_raw_pp = pad(&s_raw_pp_kernel);
let (_, s_norm_psi, s_norm_pp, _c) =
normalize_penaltywith_psi_derivatives(&s_raw, &s_raw_psi, &s_raw_pp);
let s_psi_out = project_penalty_matrix(&s_norm_psi, identifiability_transform);
let s_psi_psi_out = project_penalty_matrix(&s_norm_pp, identifiability_transform);
let kernel_transform = fast_ab(&z_kernel, &v);
let center_kernel = fast_ab(&omega, &kernel_transform);
let center_kernel_psi = fast_ab(&omega_psi, &kernel_transform);
let center_kernel_pp = fast_ab(&omega_psi_psi, &kernel_transform);
let center_mean: Vec<f64> = (0..d)
.map(|axis| centers.column(axis).sum() / k.max(1) as f64)
.collect();
let mut centered = centers.to_owned();
for axis in 0..d {
let mean = center_mean[axis];
centered.column_mut(axis).mapv_inplace(|value| value - mean);
}
let center_poly = thin_plate_polynomial_block(centered.view());
let mut center_design = Array2::<f64>::zeros((k, total_cols));
let mut center_design_psi = Array2::<f64>::zeros((k, total_cols));
let mut center_design_pp = Array2::<f64>::zeros((k, total_cols));
center_design
.slice_mut(s![.., 0..kernel_cols])
.assign(¢er_kernel);
center_design
.slice_mut(s![.., kernel_cols..])
.assign(¢er_poly);
center_design_psi
.slice_mut(s![.., 0..kernel_cols])
.assign(¢er_kernel_psi);
center_design_pp
.slice_mut(s![.., 0..kernel_cols])
.assign(¢er_kernel_pp);
let (center_design, center_design_psi, center_design_pp, null_frame) =
if let Some(transform) = identifiability_transform {
if transform.nrows() != total_cols {
crate::bail_dim_basis!(
"thin-plate identifiability transform has {} rows, expected {}",
transform.nrows(),
total_cols
);
}
let kernel_coordinate_map = transform.slice(s![0..kernel_cols, ..]).to_owned();
let (frame, _) = rrqr_nullspace_basis(
&kernel_coordinate_map.t().to_owned(),
default_rrqr_rank_alpha(),
)
.map_err(BasisError::LinalgError)?;
(
fast_ab(¢er_design, transform),
fast_ab(¢er_design_psi, transform),
fast_ab(¢er_design_pp, transform),
frame,
)
} else {
let mut frame = Array2::<f64>::zeros((total_cols, poly_cols));
for column in 0..poly_cols {
frame[[kernel_cols + column, column]] = 1.0;
}
(center_design, center_design_psi, center_design_pp, frame)
};
let gram = symmetrize_penalty(&fast_ata(¢er_design));
let gram_psi = symmetrize_penalty(
&(fast_atb(¢er_design_psi, ¢er_design)
+ fast_atb(¢er_design, ¢er_design_psi)),
);
let gram_pp = symmetrize_penalty(
&(fast_atb(¢er_design_pp, ¢er_design)
+ fast_atb(¢er_design_psi, ¢er_design_psi).mapv(|value| 2.0 * value)
+ fast_atb(¢er_design, ¢er_design_pp)),
);
let ridge_jet = function_space_subspace_shrinkage_derivatives(
&null_frame,
&gram,
&gram_psi,
&gram_psi,
&gram_pp,
)?;
let (_, ridge_psi, ridge_pp, _) = normalize_penaltywith_psi_derivatives(
&ridge_jet.value,
&ridge_jet.first_a,
&ridge_jet.mixed,
);
Ok((s_psi_out, s_psi_psi_out, ridge_psi, ridge_pp))
}
pub(crate) fn build_thin_plate_scalar_design_psi_derivatives(
data: ArrayView2<'_, f64>,
centers: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
identifiability_transform: Option<&Array2<f64>>,
workspace: &mut BasisWorkspace,
) -> Result<ScalarDesignPsiDerivatives, BasisError> {
let z_kernel = thin_plate_kernel_constraint_nullspace(centers, &mut workspace.cache)?;
let constrained_kernel_cols = z_kernel.ncols();
let kernel_transform = if let Some(v) = spec.radial_reparam.as_ref() {
if v.nrows() != constrained_kernel_cols {
crate::bail_dim_basis!(
"thin-plate radial reparam shape {:?} does not match constrained radial dimension {}",
v.dim(),
constrained_kernel_cols
);
}
fast_ab(&z_kernel, v)
} else {
z_kernel
};
let kernel_cols = kernel_transform.ncols();
let poly_cols = thin_plate_polynomial_basis_dimension(data.ncols());
let p_after_pad = kernel_cols + poly_cols;
let p_final = identifiability_transform
.map(|zf| zf.ncols())
.unwrap_or(p_after_pad);
build_scalar_design_psi_derivatives_shared(
data,
centers,
None,
p_final,
Some(kernel_transform),
identifiability_transform.cloned(),
poly_cols,
RadialScalarKind::ThinPlate {
length_scale: spec.length_scale,
dim: data.ncols(),
},
0.0,
)
}
pub fn build_thin_plate_basis_log_kappa_derivative(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiDerivativeResult, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappa_derivativewithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappa_derivativewithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiDerivativeResult, BasisError> {
let mut bundle =
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
bundle.first.implicit_operator = bundle.implicit_operator;
Ok(bundle.first)
}
pub fn build_thin_plate_basis_log_kappa_derivatives(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiDerivativeBundle, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappa_derivativeswithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiDerivativeBundle, BasisError> {
let base = build_thin_plate_basiswithworkspace(data, spec, workspace)?;
let (centers, identifiability_transform, radial_reparam) = match &base.metadata {
BasisMetadata::ThinPlate {
centers,
identifiability_transform,
radial_reparam,
..
} => (
centers.clone(),
identifiability_transform.clone(),
radial_reparam.clone(),
),
_ => {
crate::bail_invalid_basis!("ThinPlate derivative path expected ThinPlate metadata");
}
};
let mut derivative_spec = spec.clone();
if derivative_spec.radial_reparam.is_none() {
derivative_spec.radial_reparam = radial_reparam;
}
let scalar = build_thin_plate_scalar_design_psi_derivatives(
data,
centers.view(),
&derivative_spec,
identifiability_transform.as_ref(),
workspace,
)?;
let (
primary_derivative_opt,
primarysecond_derivative_opt,
nullspace_derivative_opt,
nullspacesecond_derivative_opt,
) = build_thin_plate_penalty_psi_derivativeswithworkspace(
centers.view(),
&derivative_spec,
identifiability_transform.as_ref(),
workspace,
)?;
let primary_derivative = primary_derivative_opt;
let primarysecond_derivative = primarysecond_derivative_opt;
let nullspace_derivative = nullspace_derivative_opt;
let nullspacesecond_derivative = nullspacesecond_derivative_opt;
let penalties_derivative = active_thin_plate_penalty_derivatives(
&base.active_penalties,
&primary_derivative,
&nullspace_derivative,
)?;
let penaltiessecond_derivative = active_thin_plate_penalty_derivatives(
&base.active_penalties,
&primarysecond_derivative,
&nullspacesecond_derivative,
)?;
Ok(BasisPsiDerivativeBundle {
first: BasisPsiDerivativeResult {
design_derivative: scalar.design_first,
penalties_derivative,
implicit_operator: None,
},
second: BasisPsiSecondDerivativeResult {
designsecond_derivative: scalar.design_second_diag,
penaltiessecond_derivative,
implicit_operator: None,
},
implicit_operator: scalar.implicit_operator,
})
}
pub fn build_thin_plate_basis_log_kappasecond_derivative(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
let mut workspace = BasisWorkspace::default();
build_thin_plate_basis_log_kappasecond_derivativewithworkspace(data, spec, &mut workspace)
}
pub fn build_thin_plate_basis_log_kappasecond_derivativewithworkspace(
data: ArrayView2<'_, f64>,
spec: &ThinPlateBasisSpec,
workspace: &mut BasisWorkspace,
) -> Result<BasisPsiSecondDerivativeResult, BasisError> {
let mut bundle =
build_thin_plate_basis_log_kappa_derivativeswithworkspace(data, spec, workspace)?;
bundle.second.implicit_operator = bundle.implicit_operator;
Ok(bundle.second)
}
pub fn create_thin_plate_spline_basis_with_knot_count(
data: ArrayView2<f64>,
num_knots: usize,
) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
let mut workspace = BasisWorkspace::default();
create_thin_plate_spline_basis_with_knot_count_andworkspace(data, num_knots, &mut workspace)
}
pub fn create_thin_plate_spline_basis_with_knot_count_andworkspace(
data: ArrayView2<f64>,
num_knots: usize,
workspace: &mut BasisWorkspace,
) -> Result<(ThinPlateSplineBasis, Array2<f64>), BasisError> {
let knots = select_thin_plate_knots(data, num_knots)?;
let basis = create_thin_plate_spline_basiswithworkspace(data, knots.view(), workspace)?;
Ok((basis, knots))
}
pub fn apply_sum_to_zero_constraint(
basis_matrix: ArrayView2<f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if k < 2 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let constraintvector = match weights {
Some(w) => {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
w.to_owned()
}
None => Array1::<f64>::ones(n),
};
let c = basis_matrix.t().dot(&constraintvector);
let mut c_mat = Array2::<f64>::zeros((k, 1));
c_mat.column_mut(0).assign(&c);
let (z, rank) =
rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "apply_sum_to_zero_constraint",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
if rank == 0 {
return Ok((basis_matrix.to_owned(), Array2::eye(k)));
}
let gauge = gam_problem::Gauge::sum_to_zero(z);
let constrained = gauge.restrict_design(&basis_matrix);
let z = gauge.block_transform(0);
Ok((constrained, z))
}
pub fn apply_sum_to_zero_constraint_sparse(
basis_matrix: &SparseColMat<usize, f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if k < 2 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let constraint_weights = match weights {
Some(w) => {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
w.to_owned()
}
None => Array1::<f64>::ones(n),
};
let mut c = Array1::<f64>::zeros(k);
let (symbolic, values) = basis_matrix.parts();
let col_ptr = symbolic.col_ptr();
let row_idx = symbolic.row_idx();
for col in 0..k {
let mut sum = 0.0;
for idx in col_ptr[col]..col_ptr[col + 1] {
sum += values[idx] * constraint_weights[row_idx[idx]];
}
c[col] = sum;
}
let mut c_mat = Array2::<f64>::zeros((k, 1));
c_mat.column_mut(0).assign(&c);
let (z, rank) =
rrqr_nullspace_basis(&c_mat, default_rrqr_rank_alpha()).map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "apply_sum_to_zero_constraint_sparse",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: c.iter().map(|v| v * v).sum::<f64>().sqrt(),
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
if rank == 0 {
let mut dense_b = Array2::<f64>::zeros((n, k));
for col in 0..k {
for idx in col_ptr[col]..col_ptr[col + 1] {
dense_b[[row_idx[idx], col]] = values[idx];
}
}
return Ok((dense_b, Array2::eye(k)));
}
let kc = z.ncols();
let mut constrained = Array2::<f64>::zeros((n, kc));
for out_col in 0..kc {
let z_col = z.column(out_col);
let mut dst = constrained.column_mut(out_col);
for src_col in 0..k {
let coeff = z_col[src_col];
if coeff == 0.0 {
continue;
}
for idx in col_ptr[src_col]..col_ptr[src_col + 1] {
dst[row_idx[idx]] += coeff * values[idx];
}
}
}
Ok((constrained, z))
}
pub fn applyweighted_orthogonality_constraint(
basis_matrix: ArrayView2<f64>,
constraint_matrix: ArrayView2<f64>,
weights: Option<ArrayView1<f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = basis_matrix.nrows();
let k = basis_matrix.ncols();
if constraint_matrix.nrows() != n {
return Err(BasisError::ConstraintMatrixRowMismatch {
basisrows: n,
constraintrows: constraint_matrix.nrows(),
});
}
if k == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let q = constraint_matrix.ncols();
if q == 0 {
return Ok((basis_matrix.to_owned(), Array2::eye(k)));
}
let mut weighted_constraints = constraint_matrix.to_owned();
if let Some(w) = weights {
if w.len() != n {
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
for (mut row, &weight) in weighted_constraints.axis_iter_mut(Axis(0)).zip(w.iter()) {
row *= weight;
}
}
let constraint_cross = basis_matrix.t().dot(&weighted_constraints); let gram = fast_ata(&basis_matrix);
let transform = orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)?;
let basis_orthonormal = fast_ab(&basis_matrix, &transform);
Ok((basis_orthonormal, transform))
}
pub fn compute_greville_abscissae(
knot_vector: &Array1<f64>,
degree: usize,
) -> Result<Array1<f64>, BasisError> {
let n_knots = knot_vector.len();
if degree == 0 {
let n_basis = n_knots.saturating_sub(1);
if n_basis == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let mut g = Array1::<f64>::zeros(n_basis);
for j in 0..n_basis {
g[j] = 0.5 * (knot_vector[j] + knot_vector[j + 1]);
}
return Ok(g);
}
if n_knots <= degree + 1 {
return Err(BasisError::InsufficientColumnsForConstraint {
found: n_knots.saturating_sub(degree + 1),
});
}
let n_basis = n_knots - degree - 1;
let mut g = Array1::<f64>::zeros(n_basis);
let d_inv = 1.0 / (degree as f64);
for j in 0..n_basis {
let mut sum = 0.0;
for k in 1..=degree {
sum += knot_vector[j + k];
}
g[j] = sum * d_inv;
}
let g_min = g.iter().cloned().fold(f64::INFINITY, f64::min);
let g_max = g.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if (g_max - g_min) < 1e-10 {
return Err(BasisError::DegenerateKnots);
}
Ok(g)
}
pub fn compute_geometric_constraint_transform(
knot_vector: &Array1<f64>,
degree: usize,
penalty_order: usize,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let g = compute_greville_abscissae(knot_vector, degree)?;
let k = g.len();
if k < 3 {
return Err(BasisError::InsufficientColumnsForConstraint { found: k });
}
let mut c_geom = Array2::<f64>::zeros((2, k));
for j in 0..k {
c_geom[[0, j]] = 1.0;
c_geom[[1, j]] = g[j];
}
let g_mean = g.mean().unwrap_or(0.0);
let gvar = g.iter().map(|&x| (x - g_mean).powi(2)).sum::<f64>() / (k as f64);
let g_std = gvar.sqrt().max(1e-10);
for j in 0..k {
c_geom[[1, j]] = (c_geom[[1, j]] - g_mean) / g_std;
}
let (z, rank) = rrqr_nullspace_basis(&c_geom.t(), default_rrqr_rank_alpha())
.map_err(BasisError::LinalgError)?;
if rank >= k {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "compute_geometric_constraint_transform",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: f64::NAN,
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
if z.ncols() == 0 {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "compute_geometric_constraint_transform",
cross_rank: 0,
coeff_dim: k,
cross_frobenius: f64::NAN,
gram_spectrum: "not computed (structural rank collapse before Gram eigendecomposition)"
.to_string(),
});
}
let s_raw = create_difference_penalty_matrix(k, penalty_order, Some(g.view()))?;
let s_constrained = {
let zt_s = fast_atb(&z, &s_raw);
fast_ab(&zt_s, &z)
};
Ok((z, s_constrained))
}
#[derive(Debug, Clone)]
pub struct AutoBSplineKnots {
pub knots: Array1<f64>,
pub degree: usize,
pub num_internal_knots: usize,
pub shrunk: bool,
}
pub fn auto_knot_vector_1d_quantile(
data: ArrayView1<'_, f64>,
num_internal_knots: usize,
degree: usize,
) -> Result<AutoBSplineKnots, BasisError> {
let n = data.len();
let Some((eff_knots, eff_degree, shrunk)) =
auto_shrink_bspline_config(n, num_internal_knots, degree)
else {
crate::bail_invalid_basis!(
"auto-knot placement needs at least 2 finite evaluation points (got n={n}); \
cannot fit even a linear B-spline",
);
};
let knots = internal::generate_full_knot_vector_quantile(data, eff_knots, eff_degree)?;
Ok(AutoBSplineKnots {
knots,
degree: eff_degree,
num_internal_knots: eff_knots,
shrunk,
})
}
pub fn clamped_knot_vector_from_internal_positions(
data_range: (f64, f64),
internal_positions: &[f64],
degree: usize,
) -> Result<Array1<f64>, BasisError> {
let (minval, maxval) = data_range;
if !(minval.is_finite() && maxval.is_finite()) {
crate::bail_invalid_basis!(
"explicit knots require a finite data range, got ({minval:.6e}, {maxval:.6e})"
);
}
if minval >= maxval {
return Err(BasisError::InvalidRange(minval, maxval));
}
let scale = (maxval - minval).abs().max(1.0);
let tol = 1e-12 * scale;
let mut interior: Vec<f64> = Vec::with_capacity(internal_positions.len());
for &k in internal_positions {
if !k.is_finite() {
crate::bail_invalid_basis!("explicit knot position {k:.6e} is not finite");
}
if k <= minval + tol || k >= maxval - tol {
crate::bail_invalid_basis!(
"explicit internal knot {k:.6e} must lie strictly inside the data range \
({minval:.6e}, {maxval:.6e}); boundary knots are added automatically"
);
}
interior.push(k);
}
interior.sort_by(f64::total_cmp);
for w in interior.windows(2) {
if (w[1] - w[0]).abs() <= tol {
crate::bail_invalid_basis!(
"explicit internal knots must be strictly increasing; \
found a duplicate/near-duplicate near {:.6e}",
w[0]
);
}
}
let total_knots = interior.len() + 2 * (degree + 1);
let mut knots = Vec::with_capacity(total_knots);
for _ in 0..=degree {
knots.push(minval);
}
knots.extend_from_slice(&interior);
for _ in 0..=degree {
knots.push(maxval);
}
Ok(Array::from_vec(knots))
}
pub fn auto_centers_1d_equal_mass(
data: ArrayView1<'_, f64>,
num_centers: usize,
) -> Result<Array1<f64>, BasisError> {
let column = data.to_owned().insert_axis(Axis(1));
let centers = select_equal_mass_centers(column.view(), num_centers)?;
let mut flat: Vec<f64> = centers.column(0).iter().copied().collect();
flat.sort_by(f64::total_cmp);
Ok(Array1::from_vec(flat))
}
#[cfg(test)]
mod knot_selection_tie_break_cost_tests {
use super::{select_thin_plate_knot_rows, select_thin_plate_knots};
use ndarray::Array2;
struct KnotSelection {
rows: Vec<usize>,
profile_builds: usize,
}
fn select_with_profile_count(data: &Array2<f64>, num_knots: usize) -> KnotSelection {
let (rows, profile_builds) = select_thin_plate_knot_rows(data.view(), num_knots)
.expect("fixture admits the requested knot budget");
KnotSelection {
rows,
profile_builds,
}
}
fn hashed_unit(index: u64) -> f64 {
let mut z = index.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64
}
fn asymmetric_cloud(n: usize, d: usize) -> Array2<f64> {
Array2::from_shape_fn((n, d), |(row, col)| hashed_unit((row * d + col) as u64))
}
fn integer_grid(side: usize) -> Array2<f64> {
Array2::from_shape_fn((side * side, 2), |(row, col)| {
if col == 0 {
(row / side) as f64
} else {
(row % side) as f64
}
})
}
#[test]
fn thin_plate_knots_cost_no_profile_without_an_exact_tie() {
for (n, d) in [(2_000_usize, 2_usize), (2_000, 8), (8_000, 3)] {
for k in [20_usize, 100] {
let data = asymmetric_cloud(n, d);
let chosen = select_with_profile_count(&data, k);
assert_eq!(chosen.rows.len(), k, "knot budget (n={n}, d={d}, k={k})");
assert_eq!(
chosen.profile_builds,
0,
"no row ties the maximin key on an asymmetric cloud, so the profile \
tie-break must never be built (n={n}, d={d}, k={k}); the replaced \
comparator scan built {}",
2 * k
);
}
}
}
#[test]
fn thin_plate_knot_profile_cost_does_not_scale_with_the_row_count() {
for side in [20_usize, 60] {
for k in [20_usize, 100] {
let data = integer_grid(side);
let n = data.nrows();
let chosen = select_with_profile_count(&data, k);
assert_eq!(chosen.rows.len(), k, "knot budget (n={n}, k={k})");
assert!(
chosen.profile_builds <= 2 * k,
"profile-key builds must stay proportional to the knot budget, not to \
the row count; got {} at n={n} k={k}",
chosen.profile_builds
);
}
}
}
#[test]
fn thin_plate_knot_profile_key_is_still_built_where_it_decides_an_orbit() {
let data = ndarray::array![
[-1.0_f64, -1.0],
[-1.0, 1.0],
[1.0, -1.0],
[1.0, 1.0],
[0.0, 0.0]
];
let chosen = select_with_profile_count(&data, 5);
assert_eq!(chosen.rows.len(), 5);
assert!(
chosen.profile_builds > 0,
"the four-corner orbit is only provable through the invariant profile key"
);
}
#[test]
fn the_public_knot_matrix_is_the_selected_rows_verbatim() {
for (n, d, k) in [(500_usize, 2_usize, 17_usize), (441, 2, 40)] {
let data = if d == 2 && n == 441 {
integer_grid(21)
} else {
asymmetric_cloud(n, d)
};
let chosen = select_with_profile_count(&data, k);
let knots = select_thin_plate_knots(data.view(), k).expect("same budget");
assert_eq!(knots.nrows(), chosen.rows.len());
for (r, &row) in chosen.rows.iter().enumerate() {
for c in 0..d {
assert_eq!(
knots[[r, c]].to_bits(),
data[[row, c]].to_bits(),
"knot {r} column {c} is not data row {row} verbatim"
);
}
}
}
}
}
#[cfg(test)]
mod knot_selection_invariance_tests {
use super::select_thin_plate_knots;
use ndarray::Array2;
fn sample_cloud() -> Array2<f64> {
let pts: Vec<[f64; 2]> = vec![
[0.10, 0.20],
[1.30, 0.05],
[2.10, 1.40],
[0.40, 2.30],
[1.90, 2.80],
[3.20, 0.70],
[2.70, 3.10],
[0.90, 1.10],
[3.50, 2.20],
[1.60, 3.60],
[0.05, 3.05],
[2.40, 0.30],
];
let mut a = Array2::<f64>::zeros((pts.len(), 2));
for (i, p) in pts.iter().enumerate() {
a[[i, 0]] = p[0];
a[[i, 1]] = p[1];
}
a
}
fn canonical(knots: &Array2<f64>) -> Vec<(u64, u64)> {
let mut rows: Vec<(u64, u64)> = (0..knots.nrows())
.map(|r| (knots[[r, 0]].to_bits(), knots[[r, 1]].to_bits()))
.collect();
rows.sort_unstable();
rows
}
fn data_centroid_2d(data: &Array2<f64>) -> (f64, f64) {
let n = data.nrows();
let cx = (0..n).map(|i| data[[i, 0]]).sum::<f64>() / n as f64;
let cz = (0..n).map(|i| data[[i, 1]]).sum::<f64>() / n as f64;
(cx, cz)
}
fn rotate_90_about(data: &Array2<f64>, cx: f64, cz: f64) -> Array2<f64> {
let n = data.nrows();
let mut out = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let dx = data[[i, 0]] - cx;
let dz = data[[i, 1]] - cz;
out[[i, 0]] = cx - dz;
out[[i, 1]] = cz + dx;
}
out
}
#[test]
fn knot_set_is_rotation_invariant_gh1456() {
let data = sample_cloud();
let n = data.nrows();
let num_knots = 5;
assert!(num_knots < n, "must exercise the farthest-point selector");
let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
assert_eq!(knots.nrows(), num_knots);
let (cx, cz) = data_centroid_2d(&data);
let rotated = rotate_90_about(&data, cx, cz);
let knots_rot = select_thin_plate_knots(rotated.view(), num_knots).expect("select rotated");
let knots_then_rotate = rotate_90_about(&knots, cx, cz);
assert_eq!(
canonical(&knots_then_rotate),
canonical(&knots_rot),
"rotating-then-selecting must equal selecting-then-rotating (gh#1456); \
the OLD lexicographic seed picks a different physical point after rotation"
);
}
#[test]
fn knot_set_is_row_permutation_invariant_gh1378() {
let data = sample_cloud();
let n = data.nrows();
let num_knots = 5;
assert!(num_knots < n, "must exercise the farthest-point selector");
let knots = select_thin_plate_knots(data.view(), num_knots).expect("select knots");
let perm: Vec<usize> = vec![7, 0, 11, 3, 9, 1, 5, 10, 2, 8, 4, 6];
assert_eq!(perm.len(), n);
let mut permuted = Array2::<f64>::zeros((n, 2));
for (new_row, &old_row) in perm.iter().enumerate() {
permuted[[new_row, 0]] = data[[old_row, 0]];
permuted[[new_row, 1]] = data[[old_row, 1]];
}
let knots_perm =
select_thin_plate_knots(permuted.view(), num_knots).expect("select permuted");
assert_eq!(
canonical(&knots),
canonical(&knots_perm),
"reordering rows must not change the selected knot set (gh#1378)"
);
}
#[test]
fn symmetric_nonseed_orbit_is_completed_atomically() {
let data = ndarray::array![[0.0, 0.0], [0.0, 0.0], [0.0, 1.0], [0.0, -1.0]];
let permutations = [[0_usize, 1, 2, 3], [0, 1, 3, 2], [2, 0, 3, 1], [3, 1, 2, 0]];
let mut reference = None;
for order in permutations {
let permuted = Array2::from_shape_fn((4, 2), |(row, col)| data[[order[row], col]]);
let knots = select_thin_plate_knots(permuted.view(), 3)
.expect("origin plus the complete endpoint orbit fits the budget");
let center_set = canonical(&knots);
if let Some(expected) = reference.as_ref() {
assert_eq!(¢er_set, expected);
} else {
reference = Some(center_set);
}
}
}
#[test]
fn incomplete_nonseed_orbit_is_capped_not_refused() {
let data = ndarray::array![[0.0, 0.0], [0.0, 0.0], [0.0, 1.0], [0.0, -1.0]];
let knots = select_thin_plate_knots(data.view(), 2)
.expect("an oversized orbit must be capped, never refused");
assert_eq!(knots.nrows(), 2, "capped selection must honour the budget");
assert_eq!(
canonical(&knots),
canonical(&ndarray::array![[0.0, 0.0], [0.0, 1.0]]),
"seed plus the lowest-row endpoint of the tied orbit"
);
}
#[test]
fn seed_orbit_larger_than_budget_is_capped_not_refused() {
let data = ndarray::array![[-1.0, 0.0], [1.0, 0.0]];
let knots = select_thin_plate_knots(data.view(), 1)
.expect("an antipodal seed orbit must be capped, never refused");
assert_eq!(knots.nrows(), 1, "capped selection must honour the budget");
assert_eq!(
canonical(&knots),
canonical(&ndarray::array![[-1.0, 0.0]]),
"lowest-row member of the antipodal seed orbit"
);
}
#[test]
fn regular_grid_fits_every_budget_with_distinct_centers() {
let side = 7usize;
let grid = Array2::from_shape_fn((side * side, 2), |(row, col)| {
let (ix, iy) = (row % side, row / side);
if col == 0 { ix as f64 } else { iy as f64 }
});
for k in 1..=side * side {
let knots = select_thin_plate_knots(grid.view(), k)
.unwrap_or_else(|e| panic!("grid must fit k={k}, got: {e}"));
assert_eq!(knots.nrows(), k, "grid selection must honour budget k={k}");
let mut set = canonical(&knots);
let full = set.len();
set.dedup();
assert_eq!(set.len(), full, "duplicate centers at k={k}");
}
}
#[test]
fn capping_preserves_rotation_equivariance_on_generic_cloud() {
let data = sample_cloud();
let num_knots = 9.min(data.nrows() - 1);
let (cx, cz) = data_centroid_2d(&data);
let knots = select_thin_plate_knots(data.view(), num_knots).expect("base select");
let rotated = rotate_90_about(&data, cx, cz);
let knots_rot = select_thin_plate_knots(rotated.view(), num_knots).expect("rotated select");
assert_eq!(
canonical(&rotate_90_about(&knots, cx, cz)),
canonical(&knots_rot),
"capping change must not break rotation equivariance on generic data"
);
}
}
#[cfg(test)]
mod duchon_operator_gate_tests {
use super::{DuchonOperatorPenaltySpec, OperatorPenaltySpec};
#[test]
fn default_duchon_operator_penalties_are_active() {
let default_spec = DuchonOperatorPenaltySpec::default();
assert!(
default_spec.has_active_operator_penalty(),
"default Duchon terms must bypass the native-only fused radial path"
);
assert!(
matches!(default_spec.mass, OperatorPenaltySpec::Active { .. })
&& matches!(default_spec.tension, OperatorPenaltySpec::Active { .. })
&& matches!(default_spec.stiffness, OperatorPenaltySpec::Disabled),
"the default is mass+tension active with stiffness disabled"
);
}
#[test]
fn all_disabled_duchon_operator_penalties_are_native_only() {
let native_only = DuchonOperatorPenaltySpec::all_disabled();
assert!(
!native_only.has_active_operator_penalty(),
"all_disabled() is the explicit native-Gram-only configuration"
);
}
}
#[cfg(test)]
mod retained_radial_indices_tests {
use super::thin_plate_retained_radial_indices;
use ndarray::Array1;
#[test]
fn linear_data_spectrum_keeps_every_mode() {
let evals = Array1::from_vec(vec![
885.4, 119.98, 26.287, 10.030, 5.066, 2.330, 1.3953, 0.67709, 0.46814, 0.34210,
0.26488, 0.17895, 0.14514,
]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(
keep.len(),
evals.len(),
"all numerically real modes must be retained"
);
}
#[test]
fn lidar_spectrum_keeps_every_real_mode() {
let evals = Array1::from_vec(vec![
1212.2, 144.94, 37.270, 15.529, 6.0768, 3.5845, 1.8094, 1.1058, 0.73002, 0.43701,
0.33814, 0.23136, 0.18267, 0.15702, 0.13654, 0.044936, 0.041844, 0.038235,
]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(keep.len(), evals.len(), "every above-floor mode is kept");
}
#[test]
fn pure_roundoff_modes_are_dropped() {
let big = 1.0e3;
let dust = 0.1 * 5.0 * f64::EPSILON * big; let evals = Array1::from_vec(vec![big, 100.0, 10.0, 1.0, dust]);
let keep = thin_plate_retained_radial_indices(&evals);
assert_eq!(keep.len(), 4, "the sub-floor roundoff mode must be pruned");
assert!(!keep.contains(&4));
}
#[test]
fn empty_and_singleton_spectra_are_handled() {
assert!(thin_plate_retained_radial_indices(&Array1::from_vec(vec![])).is_empty());
assert_eq!(
thin_plate_retained_radial_indices(&Array1::from_vec(vec![5.0])),
vec![0]
);
}
}
#[cfg(test)]
mod gc_spectrum_diag_1757_tests {
use super::{select_thin_plate_knots, thin_plate_kernel_from_dist2};
use crate::basis::default_num_centers;
use faer::Side;
use gam_linalg::faer_ndarray::FaerEigh;
use ndarray::Array2;
fn scatter(n: usize, seed: u64) -> Array2<f64> {
let mut s = seed ^ 0x9e37_79b9_7f4a_7c15;
let mut next = || {
s = s.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^= z >> 31;
((z >> 11) as f64) / ((1u64 << 53) as f64) };
let mut x = Array2::<f64>::zeros((n, 2));
for i in 0..n {
x[[i, 0]] = 2.0 * next() - 1.0;
x[[i, 1]] = 2.0 * next() - 1.0;
}
x
}
fn report(label: &str, n: usize, seed: u64) -> (usize, usize, f64) {
let x = scatter(n, seed);
let k = default_num_centers(n, 2);
let knots = select_thin_plate_knots(x.view(), k).expect("knot selection");
let kk = knots.nrows();
let mut kdes = Array2::<f64>::zeros((n, kk));
for i in 0..n {
for c in 0..kk {
let dx = x[[i, 0]] - knots[[c, 0]];
let dy = x[[i, 1]] - knots[[c, 1]];
let d2 = dx * dx + dy * dy;
kdes[[i, c]] = thin_plate_kernel_from_dist2(d2, 2).expect("kernel");
}
}
let gc = kdes.t().dot(&kdes); let (evals, _evecs) = FaerEigh::eigh(&gc, Side::Lower).expect("eigh");
let mut ev: Vec<f64> = evals.iter().copied().filter(|v| *v > 0.0).collect();
ev.sort_by(|a, b| b.partial_cmp(a).unwrap()); let m = ev.len();
if m == 0 {
eprintln!("[GC-DIAG-1757] {label} n={n}: empty spectrum");
return (0, kk, f64::INFINITY);
}
let lam_max = ev[0];
let eps_floor = (kk as f64) * f64::EPSILON * lam_max; let eps_kept = ev.iter().filter(|v| **v > eps_floor).count();
let count_rel = |t: f64| ev.iter().filter(|v| **v / lam_max > t).count();
let mut best_gap = 0.0_f64;
let mut gap_keep = m;
for j in 0..m - 1 {
let g = (ev[j] / ev[j + 1]).ln();
if g > best_gap {
best_gap = g;
gap_keep = j + 1;
}
}
eprintln!(
"[GC-DIAG-1757] {label} n={n} k_req={k} kk={kk} p={m} cond={:.2e} eps_kept={eps_kept} eigengap_keep={gap_keep} log_gap={:.2} | #rel> 1e-2:{} 1e-4:{} 1e-6:{} 1e-8:{} 1e-10:{}",
lam_max / ev[m - 1],
best_gap,
count_rel(1e-2),
count_rel(1e-4),
count_rel(1e-6),
count_rel(1e-8),
count_rel(1e-10),
);
let sampled: Vec<String> = (0..m)
.step_by((m / 20).max(1))
.map(|i| format!("{:.1}", (ev[i] / lam_max).log10()))
.collect();
eprintln!(
"[GC-DIAG-1757] {label} log10(rel eigenvalue) sampled: {}",
sampled.join(" ")
);
(m, kk, lam_max / ev[m - 1])
}
#[test]
fn gc_spectrum_duchon_thinplate_repro_sizes() {
for (label, n, seed) in [
("duchon_n500", 500usize, 42u64),
("duchon_n1220", 1220, 43),
("thinplate_n1200", 1200, 7),
] {
let (p, kk, cond) = report(label, n, seed);
assert!(
p >= 1 && p <= kk,
"{label}: positive-eigenvalue count {p} must be in 1..={kk}"
);
assert!(
cond.is_finite() && cond >= 1.0,
"{label}: condition number {cond} must be finite and >= 1"
);
}
}
}
#[cfg(test)]
mod range_floor_psi_jet_tests {
use super::*;
use ndarray::Array2;
fn sym_from(seed: &[f64], n: usize) -> Array2<f64> {
let mut m = Array2::<f64>::zeros((n, n));
let mut k = 0usize;
for i in 0..n {
for j in 0..=i {
m[[i, j]] = seed[k];
m[[j, i]] = seed[k];
k += 1;
}
}
m
}
fn omega_at(psi: f64) -> (Array2<f64>, Array2<f64>, Array2<f64>) {
let n = 5usize;
let seed = sym_from(
&[
1.0, 0.3, 0.9, -0.2, 0.4, 1.1, 0.15, -0.25, 0.35, 0.8, 0.05, 0.2, -0.1, 0.3, 0.95,
],
n,
);
let (_evals, u) = FaerEigh::eigh(&seed, Side::Lower).expect("seed eigh");
let d = [1.0_f64, 0.08, 0.006, 5.0e-10, 5.0e-11];
let mut base = Array2::<f64>::zeros((n, n));
for i in 0..n {
for j in 0..n {
let mut acc = 0.0;
for k in 0..n {
acc += u[[i, k]] * d[k] * u[[j, k]];
}
base[[i, j]] = acc;
}
}
let scale = 1.0e-3;
let b = sym_from(
&[
0.7, -0.2, 0.5, 0.1, -0.3, 0.4, 0.05, 0.2, -0.1, 0.6, 0.02, -0.04, 0.03, 0.08,
-0.05,
],
n,
)
.mapv(|v| v * scale);
let c = sym_from(
&[
0.2, 0.1, -0.15, 0.05, 0.2, -0.1, 0.03, -0.02, 0.04, 0.1, 0.01, 0.02, -0.03, 0.05,
0.02,
],
n,
)
.mapv(|v| v * scale);
let omega = &base + &b.mapv(|v| v * psi) + &c.mapv(|v| v * 0.5 * psi * psi);
let omega_psi = &b + &c.mapv(|v| v * psi);
(omega, omega_psi, c)
}
#[test]
fn range_floor_psi_jet_matches_central_differences() {
let dim = 8usize; let (o0, b0, c0) = omega_at(0.0);
let jet =
duchon_range_floor_curvature_psi_jet(&o0, &b0, &c0, dim).expect("range-floor psi jet");
let direct = duchon_range_floor_curvature(&o0, dim).expect("range floor");
let val_err = (&jet.value - &direct)
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt();
assert!(
val_err < 1e-10,
"range-floor value mismatch vs standalone: {val_err:.3e}"
);
let floor_gap = (&jet.value - &symmetrize_penalty(&o0))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
assert!(
floor_gap > 0.0,
"range floor is not active — test is vacuous"
);
let eps = 1e-6;
let (op, _, _) = omega_at(eps);
let (om, _, _) = omega_at(-eps);
let vp = duchon_range_floor_curvature_psi_jet(&op, &b0, &c0, dim)
.unwrap()
.value;
let vm = duchon_range_floor_curvature_psi_jet(&om, &b0, &c0, dim)
.unwrap()
.value;
let fd_first = (&vp - &vm).mapv(|v| v / (2.0 * eps));
let first_err = (&jet.first - &fd_first)
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt();
let first_scale = jet
.first
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt()
.max(1e-9);
assert!(
first_err / first_scale < 1e-4,
"range-floor first derivative mismatch: rel={:.3e} (err={first_err:.3e})",
first_err / first_scale
);
let (op2, bp2, cp2) = omega_at(eps);
let (om2, bm2, cm2) = omega_at(-eps);
let fp = duchon_range_floor_curvature_psi_jet(&op2, &bp2, &cp2, dim)
.unwrap()
.first;
let fm = duchon_range_floor_curvature_psi_jet(&om2, &bm2, &cm2, dim)
.unwrap()
.first;
let fd_second = (&fp - &fm).mapv(|v| v / (2.0 * eps));
let second_err = (&jet.second - &fd_second)
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt();
let second_scale = jet
.second
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt()
.max(1e-9);
assert!(
second_err / second_scale < 1e-3,
"range-floor second derivative mismatch: rel={:.3e} (err={second_err:.3e})",
second_err / second_scale
);
}
#[test]
fn duchon_range_floor_sits_the_stated_margin_above_the_embedded_rank_cutoff() {
let n = 3usize;
let embedded = 100usize;
let mut omega = Array2::<f64>::zeros((n, n));
omega[[0, 0]] = 1.0;
omega[[1, 1]] = 1.0e-14;
omega[[2, 2]] = 1.0e-15;
let floored = duchon_range_floor_curvature(&omega, embedded)
.expect("a finite PSD diagonal must range-floor");
let (evals, _) =
FaerEigh::eigh(&floored, Side::Lower).expect("floored block must eigendecompose");
let cutoff = spectral_tolerance_for_dim(embedded.max(n), &evals);
let expected = RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF * cutoff;
let lam_max = evals.iter().copied().fold(0.0_f64, |a, v| a.max(v.abs()));
let min_eval = evals.iter().copied().fold(f64::INFINITY, f64::min);
let envelope = 2.0 * (n as f64) * f64::EPSILON * lam_max;
assert!(
(min_eval - expected).abs() <= envelope,
"range floor {min_eval:.17e} is not {RANGE_FLOOR_ABOVE_SPECTRAL_RANK_CUTOFF}x the \
embedded-dimension rank cutoff {cutoff:.17e} (expected {expected:.17e}, \
envelope {envelope:.3e})"
);
assert!(
evals.iter().all(|&v| v > cutoff),
"range-floored spectrum still holds a sub-cutoff (null-classified) mode: {evals:?}"
);
}
}