use super::*;
#[derive(Clone, Copy)]
pub(crate) struct SendPtr(pub(crate) *mut f64);
unsafe impl Send for SendPtr {}
unsafe impl Sync for SendPtr {}
impl SendPtr {
#[inline(always)]
pub(crate) fn add(self, offset: usize) -> *mut f64 {
unsafe { self.0.add(offset) }
}
}
#[derive(Error, Debug)]
pub enum BasisError {
#[error("Spline degree must be at least 1, but was {0}.")]
InvalidDegree(usize),
#[error(
"Spline degree {degree} is too low for derivative order {derivative_order}; need degree >= {minimum_degree}."
)]
InsufficientDegreeForDerivative {
degree: usize,
derivative_order: usize,
minimum_degree: usize,
},
#[error("Data range is invalid: start ({0}) must be less than or equal to end ({1}).")]
InvalidRange(f64, f64),
#[error(
"Data range has zero width (min equals max), which collapses the B-spline knot domain; requested {0} internal knots."
)]
DegenerateRange(usize),
#[error(
"Penalty order ({order}) must be positive and less than the number of basis functions ({num_basis})."
)]
InvalidPenaltyOrder { order: usize, num_basis: usize },
#[error(
"Insufficient knots for degree {degree} spline: need at least {required} knots but only {provided} were provided."
)]
InsufficientKnotsForDegree {
degree: usize,
required: usize,
provided: usize,
},
#[error(
"Cannot apply sum-to-zero constraint: requires at least 2 basis functions, but only {found} were provided."
)]
InsufficientColumnsForConstraint { found: usize },
#[error(
"Constraint matrix must have the same number of rows as the basis: basis has {basisrows}, constraint has {constraintrows}."
)]
ConstraintMatrixRowMismatch {
basisrows: usize,
constraintrows: usize,
},
#[error(
"Weights dimension mismatch: expected {expected} weights to match basis matrix rows, but got {found}."
)]
WeightsDimensionMismatch { expected: usize, found: usize },
#[error("QR decomposition failed while applying constraints: {0}")]
LinalgError(#[from] FaerLinalgError),
#[error(
"Failed to identify a constraint nullspace basis at {site}: \
coefficient dim {coeff_dim}, cross-rank {cross_rank}, \
constraint Frobenius {cross_frobenius:.3e}, \
constrained Gram spectrum {gram_spectrum}. \
The smooth basis collapses onto the parametric block — typical causes: \
(a) the smooth's evaluated kernel underflows after projecting out the \
polynomial nullspace, leaving only floating-point noise (Duchon hybrid \
in moderate-to-high d with length_scale near pairwise center distances); \
(b) the parametric block already spans the smooth's column space \
(over-restrictive identifiability constraint); \
(c) the smooth has effective rank ≤ parametric-block size on this data."
)]
ConstraintNullspaceCollapsed {
site: &'static str,
cross_rank: usize,
coeff_dim: usize,
cross_frobenius: f64,
gram_spectrum: String,
},
#[error(
"Knot vector is degenerate: all Greville abscissae are equal, so linear constraint cannot be applied."
)]
DegenerateKnots,
#[error(
"The provided knot vector is invalid: {0}. It must be non-decreasing and contain only finite values."
)]
InvalidKnotVector(String),
#[error("Failed to build sparse basis matrix: {0}")]
SparseCreation(String),
#[error("Dimension mismatch: {0}")]
DimensionMismatch(String),
#[error(
"Indefinite penalty matrix in {context}: minimum eigenvalue {min_eigenvalue:.3e} is below tolerance {tolerance:.3e}. {guidance}"
)]
IndefinitePenalty {
context: String,
min_eigenvalue: f64,
tolerance: f64,
guidance: String,
},
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("{0}")]
DenseDerivativeMaterializationRefused(String),
#[error(
"Radial basis derivative is undefined at center collision (r = 0) for {kernel} \
with dim = {dim}, m = {m}: {message}. The first/second derivative of the \
underlying φ(r) does not have a finite limit as r → 0+, so the design-row \
gradient and Hessian have no well-defined value at coincident points."
)]
DegenerateAtCollision {
kernel: &'static str,
dim: usize,
m: f64,
message: &'static str,
},
#[error(
"Periodic radial basis derivative is undefined at the wrap branch cut \
(signed displacement = ±period/2) for raw delta = {raw}, period = {period}: \
the wrapped displacement jumps between ±period/2 and the first derivative \
w.r.t. the input has a one-sided discontinuity. Move the evaluation point \
off the branch cut or define a one-sided convention."
)]
PeriodicWrapBranchCut { raw: f64, period: f64 },
#[error("{0}")]
Other(String),
}
#[derive(Clone, Copy, Debug, Default)]
pub struct BasisOptions {
pub derivative_order: usize,
pub basis_family: BasisFamily,
}
impl BasisOptions {
pub const fn value() -> Self {
Self {
derivative_order: 0,
basis_family: BasisFamily::BSpline,
}
}
pub const fn first_derivative() -> Self {
Self {
derivative_order: 1,
basis_family: BasisFamily::BSpline,
}
}
pub const fn second_derivative() -> Self {
Self {
derivative_order: 2,
basis_family: BasisFamily::BSpline,
}
}
pub const fn m_spline() -> Self {
Self {
derivative_order: 0,
basis_family: BasisFamily::MSpline,
}
}
pub const fn i_spline() -> Self {
Self {
derivative_order: 0,
basis_family: BasisFamily::ISpline,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BasisFamily {
#[default]
BSpline,
MSpline,
ISpline,
}
#[derive(Clone, Debug)]
pub enum KnotSource<'a> {
Provided(ArrayView1<'a, f64>),
Generate {
data_range: (f64, f64),
num_internal_knots: usize,
},
}
#[derive(Debug, Clone)]
pub struct ThinPlateSplineBasis {
pub basis: Array2<f64>,
pub penalty_bending: Array2<f64>,
pub penalty_ridge: Array2<f64>,
pub num_kernel_basis: usize,
pub num_polynomial_basis: usize,
pub dimension: usize,
pub radial_reparam: Array2<f64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum MaternNu {
Half,
ThreeHalves,
FiveHalves,
SevenHalves,
NineHalves,
}
impl MaternNu {
pub const fn half_integer_value(self) -> f64 {
match self {
MaternNu::Half => 0.5,
MaternNu::ThreeHalves => 1.5,
MaternNu::FiveHalves => 2.5,
MaternNu::SevenHalves => 3.5,
MaternNu::NineHalves => 4.5,
}
}
}
#[derive(Debug, Clone)]
pub struct MaternSplineBasis {
pub basis: Array2<f64>,
pub penalty_kernel: Array2<f64>,
pub penalty_ridge: Array2<f64>,
pub num_kernel_basis: usize,
pub num_polynomial_basis: usize,
pub dimension: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct DuchonBasisDesign {
pub(crate) basis: Array2<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum OneDimensionalBoundary {
#[default]
Open,
Cyclic { start: f64, end: f64 },
}
impl OneDimensionalBoundary {
pub(crate) fn period(&self) -> Option<(f64, f64, f64)> {
match *self {
OneDimensionalBoundary::Open => None,
OneDimensionalBoundary::Cyclic { start, end } if end > start => {
Some((start, end, end - start))
}
OneDimensionalBoundary::Cyclic { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BSplineKnotSpec {
Generate {
data_range: (f64, f64),
num_internal_knots: usize,
},
PeriodicUniform {
data_range: (f64, f64),
num_basis: usize,
},
Automatic {
num_internal_knots: Option<usize>,
placement: BSplineKnotPlacement,
},
Provided(Array1<f64>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BSplineKnotPlacement {
Uniform,
Quantile,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BSplineBasisSpec {
pub degree: usize,
pub penalty_order: usize,
pub knotspec: BSplineKnotSpec,
pub double_penalty: bool,
pub identifiability: BSplineIdentifiability,
#[serde(default)]
pub boundary: OneDimensionalBoundary,
#[serde(default)]
pub boundary_conditions: BSplineBoundaryConditions,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub enum BSplineEndpointBoundaryCondition {
#[default]
Free,
Clamped,
Anchored { value: f64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct BSplineBoundaryConditions {
#[serde(default)]
pub left: BSplineEndpointBoundaryCondition,
#[serde(default)]
pub right: BSplineEndpointBoundaryCondition,
}
impl BSplineBoundaryConditions {
pub const fn is_free(&self) -> bool {
matches!(self.left, BSplineEndpointBoundaryCondition::Free)
&& matches!(self.right, BSplineEndpointBoundaryCondition::Free)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BSplineIdentifiability {
None,
WeightedSumToZero { weights: Option<Array1<f64>> },
RemoveLinearTrend,
OrthogonalToDesignColumns {
columns: Array2<f64>,
weights: Option<Array1<f64>>,
},
FrozenTransform { transform: Array2<f64> },
}
impl Default for BSplineIdentifiability {
fn default() -> Self {
BSplineIdentifiability::WeightedSumToZero { weights: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CenterStrategy {
Auto(Box<CenterStrategy>),
UserProvided(Array2<f64>),
EqualMass {
num_centers: usize,
},
EqualMassCovarRepresentative {
num_centers: usize,
},
FarthestPoint {
num_centers: usize,
},
KMeans {
num_centers: usize,
max_iter: usize,
},
UniformGrid {
points_per_dim: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CenterStrategyKind {
UserProvided,
EqualMass,
EqualMassCovarRepresentative,
FarthestPoint,
KMeans,
UniformGrid,
}
pub fn default_num_centers(n: usize, d: usize) -> usize {
const K_MIN: usize = 200;
const K_MAX: usize = 2000;
const ALPHA: f64 = 0.4;
const C: f64 = 8.0;
const PER_DIM_GROWTH: f64 = 0.15;
const FLOOR_N_DIVISOR: usize = 8;
const COND_N_DIVISOR: usize = 4;
let d_factor = 1.0 + PER_DIM_GROWTH * (d.max(1) - 1) as f64;
let raw = (C * d_factor * (n as f64).powf(ALPHA)).ceil() as usize;
let floor = K_MIN.min(n / FLOOR_N_DIVISOR);
let k = raw.clamp(floor, K_MAX);
k.min(n).min(n / COND_N_DIVISOR)
}
pub fn conservative_secondary_centers(n: usize, d: usize) -> usize {
const BASE_1D_CENTERS: usize = 10;
let modest = BASE_1D_CENTERS.saturating_mul(d.max(1));
default_num_centers(n, d).min(modest).max(1)
}
#[derive(Clone, Debug)]
pub struct SpatialBasisPlan {
pub n: usize,
pub d: usize,
pub centers: usize,
pub p_final_estimate: usize,
pub dense_design_bytes: usize,
pub first_derivative_dense_bytes: usize,
pub second_derivative_dense_bytes: usize,
pub recommended_storage: SpatialStorageMode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpatialStorageMode {
DenseValueDenseDerivatives,
LazyValueImplicitDerivatives,
OperatorOnly,
}
#[derive(Clone, Copy, Debug)]
pub enum CenterCountRequest {
Default,
Explicit(usize),
HeuristicCapped { cap: usize },
}
pub fn plan_spatial_basis(
n: usize,
d: usize,
requested_centers: CenterCountRequest,
nullspace_order: DuchonNullspaceOrder,
scale_dims: bool,
policy: &crate::resource::ResourcePolicy,
) -> Result<SpatialBasisPlan, BasisError> {
if n == 0 {
crate::bail_invalid_basis!("plan_spatial_basis: n must be >= 1");
}
if d == 0 {
crate::bail_invalid_basis!("plan_spatial_basis: d must be >= 1");
}
let centers = match requested_centers {
CenterCountRequest::Default => default_num_centers(n, d),
CenterCountRequest::Explicit(k) => k,
CenterCountRequest::HeuristicCapped { cap } => default_num_centers(n, d).min(cap),
};
let m = duchon_p_from_nullspace_order(nullspace_order);
let nullspace_dim = if m == 0 {
0
} else {
duchon_nullspace_dimension(d, m - 1)
};
let p = centers.saturating_add(nullspace_dim);
let derivative_axes = if scale_dims { d } else { 0 };
let bytes_per_f64 = std::mem::size_of::<f64>();
let dense_design_bytes = bytes_per_f64.saturating_mul(n).saturating_mul(p);
let first_derivative_dense_bytes = dense_design_bytes.saturating_mul(derivative_axes);
let second_derivative_dense_bytes = first_derivative_dense_bytes;
let recommended_storage = match policy.derivative_storage_mode {
crate::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
SpatialStorageMode::OperatorOnly
}
crate::resource::DerivativeStorageMode::MaterializeIfSmall => {
let budget = policy.max_single_materialization_bytes;
if derivative_axes == 0 {
if dense_design_bytes <= budget {
SpatialStorageMode::DenseValueDenseDerivatives
} else {
SpatialStorageMode::LazyValueImplicitDerivatives
}
} else {
let total = dense_design_bytes
.saturating_add(first_derivative_dense_bytes)
.saturating_add(second_derivative_dense_bytes);
if total <= budget {
SpatialStorageMode::DenseValueDenseDerivatives
} else if dense_design_bytes <= budget {
SpatialStorageMode::LazyValueImplicitDerivatives
} else {
SpatialStorageMode::OperatorOnly
}
}
}
crate::resource::DerivativeStorageMode::DiagnosticsOnly => {
SpatialStorageMode::OperatorOnly
}
};
Ok(SpatialBasisPlan {
n,
d,
centers,
p_final_estimate: p,
dense_design_bytes,
first_derivative_dense_bytes,
second_derivative_dense_bytes,
recommended_storage,
})
}
pub const fn default_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
if d <= 3 {
CenterStrategy::FarthestPoint { num_centers }
} else {
CenterStrategy::EqualMassCovarRepresentative { num_centers }
}
}
pub fn auto_spatial_center_strategy(num_centers: usize, d: usize) -> CenterStrategy {
let strategy = if d == 1 {
CenterStrategy::FarthestPoint { num_centers }
} else {
default_spatial_center_strategy(num_centers, d)
};
CenterStrategy::Auto(Box::new(strategy))
}
pub const fn center_strategy_is_auto(strategy: &CenterStrategy) -> bool {
matches!(strategy, CenterStrategy::Auto(_))
}
pub(crate) fn realized_center_strategy(strategy: &CenterStrategy) -> &CenterStrategy {
match strategy {
CenterStrategy::Auto(inner) => inner.as_ref(),
other => other,
}
}
pub fn center_strategy_kind(strategy: &CenterStrategy) -> CenterStrategyKind {
match strategy {
CenterStrategy::Auto(inner) => center_strategy_kind(inner.as_ref()),
CenterStrategy::UserProvided(_) => CenterStrategyKind::UserProvided,
CenterStrategy::EqualMass { .. } => CenterStrategyKind::EqualMass,
CenterStrategy::EqualMassCovarRepresentative { .. } => {
CenterStrategyKind::EqualMassCovarRepresentative
}
CenterStrategy::FarthestPoint { .. } => CenterStrategyKind::FarthestPoint,
CenterStrategy::KMeans { .. } => CenterStrategyKind::KMeans,
CenterStrategy::UniformGrid { .. } => CenterStrategyKind::UniformGrid,
}
}
pub fn center_strategy_num_centers(strategy: &CenterStrategy) -> Option<usize> {
match strategy {
CenterStrategy::Auto(inner) => center_strategy_num_centers(inner.as_ref()),
CenterStrategy::UserProvided(centers) => Some(centers.nrows()),
CenterStrategy::EqualMass { num_centers }
| CenterStrategy::EqualMassCovarRepresentative { num_centers }
| CenterStrategy::FarthestPoint { num_centers }
| CenterStrategy::KMeans { num_centers, .. } => Some(*num_centers),
CenterStrategy::UniformGrid { .. } => None,
}
}
pub fn center_strategy_with_num_centers(
strategy: &CenterStrategy,
num_centers: usize,
) -> Result<CenterStrategy, BasisError> {
validate_center_count(num_centers)?;
fn rebuild_inner(
strategy: &CenterStrategy,
num_centers: usize,
) -> Result<CenterStrategy, BasisError> {
match strategy {
CenterStrategy::Auto(inner) => rebuild_inner(inner.as_ref(), num_centers),
CenterStrategy::EqualMass { .. } => Ok(CenterStrategy::EqualMass { num_centers }),
CenterStrategy::EqualMassCovarRepresentative { .. } => {
Ok(CenterStrategy::EqualMassCovarRepresentative { num_centers })
}
CenterStrategy::FarthestPoint { .. } => {
Ok(CenterStrategy::FarthestPoint { num_centers })
}
CenterStrategy::KMeans { max_iter, .. } => Ok(CenterStrategy::KMeans {
num_centers,
max_iter: *max_iter,
}),
CenterStrategy::UserProvided(_) | CenterStrategy::UniformGrid { .. } => {
Err(BasisError::InvalidInput(format!(
"cannot replace center count for {:?} strategy",
center_strategy_kind(strategy)
)))
}
}
}
let rebuilt = rebuild_inner(strategy, num_centers)?;
Ok(match strategy {
CenterStrategy::Auto(_) => CenterStrategy::Auto(Box::new(rebuilt)),
_ => rebuilt,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinPlateBasisSpec {
pub center_strategy: CenterStrategy,
#[serde(default)]
pub periodic: Option<Vec<Option<f64>>>,
pub length_scale: f64,
pub double_penalty: bool,
#[serde(default)]
pub identifiability: SpatialIdentifiability,
#[serde(default)]
pub radial_reparam: Option<Array2<f64>>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub enum SpatialIdentifiability {
None,
#[default]
OrthogonalToParametric,
FrozenTransform { transform: Array2<f64> },
}
pub(crate) use sphere_kernels::{
wahba_sphere_kernel_derivative_dcos_kind, wahba_sphere_kernel_from_cos_kind,
wahba_sphere_kernel_from_cos_simd_kind, wahba_sphere_kernel_sobolev_derivative_dcos,
};
pub use sphere_spectral::{
pseudo_s2_truncated_coefficients, sobolev_s2_truncated_coefficients,
sphere_truncated_spectral_eval,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaternBasisSpec {
pub center_strategy: CenterStrategy,
#[serde(default)]
pub periodic: Option<Vec<Option<f64>>>,
pub length_scale: f64,
pub nu: MaternNu,
#[serde(default)]
pub include_intercept: bool,
pub double_penalty: bool,
#[serde(default)]
pub identifiability: MaternIdentifiability,
#[serde(default)]
pub aniso_log_scales: Option<Vec<f64>>,
#[serde(default)]
pub nullspace_shrinkage_survived: Option<bool>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub enum MaternIdentifiability {
None,
#[default]
CenterSumToZero,
CenterLinearOrthogonal,
FrozenTransform {
transform: Array2<f64>,
#[serde(default)]
nullspace_shrinkage_survived: Option<bool>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DuchonNullspaceOrder {
Zero,
Linear,
Degree(usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DuchonBasisSpec {
pub center_strategy: CenterStrategy,
#[serde(default)]
pub periodic: Option<Vec<Option<f64>>>,
pub length_scale: Option<f64>,
pub power: f64,
pub nullspace_order: DuchonNullspaceOrder,
#[serde(default)]
pub identifiability: SpatialIdentifiability,
#[serde(default)]
pub aniso_log_scales: Option<Vec<f64>>,
#[serde(default)]
pub operator_penalties: DuchonOperatorPenaltySpec,
#[serde(default)]
pub boundary: OneDimensionalBoundary,
#[serde(default)]
pub radial_reparam: Option<Array2<f64>>,
}
impl DuchonBasisSpec {
pub fn power_as_usize(&self) -> usize {
duchon_power_to_usize(self.power)
}
}
pub fn duchon_power_to_usize(power: f64) -> usize {
if !power.is_finite() || power < 0.0 {
return 0;
}
let rounded = power.round();
if (rounded - power).abs() > 1e-9 {
return 0;
}
rounded as usize
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DuchonOperatorPenaltySpec {
pub mass: OperatorPenaltySpec,
pub tension: OperatorPenaltySpec,
pub stiffness: OperatorPenaltySpec,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum OperatorPenaltySpec {
Active {
initial_log_lambda: f64,
prior: Option<RhoPrior>,
},
Disabled,
}
impl Default for DuchonOperatorPenaltySpec {
fn default() -> Self {
Self {
mass: OperatorPenaltySpec::Active {
initial_log_lambda: 0.0,
prior: None,
},
tension: OperatorPenaltySpec::Active {
initial_log_lambda: 0.0,
prior: None,
},
stiffness: OperatorPenaltySpec::Disabled,
}
}
}
impl DuchonOperatorPenaltySpec {
pub fn all_disabled() -> Self {
Self {
mass: OperatorPenaltySpec::Disabled,
tension: OperatorPenaltySpec::Disabled,
stiffness: OperatorPenaltySpec::Disabled,
}
}
pub fn all_active() -> Self {
let active = || OperatorPenaltySpec::Active {
initial_log_lambda: 0.0,
prior: None,
};
Self {
mass: active(),
tension: active(),
stiffness: active(),
}
}
pub fn matern_for_smoothness(nu: MaternNu, d: usize) -> Self {
let m = nu.half_integer_value() + 0.5 * d as f64;
const ORDER_EPS: f64 = 1e-9;
let active = || OperatorPenaltySpec::Active {
initial_log_lambda: 0.0,
prior: None,
};
let gate = |order: f64| {
if m > order + ORDER_EPS {
active()
} else {
OperatorPenaltySpec::Disabled
}
};
Self {
mass: active(),
tension: gate(1.0),
stiffness: gate(2.0),
}
}
}
pub fn minimum_duchon_power_for_operator_penalties(
dim: usize,
nullspace_order: DuchonNullspaceOrder,
max_operator_derivative_order: usize,
) -> usize {
let p = duchon_p_from_nullspace_order(nullspace_order);
let mut s = 0usize;
while 2 * (p + s) <= dim + max_operator_derivative_order {
s += 1;
}
s
}
pub fn resolve_duchon_orders(
dim: usize,
requested_nullspace_order: DuchonNullspaceOrder,
max_operator_derivative_order: usize,
length_scale: Option<f64>,
) -> (DuchonNullspaceOrder, usize) {
assert!(dim >= 1, "Duchon basis requires dim >= 1");
let pure = length_scale.is_none();
let mut nullspace = requested_nullspace_order;
for _ in 0..=(dim + max_operator_derivative_order + 1) {
let p = duchon_p_from_nullspace_order(nullspace);
let s_op = if 2 * p > dim + max_operator_derivative_order {
0
} else {
(dim + max_operator_derivative_order + 2 - 2 * p) / 2
};
if !pure || 2 * s_op < dim {
return (nullspace, s_op);
}
nullspace = duchon_next_nullspace_order(nullspace);
}
(nullspace, 0)
}
#[inline]
pub(crate) fn duchon_next_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
match order {
DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Linear,
DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Degree(2),
DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k + 1),
}
}
pub(crate) fn duchon_previous_nullspace_order(order: DuchonNullspaceOrder) -> DuchonNullspaceOrder {
match order {
DuchonNullspaceOrder::Zero => DuchonNullspaceOrder::Zero,
DuchonNullspaceOrder::Linear => DuchonNullspaceOrder::Zero,
DuchonNullspaceOrder::Degree(2) => DuchonNullspaceOrder::Linear,
DuchonNullspaceOrder::Degree(k) => DuchonNullspaceOrder::Degree(k - 1),
}
}
pub fn duchon_max_active_operator_derivative_order(
operator_penalties: &DuchonOperatorPenaltySpec,
) -> usize {
if matches!(
operator_penalties.stiffness,
OperatorPenaltySpec::Active { .. }
) {
2
} else if matches!(
operator_penalties.tension,
OperatorPenaltySpec::Active { .. }
) {
1
} else {
0
}
}
#[derive(Debug, Clone)]
pub enum BasisMetadata {
BSpline1D {
knots: Array1<f64>,
identifiability_transform: Option<Array2<f64>>,
periodic: Option<(f64, f64, usize)>,
degree: Option<usize>,
auto_shrink_note: Option<String>,
},
ThinPlate {
centers: Array2<f64>,
length_scale: f64,
periodic: Option<Vec<Option<f64>>>,
identifiability_transform: Option<Array2<f64>>,
input_scales: Option<Vec<f64>>,
radial_reparam: Option<Array2<f64>>,
},
Sphere {
centers: Array2<f64>,
penalty_order: usize,
method: SphereMethod,
max_degree: Option<usize>,
wahba_kernel: SphereWahbaKernel,
constraint_transform: Option<Array2<f64>>,
},
ConstantCurvature {
centers: Array2<f64>,
kappa: f64,
length_scale: f64,
constraint_transform: Option<Array2<f64>>,
},
MeasureJet {
centers: Array2<f64>,
input_scales: Option<Vec<f64>>,
length_scale: f64,
eps_band: Vec<f64>,
order_s: f64,
alpha: f64,
tau0: f64,
masses: Array1<f64>,
support_means: Vec<f64>,
penalty_normalization_scales: Vec<f64>,
raw_penalty_normalization_scales: Vec<f64>,
fused_penalty_normalization_scale: Option<f64>,
constraint_transform: Option<Array2<f64>>,
},
Matern {
centers: Array2<f64>,
length_scale: f64,
periodic: Option<Vec<Option<f64>>>,
nu: MaternNu,
include_intercept: bool,
identifiability_transform: Option<Array2<f64>>,
input_scales: Option<Vec<f64>>,
aniso_log_scales: Option<Vec<f64>>,
nullspace_shrinkage_survived: bool,
},
Duchon {
centers: Array2<f64>,
length_scale: Option<f64>,
periodic: Option<Vec<Option<f64>>>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
identifiability_transform: Option<Array2<f64>>,
input_scales: Option<Vec<f64>>,
aniso_log_scales: Option<Vec<f64>>,
operator_collocation_points: Option<Array2<f64>>,
radial_reparam: Option<Array2<f64>>,
},
Pca {
feature_cols: Vec<usize>,
basis_matrix: Array2<f64>,
centered: bool,
smooth_penalty: f64,
center_mean: Option<Array1<f64>>,
pca_basis_path: Option<std::path::PathBuf>,
chunk_size: usize,
},
TensorBSpline {
feature_cols: Vec<usize>,
knots: Vec<Array1<f64>>,
degrees: Vec<usize>,
periods: Vec<Option<f64>>,
identifiability_transform: Option<Array2<f64>>,
},
SphereHarmonics {
max_degree: usize,
radians: bool,
},
BySmooth {
inner: Box<BasisMetadata>,
by_col: usize,
levels: Option<Vec<u64>>,
ordered: bool,
},
FactorSmooth {
continuous_cols: Vec<usize>,
group_col: usize,
knots: Array1<f64>,
degree: usize,
periodic: Option<(f64, f64, usize)>,
group_levels: Vec<u64>,
flavour: String,
},
}
#[derive(Clone)]
pub struct BasisBuildResult {
pub design: DesignMatrix,
pub penalties: Vec<Array2<f64>>,
pub nullspace_dims: Vec<usize>,
pub penaltyinfo: Vec<PenaltyInfo>,
pub metadata: BasisMetadata,
pub kronecker_factored: Option<KroneckerFactoredBasis>,
pub ops: Vec<Option<std::sync::Arc<dyn crate::terms::analytic_penalties::PenaltyOp>>>,
pub null_eigenvectors: Vec<Option<Array2<f64>>>,
pub joint_null_rotation: Option<JointNullRotation>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct JointNullRotation {
pub rotation: Array2<f64>,
pub joint_nullity: usize,
}
impl std::fmt::Debug for JointNullRotation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JointNullRotation")
.field(
"rotation",
&format_args!("{}×{}", self.rotation.nrows(), self.rotation.ncols()),
)
.field("joint_nullity", &self.joint_nullity)
.finish()
}
}
impl std::fmt::Debug for BasisBuildResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BasisBuildResult")
.field("design", &self.design)
.field("penalties", &self.penalties)
.field("nullspace_dims", &self.nullspace_dims)
.field("penaltyinfo", &self.penaltyinfo)
.field("metadata", &self.metadata)
.field("kronecker_factored", &self.kronecker_factored)
.field(
"ops",
&format_args!(
"[{}]",
self.ops
.iter()
.map(|o| if o.is_some() { "Some" } else { "None" })
.collect::<Vec<_>>()
.join(", ")
),
)
.field(
"null_eigenvectors",
&format_args!(
"[{}]",
self.null_eigenvectors
.iter()
.map(|u| match u {
Some(m) => format!("Some({}x{})", m.nrows(), m.ncols()),
None => "None".to_string(),
})
.collect::<Vec<_>>()
.join(", ")
),
)
.field("joint_null_rotation", &self.joint_null_rotation)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct KroneckerFactoredBasis {
pub marginal_designs: Vec<Array2<f64>>,
pub marginal_penalties: Vec<Array2<f64>>,
pub marginal_dims: Vec<usize>,
pub has_double_penalty: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PenaltySource {
Primary,
DoublePenaltyNullspace,
OperatorMass,
OperatorTension,
OperatorStiffness,
OperatorRelevance {
axis: usize,
},
TensorMarginal {
dim: usize,
},
TensorSeparable {
penalized_margins: Vec<usize>,
},
TensorGlobalRidge,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PenaltyDropReason {
ZeroMatrix,
NumericalRankZero,
}
fn default_normalization_scale() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PenaltyInfo {
pub source: PenaltySource,
pub original_index: usize,
pub active: bool,
pub effective_rank: usize,
pub dropped_reason: Option<PenaltyDropReason>,
pub nullspace_dim_hint: usize,
#[serde(default = "default_normalization_scale")]
pub normalization_scale: f64,
#[serde(skip)]
pub kronecker_factors: Option<Vec<Array2<f64>>>,
}
#[derive(Clone)]
pub struct PenaltyCandidate {
pub matrix: Array2<f64>,
pub nullspace_dim_hint: usize,
pub source: PenaltySource,
pub normalization_scale: f64,
pub kronecker_factors: Option<Vec<Array2<f64>>>,
pub op: Option<std::sync::Arc<dyn crate::terms::analytic_penalties::PenaltyOp>>,
}
impl std::fmt::Debug for PenaltyCandidate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PenaltyCandidate")
.field(
"matrix",
&format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
)
.field("nullspace_dim_hint", &self.nullspace_dim_hint)
.field("source", &self.source)
.field("normalization_scale", &self.normalization_scale)
.field(
"kronecker_factors",
&self.kronecker_factors.as_ref().map(|v| v.len()),
)
.field("op", &self.op.as_ref().map(|o| o.dim()))
.finish()
}
}
#[derive(Clone)]
pub struct CanonicalPenaltyBlock {
pub sym_penalty: Array2<f64>,
pub eigenvalues: Array1<f64>,
pub eigenvectors: Array2<f64>,
pub rank: usize,
pub nullity: usize,
pub negative_dim: usize,
pub tol: f64,
pub iszero: bool,
pub op: Option<std::sync::Arc<dyn crate::terms::analytic_penalties::PenaltyOp>>,
}
impl std::fmt::Debug for CanonicalPenaltyBlock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CanonicalPenaltyBlock")
.field(
"sym_penalty",
&format_args!("{}×{}", self.sym_penalty.nrows(), self.sym_penalty.ncols()),
)
.field("eigenvalues", &self.eigenvalues)
.field(
"eigenvectors",
&format_args!(
"{}×{}",
self.eigenvectors.nrows(),
self.eigenvectors.ncols()
),
)
.field("rank", &self.rank)
.field("nullity", &self.nullity)
.field("negative_dim", &self.negative_dim)
.field("tol", &self.tol)
.field("iszero", &self.iszero)
.field("op", &self.op.as_ref().map(|o| o.dim()))
.finish()
}
}
#[derive(Debug)]
pub struct BasisPsiDerivativeResult {
pub design_derivative: Array2<f64>,
pub penalties_derivative: Vec<Array2<f64>>,
pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
}
#[derive(Debug)]
pub struct BasisPsiSecondDerivativeResult {
pub designsecond_derivative: Array2<f64>,
pub penaltiessecond_derivative: Vec<Array2<f64>>,
pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
}
#[derive(Debug)]
pub struct BasisPsiDerivativeBundle {
pub first: BasisPsiDerivativeResult,
pub second: BasisPsiSecondDerivativeResult,
pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
}
#[derive(Clone)]
pub struct AnisoBasisPsiDerivatives {
pub design_first: Vec<Array2<f64>>,
pub design_second_diag: Vec<Array2<f64>>,
pub design_second_cross: Vec<Array2<f64>>,
pub design_second_cross_pairs: Vec<(usize, usize)>,
pub penalties_first: Vec<Vec<Array2<f64>>>,
pub penalties_second_diag: Vec<Vec<Array2<f64>>>,
pub penalties_cross_pairs: Vec<(usize, usize)>,
pub penalties_cross_provider: Option<AnisoPenaltyCrossProvider>,
pub implicit_operator: Option<ImplicitDesignPsiDerivative>,
}
#[derive(Clone)]
pub struct AnisoPenaltyCrossProvider(
std::sync::Arc<
dyn Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
>,
);
impl AnisoPenaltyCrossProvider {
pub(crate) fn new<F>(f: F) -> Self
where
F: Fn(usize, usize) -> Result<Vec<Array2<f64>>, BasisError> + Send + Sync + 'static,
{
Self(std::sync::Arc::new(f))
}
pub fn evaluate(&self, axis_a: usize, axis_b: usize) -> Result<Vec<Array2<f64>>, BasisError> {
(self.0)(axis_a, axis_b)
}
}
pub(crate) const SPATIAL_CENTER_CENTER_MAX_BYTES: usize = 512 * 1024 * 1024; pub(crate) const DESIGN_CROSS_CHUNK_SIZE: usize = 1024;
pub fn should_use_implicit_operators_with_policy(
n: usize,
p: usize,
d: usize,
policy: &crate::resource::ResourcePolicy,
) -> bool {
let dense_bytes = 3usize
.saturating_mul(n)
.saturating_mul(p)
.saturating_mul(d)
.saturating_mul(8);
dense_bytes > policy.max_single_materialization_bytes
}
pub(crate) fn implicit_radial_cache_bytes(n: usize, k: usize, n_axes: usize) -> usize {
n.saturating_mul(k)
.saturating_mul(n_axes.saturating_add(3))
.saturating_mul(8)
}
pub(crate) fn should_cache_implicit_radial_components(
n: usize,
k: usize,
n_axes: usize,
policy: &crate::resource::ResourcePolicy,
) -> bool {
implicit_radial_cache_bytes(n, k, n_axes) <= policy.max_operator_cache_bytes
}
pub fn assert_no_dense_derivative_materialization(n: usize, p: usize, d_pc: usize) {
let first = dense_design_bytes(n, p).saturating_mul(d_pc);
let second = dense_design_bytes(n, p).saturating_mul(d_pc.saturating_mul(d_pc));
let policy = crate::resource::ResourcePolicy::default_library();
let budget = policy.max_single_materialization_bytes;
let needed = first.saturating_add(second);
match policy.derivative_storage_mode {
crate::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
panic!(
"spatial PC Duchon derivative designs must remain operator-backed; refused persistent dense derivative materialization (n={n}, p={p}, d_pc={d_pc}, first_order={:.1} MiB, second_order={:.1} MiB)",
first as f64 / (1024.0 * 1024.0),
second as f64 / (1024.0 * 1024.0),
);
}
crate::resource::DerivativeStorageMode::MaterializeIfSmall
| crate::resource::DerivativeStorageMode::DiagnosticsOnly => {
assert!(
needed <= budget,
"spatial PC Duchon derivative designs would exceed the single-materialization budget; refused persistent dense derivative materialization (n={n}, p={p}, d_pc={d_pc}, first_order={:.1} MiB, second_order={:.1} MiB, budget={:.1} MiB)",
first as f64 / (1024.0 * 1024.0),
second as f64 / (1024.0 * 1024.0),
budget as f64 / (1024.0 * 1024.0),
);
}
}
}
pub fn assert_spatial_centers_below_large_scale_cap(
d_pc: usize,
centers: ArrayView2<'_, f64>,
) -> Result<(), BasisError> {
if centers.ncols() != d_pc {
crate::bail_dim_basis!(
"spatial PC center dimension mismatch: centers have {} columns, expected {d_pc}",
centers.ncols()
);
}
let k = centers.nrows();
let centers_bytes = dense_design_bytes(k, d_pc);
let center_center_bytes = dense_design_bytes(k, k);
if centers_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
crate::bail_invalid_basis!(
"spatial PC centers exceed center storage cap: K={k}, d_pc={d_pc}, centers={:.1} MiB, cap={:.1} MiB",
centers_bytes as f64 / (1024.0 * 1024.0),
SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
);
}
if center_center_bytes > SPATIAL_CENTER_CENTER_MAX_BYTES {
crate::bail_invalid_basis!(
"spatial PC centers exceed center-center large-scale cap: K={k}, d_pc={d_pc}, KxK={:.1} MiB, cap={:.1} MiB",
center_center_bytes as f64 / (1024.0 * 1024.0),
SPATIAL_CENTER_CENTER_MAX_BYTES as f64 / (1024.0 * 1024.0),
);
}
Ok(())
}
pub(crate) fn dense_design_bytes(n: usize, p: usize) -> usize {
n.saturating_mul(p)
.saturating_mul(std::mem::size_of::<f64>())
}
pub(crate) fn should_use_lazy_spatial_design(
n: usize,
p: usize,
policy: &crate::resource::ResourcePolicy,
) -> bool {
dense_design_bytes(n, p) > policy.max_single_materialization_bytes
}
pub(crate) fn wrap_dense_design_with_transform(
design: DesignMatrix,
transform: &Array2<f64>,
label: &str,
) -> Result<DesignMatrix, BasisError> {
match design {
DesignMatrix::Dense(inner) => {
let op = CoefficientTransformOperator::new(inner, transform.clone()).map_err(|e| {
BasisError::InvalidInput(format!("{label} coefficient transform failed: {e}"))
})?;
Ok(DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(
Arc::new(op),
)))
}
DesignMatrix::Sparse(_) => Err(BasisError::InvalidInput(format!(
"{label} coefficient transform requires a dense/operator-backed design"
))),
}
}
pub(crate) fn design_cross_and_gram(
design: &DesignMatrix,
constraint_matrix: ArrayView2<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
) -> Result<(Array2<f64>, Array2<f64>), BasisError> {
let n = design.nrows();
let k = design.ncols();
if constraint_matrix.nrows() != n {
return Err(BasisError::ConstraintMatrixRowMismatch {
basisrows: n,
constraintrows: constraint_matrix.nrows(),
});
}
if let Some(w) = weights
&& w.len() != n
{
return Err(BasisError::WeightsDimensionMismatch {
expected: n,
found: w.len(),
});
}
let q = constraint_matrix.ncols();
let mut cross = Array2::<f64>::zeros((k, q));
let mut gram = Array2::<f64>::zeros((k, k));
for start in (0..n).step_by(DESIGN_CROSS_CHUNK_SIZE) {
let end = (start + DESIGN_CROSS_CHUNK_SIZE).min(n);
let basis_chunk = design
.try_row_chunk(start..end)
.map_err(|e| BasisError::InvalidInput(e.to_string()))?;
let mut constraint_chunk = constraint_matrix.slice(s![start..end, ..]).to_owned();
if let Some(w) = weights {
for (mut row, &weight) in constraint_chunk
.axis_iter_mut(Axis(0))
.zip(w.slice(s![start..end]).iter())
{
row *= weight;
}
}
cross += &fast_atb(&basis_chunk, &constraint_chunk);
gram += &fast_atb(&basis_chunk, &basis_chunk);
}
Ok((cross, gram))
}
pub(crate) fn positive_spectral_whitener_from_gram(
gram: &Array2<f64>,
) -> Result<Array2<f64>, BasisError> {
let (eigenvalues, eigenvectors) = gram.eigh(Side::Lower).map_err(BasisError::LinalgError)?;
let n = gram.nrows();
let max_eval = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
let tol = (default_rrqr_rank_alpha() * f64::EPSILON * (n.max(1) as f64) * max_eval.max(1.0))
.max(f64::EPSILON);
let keep = eigenvalues.iter().filter(|&&ev| ev > tol).count();
if keep == 0 {
let min_ev = eigenvalues.iter().copied().fold(f64::INFINITY, f64::min);
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "positive_spectral_whitener_from_gram",
cross_rank: 0,
coeff_dim: gram.nrows(),
cross_frobenius: gram.iter().map(|v| v * v).sum::<f64>().sqrt(),
gram_spectrum: format!(
"max eigenvalue {max_eval:.3e} (min {min_ev:.3e}, spectral tolerance {tol:.3e})"
),
});
}
let eig_start = eigenvalues.len() - keep;
let kept_vectors = eigenvectors.slice(s![.., eig_start..]).to_owned();
let mut inv_sqrt = Array2::<f64>::zeros((keep, keep));
for (out_i, eig_i) in (eig_start..eigenvalues.len()).enumerate() {
inv_sqrt[[out_i, out_i]] = 1.0 / eigenvalues[eig_i].sqrt();
}
Ok(fast_ab(&kept_vectors, &inv_sqrt))
}
pub(crate) fn stabilized_orthogonality_transform_from_gram(
gram: &Array2<f64>,
transform: &Array2<f64>,
) -> Result<Array2<f64>, BasisError> {
let constrained_gram = {
let gt = fast_ab(gram, transform);
fast_atb(transform, >)
};
let whitening = positive_spectral_whitener_from_gram(&constrained_gram)?;
Ok(fast_ab(transform, &whitening))
}
pub(crate) fn orthogonality_transform_from_cross_and_gram(
constraint_cross: &Array2<f64>,
gram: &Array2<f64>,
) -> Result<Array2<f64>, BasisError> {
let k = constraint_cross.nrows();
if k == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let (transform_raw, rank) = rrqr_nullspace_basis(constraint_cross, default_rrqr_rank_alpha())
.map_err(BasisError::LinalgError)?;
if rank >= k || transform_raw.ncols() == 0 {
return Err(BasisError::ConstraintNullspaceCollapsed {
site: "orthogonality_transform_from_cross_and_gram",
cross_rank: rank,
coeff_dim: k,
cross_frobenius: constraint_cross.iter().map(|v| v * v).sum::<f64>().sqrt(),
gram_spectrum: "not computed (structural cross-rank collapse: null(Mᵀ) is empty, \
so no constrained design exists to eigendecompose)"
.to_string(),
});
}
stabilized_orthogonality_transform_from_gram(gram, &transform_raw)
}
pub(crate) fn orthogonality_transform_for_design(
design: &DesignMatrix,
constraint_matrix: ArrayView2<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
) -> Result<Array2<f64>, BasisError> {
let k = design.ncols();
if k == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
let q = constraint_matrix.ncols();
if q == 0 {
return Ok(Array2::eye(k));
}
let (constraint_cross, gram) = design_cross_and_gram(design, constraint_matrix, weights)?;
orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)
}