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) }
}
}
pub use gam_problem::BasisError;
#[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>),
NaturalCubicRegression {
knots: 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)
}
pub const fn has_anchor(&self) -> bool {
matches!(self.left, BSplineEndpointBoundaryCondition::Anchored { .. })
|| matches!(
self.right,
BSplineEndpointBoundaryCondition::Anchored { .. }
)
}
pub fn has_nonzero_anchor(&self) -> bool {
let nonzero = |condition: BSplineEndpointBoundaryCondition| {
matches!(
condition,
BSplineEndpointBoundaryCondition::Anchored { value } if value != 0.0
)
};
nonzero(self.left) || nonzero(self.right)
}
}
#[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>),
DuchonSpectral {
knots: Box<CenterStrategy>,
basis: DuchonSpectralBasis,
},
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,
},
}
impl CenterStrategy {
pub fn planned_num_centers(&self, d: usize) -> usize {
match self {
Self::Auto(inner) => inner.planned_num_centers(d),
Self::DuchonSpectral { knots, .. } => knots.planned_num_centers(d),
Self::UserProvided(centers) => centers.nrows(),
Self::EqualMass { num_centers }
| Self::EqualMassCovarRepresentative { num_centers }
| Self::FarthestPoint { num_centers }
| Self::KMeans { num_centers, .. } => *num_centers,
Self::UniformGrid { points_per_dim } => {
points_per_dim.saturating_pow(d.clamp(1, u32::MAX as usize) as u32)
}
}
}
}
#[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 = 15;
let modest = BASE_1D_CENTERS.saturating_mul(d.max(1));
default_num_centers(n, d).min(modest).max(1)
}
pub fn starting_num_centers(n: usize, d: usize) -> usize {
let low_rank_resolution = 10usize
.saturating_mul(3usize.saturating_pow(d.saturating_sub(1).min(u32::MAX as usize) as u32));
low_rank_resolution
.min(default_num_centers(n, d))
.min(n)
.max(1)
}
pub fn expanded_num_centers(current: usize, ceiling: usize) -> Option<usize> {
if current >= ceiling {
return None;
}
let expanded = current.saturating_mul(2).min(ceiling);
(expanded > current).then_some(expanded)
}
pub fn basis_is_saturated(
edf: f64,
realized_width: usize,
nullspace_dim: usize,
resolution_tol: f64,
) -> bool {
let capacity = realized_width.saturating_sub(nullspace_dim) as f64;
if !(capacity > 0.0) || !edf.is_finite() {
return false;
}
let penalized_edf = (edf - nullspace_dim as f64).clamp(0.0, capacity);
let margin = (capacity * resolution_tol).max(resolution_tol);
penalized_edf >= capacity - margin
}
#[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: &gam_runtime::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 {
gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired => {
SpatialStorageMode::OperatorOnly
}
gam_runtime::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
}
}
}
gam_runtime::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 {
match strategy {
CenterStrategy::Auto(_) => true,
CenterStrategy::DuchonSpectral { knots, .. } => center_strategy_is_auto(knots),
_ => false,
}
}
pub(crate) fn realized_center_strategy(strategy: &CenterStrategy) -> &CenterStrategy {
match strategy {
CenterStrategy::Auto(inner) => inner.as_ref(),
CenterStrategy::DuchonSpectral { knots, .. } => realized_center_strategy(knots),
other => other,
}
}
pub(crate) fn center_strategy_spectral_basis(
strategy: &CenterStrategy,
) -> Option<&DuchonSpectralBasis> {
match strategy {
CenterStrategy::Auto(inner) => center_strategy_spectral_basis(inner),
CenterStrategy::DuchonSpectral { basis, .. } => Some(basis),
_ => None,
}
}
pub(crate) fn duchon_center_strategy_is_frozen(strategy: &CenterStrategy) -> bool {
match strategy {
CenterStrategy::UserProvided(_) => true,
CenterStrategy::DuchonSpectral {
knots,
basis: DuchonSpectralBasis::Frozen { .. },
} => matches!(knots.as_ref(), CenterStrategy::UserProvided(_)),
_ => false,
}
}
#[cfg(test)]
mod duchon_center_state_tests {
use super::*;
#[test]
fn spectral_state_is_frozen_only_when_knots_and_transform_are_resolved() {
let centers = Array2::zeros((3, 2));
let unresolved = CenterStrategy::DuchonSpectral {
knots: Box::new(CenterStrategy::UserProvided(centers.clone())),
basis: DuchonSpectralBasis::Fresh { rank: 2 },
};
assert!(!duchon_center_strategy_is_frozen(&unresolved));
let resolved = CenterStrategy::DuchonSpectral {
knots: Box::new(CenterStrategy::UserProvided(centers)),
basis: DuchonSpectralBasis::Frozen {
rank: 2,
kernel_transform: Array2::zeros((3, 1)),
bending_penalty: Array2::zeros((1, 1)),
},
};
assert!(duchon_center_strategy_is_frozen(&resolved));
}
}
pub fn center_strategy_kind(strategy: &CenterStrategy) -> CenterStrategyKind {
match strategy {
CenterStrategy::Auto(inner) => center_strategy_kind(inner.as_ref()),
CenterStrategy::DuchonSpectral { knots, .. } => center_strategy_kind(knots),
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::DuchonSpectral { knots, .. } => center_strategy_num_centers(knots),
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,
d: usize,
) -> Result<CenterStrategy, BasisError> {
validate_center_count(num_centers)?;
fn rebuild_inner(
strategy: &CenterStrategy,
num_centers: usize,
d: usize,
) -> Result<CenterStrategy, BasisError> {
match strategy {
CenterStrategy::Auto(inner) => rebuild_inner(inner.as_ref(), num_centers, d),
CenterStrategy::DuchonSpectral { knots, basis } => Ok(CenterStrategy::DuchonSpectral {
knots: Box::new(rebuild_inner(knots, num_centers, d)?),
basis: basis.clone(),
}),
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::UniformGrid { .. } if d == 1 => Ok(CenterStrategy::UniformGrid {
points_per_dim: num_centers,
}),
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, d)?;
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_half_angle::{
SphereTrig, ambient_half_angle_separation, half_angle_partials, half_angle_separation,
half_angle_separation_scalar,
};
pub(crate) use sphere_kernels::{
wahba_sphere_kernel_derivative_dhav_kind, wahba_sphere_kernel_kind,
wahba_sphere_kernel_simd_kind, wahba_sphere_kernel_sobolev_derivative_dhav,
};
pub use sphere_spectral::{
pseudo_s2_truncated_coefficients, sobolev_s2_truncated_coefficients,
sphere_truncated_spectral_eval,
};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum MaternLengthScale {
Auto { resolved: Option<f64> },
Fixed(f64),
}
impl MaternLengthScale {
pub const fn auto() -> Self {
Self::Auto { resolved: None }
}
pub const fn fixed(value: f64) -> Self {
Self::Fixed(value)
}
pub const fn is_fixed(self) -> bool {
matches!(self, Self::Fixed(_))
}
pub const fn resolved(self) -> Option<f64> {
match self {
Self::Auto { resolved } => resolved,
Self::Fixed(value) => Some(value),
}
}
pub fn set_resolved(&mut self, value: f64) {
match self {
Self::Auto { resolved } => *resolved = Some(value),
Self::Fixed(fixed) => *fixed = value,
}
}
pub fn resolve_auto_once(&mut self, value: f64) {
if let Self::Auto { resolved } = self
&& resolved.is_none()
{
*resolved = Some(value);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaternBasisSpec {
pub center_strategy: CenterStrategy,
#[serde(default)]
pub periodic: Option<Vec<Option<f64>>>,
pub length_scale: MaternLengthScale,
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>>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub enum MaternIdentifiability {
None,
#[default]
CenterSumToZero,
CenterLinearOrthogonal,
FrozenTransform { transform: Array2<f64> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DuchonNullspaceOrder {
Zero,
Linear,
Degree(usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum DuchonSpectralBasis {
Fresh {
rank: usize,
},
Frozen {
rank: usize,
kernel_transform: Array2<f64>,
bending_penalty: Array2<f64>,
},
}
impl DuchonSpectralBasis {
pub fn rank(&self) -> usize {
match self {
Self::Fresh { rank } | Self::Frozen { rank, .. } => *rank,
}
}
pub fn kernel_transform(&self) -> Option<&Array2<f64>> {
match self {
Self::Fresh { .. } => None,
Self::Frozen {
kernel_transform, ..
} => Some(kernel_transform),
}
}
pub fn bending_penalty(&self) -> Option<&Array2<f64>> {
match self {
Self::Fresh { .. } => None,
Self::Frozen {
bending_penalty, ..
} => Some(bending_penalty),
}
}
}
#[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 has_active_operator_penalty(&self) -> bool {
matches!(self.mass, OperatorPenaltySpec::Active { .. })
|| matches!(self.tension, OperatorPenaltySpec::Active { .. })
|| matches!(self.stiffness, OperatorPenaltySpec::Active { .. })
}
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 !matches!(nu, MaternNu::Half) && m + ORDER_EPS >= order {
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>,
anchor_offset_coeffs: Option<Array1<f64>>,
},
CubicRegression1D {
knots: Array1<f64>,
identifiability_transform: Option<Array2<f64>>,
},
ThinPlate {
centers: Array2<f64>,
length_scale: crate::OriginalUnits,
periodic: Option<Vec<Option<f64>>>,
identifiability_transform: Option<Array2<f64>>,
input_scale: crate::IsotropicScale,
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_scale: crate::IsotropicScale,
length_scale: crate::StandardizedUnits,
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>>,
sigma_coord: Option<f64>,
},
Matern {
centers: Array2<f64>,
length_scale: crate::OriginalUnits,
periodic: Option<Vec<Option<f64>>>,
nu: MaternNu,
include_intercept: bool,
identifiability_transform: Option<Array2<f64>>,
input_scale: crate::IsotropicScale,
aniso_log_scales: Option<Vec<f64>>,
},
Duchon {
centers: Array2<f64>,
length_scale: Option<crate::OriginalUnits>,
periodic: Option<Vec<Option<f64>>>,
power: f64,
nullspace_order: DuchonNullspaceOrder,
identifiability_transform: Option<Array2<f64>>,
input_scale: crate::IsotropicScale,
aniso_log_scales: Option<Vec<f64>>,
operator_collocation_points: Option<Array2<f64>>,
radial_reparam: Option<Array2<f64>>,
spectral_basis: Option<DuchonSpectralBasis>,
},
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>>,
is_cr: Vec<bool>,
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,
marginal_is_cr: bool,
},
}
#[derive(Clone)]
pub struct BasisBuildResult {
pub design: DesignMatrix,
pub affine_offset: Option<Array1<f64>>,
pub active_penalties: Vec<ActivePenalty>,
pub dropped_penalties: Vec<DroppedPenaltyInfo>,
pub metadata: BasisMetadata,
pub kronecker_factored: Option<KroneckerFactoredBasis>,
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(
"affine_offset_len",
&self.affine_offset.as_ref().map(|offset| offset.len()),
)
.field("active_penalties", &self.active_penalties)
.field("dropped_penalties", &self.dropped_penalties)
.field("metadata", &self.metadata)
.field("kronecker_factored", &self.kronecker_factored)
.field("joint_null_rotation", &self.joint_null_rotation)
.finish()
}
}
#[derive(Debug)]
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,
invariant: std::sync::OnceLock<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>>,
}
impl Clone for KroneckerFactoredBasis {
fn clone(&self) -> Self {
Self {
marginal_designs: self.marginal_designs.clone(),
marginal_penalties: self.marginal_penalties.clone(),
marginal_dims: self.marginal_dims.clone(),
has_double_penalty: self.has_double_penalty,
invariant: match self.invariant.get() {
Some(s) => {
let cell = std::sync::OnceLock::new();
cell.get_or_init(|| std::sync::Arc::clone(s));
cell
}
None => std::sync::OnceLock::new(),
},
}
}
}
impl KroneckerFactoredBasis {
pub fn new(
marginal_designs: Vec<Array2<f64>>,
marginal_penalties: Vec<Array2<f64>>,
marginal_dims: Vec<usize>,
has_double_penalty: bool,
) -> Self {
Self {
marginal_designs,
marginal_penalties,
marginal_dims,
has_double_penalty,
invariant: std::sync::OnceLock::new(),
}
}
pub fn invariant_structure(
&self,
) -> Result<std::sync::Arc<crate::kronecker::KroneckerInvariantStructure>, BasisError> {
if let Some(s) = self.invariant.get() {
return Ok(std::sync::Arc::clone(s));
}
let computed = std::sync::Arc::new(crate::kronecker::KroneckerInvariantStructure::compute(
&self.marginal_designs,
&self.marginal_penalties,
&self.marginal_dims,
)?);
let installed = self.invariant.get_or_init(|| computed);
Ok(std::sync::Arc::clone(installed))
}
}
#[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 ActivePenaltyInfo {
pub source: PenaltySource,
pub original_index: usize,
pub effective_rank: usize,
#[serde(default = "default_normalization_scale")]
pub normalization_scale: f64,
#[serde(skip)]
pub kronecker_factors: Option<Vec<Array2<f64>>>,
#[serde(skip)]
pub structural_null_frame: Option<Array2<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DroppedPenaltyInfo {
pub source: PenaltySource,
pub original_index: usize,
pub reason: PenaltyDropReason,
#[serde(default = "default_normalization_scale")]
pub normalization_scale: f64,
}
#[derive(Clone)]
pub struct ActivePenalty {
pub matrix: Array2<f64>,
pub nullity: usize,
pub null_eigenvectors: Option<Array2<f64>>,
pub op: Option<std::sync::Arc<dyn crate::analytic_penalties::PenaltyOp>>,
pub info: ActivePenaltyInfo,
}
impl std::fmt::Debug for ActivePenalty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActivePenalty")
.field(
"matrix",
&format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
)
.field("nullity", &self.nullity)
.field(
"null_eigenvectors",
&self
.null_eigenvectors
.as_ref()
.map(|basis| format!("{}×{}", basis.nrows(), basis.ncols())),
)
.field("op_dim", &self.op.as_ref().map(|op| op.dim()))
.field("info", &self.info)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct FilteredPenalties {
pub active: Vec<ActivePenalty>,
pub dropped: Vec<DroppedPenaltyInfo>,
}
#[derive(Clone)]
pub struct ConstructiveQuadratic {
factor: Array2<f64>,
matrix: Array2<f64>,
structural_null_frame: Option<Array2<f64>>,
}
impl ConstructiveQuadratic {
pub fn from_energy_factor(factor: Array2<f64>, context: &str) -> Result<Self, BasisError> {
if factor.iter().any(|value| !value.is_finite()) {
crate::bail_invalid_basis!(
"{context}: constructive penalty factor contains a non-finite value"
);
}
let matrix = fast_ata(&factor);
if matrix.iter().any(|value| !value.is_finite()) {
crate::bail_invalid_basis!("{context}: constructive penalty Gram is not representable");
}
Ok(Self {
factor,
matrix,
structural_null_frame: None,
})
}
pub fn with_structural_null_frame(
mut self,
frame: Array2<f64>,
context: &str,
) -> Result<Self, BasisError> {
if frame.nrows() != self.matrix.nrows() {
crate::bail_dim_basis!(
"{context}: structural null frame has {} rows but the quadratic chart has {}",
frame.nrows(),
self.matrix.nrows()
);
}
if frame.iter().any(|value| !value.is_finite()) {
crate::bail_invalid_basis!("{context}: structural null frame is not finite");
}
let gram = fast_ata(&frame);
for row in 0..gram.nrows() {
for col in 0..gram.ncols() {
let expected = if row == col { 1.0 } else { 0.0 };
if (gram[[row, col]] - expected).abs() > 1e-8 {
crate::bail_invalid_basis!(
"{context}: structural null frame is not orthonormal \
(FᵀF deviates by {:.3e} at [{row},{col}])",
(gram[[row, col]] - expected).abs()
);
}
}
}
self.structural_null_frame = Some(frame);
Ok(self)
}
pub fn structural_null_frame(&self) -> Option<&Array2<f64>> {
self.structural_null_frame.as_ref()
}
pub fn structural_null_frame_block(&self, lo: usize, hi: usize) -> Option<Array2<f64>> {
let frame = self.structural_null_frame.as_ref()?;
if lo >= hi || hi > frame.nrows() {
return None;
}
let outside = frame
.rows()
.into_iter()
.enumerate()
.filter(|(row, _)| *row < lo || *row >= hi)
.flat_map(|(_, row)| row.to_vec())
.fold(0.0_f64, |acc, value| acc.max(value.abs()));
if outside > 1e-12 {
return None;
}
Some(frame.slice(s![lo..hi, ..]).to_owned())
}
pub fn try_from_dense_psd(dense: Array2<f64>, context: &str) -> Result<Self, BasisError> {
if dense.nrows() != dense.ncols() {
crate::bail_dim_basis!(
"{context}: dense penalty must be square, got {}x{}",
dense.nrows(),
dense.ncols()
);
}
if dense.iter().any(|value| !value.is_finite()) {
crate::bail_invalid_basis!("{context}: dense penalty contains a non-finite value");
}
if dense.nrows() == 0 {
return Self::from_energy_factor(Array2::zeros((0, 0)), context);
}
let sym = symmetrize_penalty(&dense);
let (evals, evecs) = FaerEigh::eigh(&sym, Side::Lower).map_err(BasisError::LinalgError)?;
let tolerance = spectral_tolerance(&evals);
if let Some(&negative) = evals.iter().find(|&&value| value < -tolerance) {
return Err(BasisError::IndefinitePenalty {
context: context.to_string(),
min_eigenvalue: negative,
tolerance,
guidance: "supply the native energy factor for a PSD function penalty; negative curvature is not a penalty null direction".to_string(),
});
}
let positive: Vec<usize> = evals
.iter()
.enumerate()
.filter_map(|(index, &value)| (value > tolerance).then_some(index))
.collect();
let mut factor = Array2::<f64>::zeros((positive.len(), dense.nrows()));
for (row, index) in positive.into_iter().enumerate() {
let scale = evals[index].sqrt();
for column in 0..dense.nrows() {
factor[[row, column]] = scale * evecs[[column, index]];
}
}
Self::from_energy_factor(factor, context)
}
pub fn factor(&self) -> &Array2<f64> {
&self.factor
}
pub fn dense(&self) -> &Array2<f64> {
&self.matrix
}
pub fn into_dense(self) -> Array2<f64> {
self.matrix
}
pub fn restricted(
&self,
gauge: &gam_problem::Gauge,
context: &str,
) -> Result<Self, BasisError> {
let mut out =
Self::from_energy_factor(gauge.restrict_quadratic_factor(&self.factor), context)?;
if let Some(frame) = self.structural_null_frame.as_ref() {
if gauge.n_blocks() == 1 {
let transform = gauge.block_transform(0);
out.structural_null_frame = transport_structural_null_frame(frame, &transform);
}
}
Ok(out)
}
pub fn scaled(&self, scale: f64, context: &str) -> Result<Self, BasisError> {
if !scale.is_finite() || scale < 0.0 {
crate::bail_invalid_basis!(
"{context}: constructive penalty scale must be finite and non-negative, got {scale}"
);
}
let root = scale.sqrt();
let mut out = Self::from_energy_factor(self.factor.mapv(|value| value * root), context)?;
if scale > 0.0 {
out.structural_null_frame = self.structural_null_frame.clone();
}
Ok(out)
}
pub fn sum(terms: &[Self], context: &str) -> Result<Self, BasisError> {
let coefficient_dim = terms.first().map(|term| term.factor.ncols()).unwrap_or(0);
if terms
.iter()
.any(|term| term.factor.ncols() != coefficient_dim)
{
crate::bail_dim_basis!(
"{context}: constructive penalty sum has inconsistent coefficient dimensions"
);
}
let rows = terms.iter().map(|term| term.factor.nrows()).sum();
let mut factor = Array2::<f64>::zeros((rows, coefficient_dim));
let mut start = 0usize;
for term in terms {
let end = start + term.factor.nrows();
factor.slice_mut(s![start..end, ..]).assign(&term.factor);
start = end;
}
Self::from_energy_factor(factor, context)
}
pub fn zero(dimension: usize) -> Self {
Self {
factor: Array2::zeros((0, dimension)),
matrix: Array2::zeros((dimension, dimension)),
structural_null_frame: None,
}
}
}
fn transport_structural_null_frame(
frame: &Array2<f64>,
transform: &Array2<f64>,
) -> Option<Array2<f64>> {
if transform.nrows() != frame.nrows() {
return None;
}
let projected = transform - &frame.dot(&frame.t().dot(transform));
gam_linalg::faer_ndarray::rrqr_nullspace_basis(&projected.t().to_owned(), 1.0)
.ok()
.map(|(null, _)| null)
}
impl std::fmt::Debug for ConstructiveQuadratic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConstructiveQuadratic")
.field(
"factor",
&format_args!("{}×{}", self.factor.nrows(), self.factor.ncols()),
)
.field(
"matrix",
&format_args!("{}×{}", self.matrix.nrows(), self.matrix.ncols()),
)
.field(
"structural_null_frame",
&self
.structural_null_frame
.as_ref()
.map(|frame| format!("{}×{}", frame.nrows(), frame.ncols())),
)
.finish()
}
}
impl std::ops::Deref for ConstructiveQuadratic {
type Target = Array2<f64>;
fn deref(&self) -> &Self::Target {
&self.matrix
}
}
#[derive(Clone)]
pub struct PenaltyCandidate {
pub matrix: ConstructiveQuadratic,
pub source: PenaltySource,
pub normalization_scale: f64,
pub kronecker_factors: Option<Vec<Array2<f64>>>,
pub op: Option<std::sync::Arc<dyn crate::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("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 rank_tol: f64,
pub noise_tol: f64,
pub iszero: bool,
pub op: Option<std::sync::Arc<dyn crate::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("rank_tol", &self.rank_tol)
.field("noise_tol", &self.noise_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: &gam_runtime::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: &gam_runtime::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 = gam_runtime::resource::ResourcePolicy::default_library();
let budget = policy.max_single_materialization_bytes;
let needed = first.saturating_add(second);
match policy.derivative_storage_mode {
gam_runtime::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),
);
}
gam_runtime::resource::DerivativeStorageMode::MaterializeIfSmall
| gam_runtime::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: &gam_runtime::resource::ResourcePolicy,
) -> bool {
matches!(
policy.derivative_storage_mode,
gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired
) || 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(
gam_linalg::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(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 fn contained_constraint_directions(
design: &DesignMatrix,
constraint_matrix: ArrayView2<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
) -> Result<Array2<f64>, BasisError> {
let n = design.nrows();
let k = design.ncols();
let q = constraint_matrix.ncols();
if q == 0 || k == 0 {
return Ok(Array2::zeros((n, 0)));
}
let normalized = unit_normalize_constraint_columns(constraint_matrix, weights);
let (cross, gram) = design_cross_and_gram(design, normalized.view(), weights)?;
let mut constraint_gram = Array2::<f64>::zeros((q, q));
for i in 0..q {
for j in i..q {
let mut acc = 0.0_f64;
for row in 0..n {
let w = weights.map_or(1.0, |ws| ws[row]);
acc += w * normalized[[row, i]] * normalized[[row, j]];
}
constraint_gram[[i, j]] = acc;
constraint_gram[[j, i]] = acc;
}
}
let (design_evals, design_evecs) =
FaerEigh::eigh(&gram, Side::Lower).map_err(BasisError::LinalgError)?;
let design_top = design_evals.iter().cloned().fold(0.0_f64, f64::max);
let mut whitened_cross = design_evecs.t().dot(&cross);
for i in 0..k {
let scale = if design_evals[i] > design_top * (k as f64) * f64::EPSILON {
1.0 / design_evals[i].sqrt()
} else {
0.0
};
for j in 0..q {
whitened_cross[[i, j]] *= scale;
}
}
let cos2 = whitened_cross.t().dot(&whitened_cross);
let (constraint_evals, constraint_evecs) =
FaerEigh::eigh(&constraint_gram, Side::Lower).map_err(BasisError::LinalgError)?;
let constraint_top = constraint_evals.iter().cloned().fold(0.0_f64, f64::max);
let keep: Vec<usize> = (0..q)
.filter(|&i| constraint_evals[i] > constraint_top * (q as f64) * f64::EPSILON)
.collect();
if keep.is_empty() {
return Ok(Array2::zeros((n, 0)));
}
let mut inverse_root = Array2::<f64>::zeros((q, keep.len()));
for (slot, &i) in keep.iter().enumerate() {
let scale = 1.0 / constraint_evals[i].sqrt();
for row in 0..q {
inverse_root[[row, slot]] = constraint_evecs[[row, i]] * scale;
}
}
let reduced = inverse_root.t().dot(&cos2).dot(&inverse_root);
let (_, angle_evecs) =
FaerEigh::eigh(&reduced, Side::Lower).map_err(BasisError::LinalgError)?;
let directions = normalized.dot(&inverse_root.dot(&angle_evecs));
let mut direction_cross = Array2::<f64>::zeros((k, directions.ncols()));
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 direction_chunk = directions.slice(s![start..end, ..]).to_owned();
if let Some(ws) = weights {
for (mut row, &weight) in direction_chunk
.axis_iter_mut(Axis(0))
.zip(ws.slice(s![start..end]).iter())
{
row *= weight;
}
}
direction_cross += &fast_atb(&basis_chunk, &direction_chunk);
}
let mut design_pinv_cross = design_evecs.t().dot(&direction_cross);
for i in 0..k {
let scale = if design_evals[i] > design_top * (k as f64) * f64::EPSILON {
1.0 / design_evals[i]
} else {
0.0
};
for j in 0..directions.ncols() {
design_pinv_cross[[i, j]] *= scale;
}
}
let coefficients = design_evecs.dot(&design_pinv_cross);
let mut residual_sq = vec![0.0_f64; directions.ncols()];
let mut direction_sq = vec![0.0_f64; directions.ncols()];
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 approximation = basis_chunk.dot(&coefficients);
for j in 0..directions.ncols() {
for row in start..end {
let w = weights.map_or(1.0, |ws| ws[row]);
let target = directions[[row, j]];
let gap = target - approximation[[row - start, j]];
residual_sq[j] += w * gap * gap;
direction_sq[j] += w * target * target;
}
}
}
let containment_bar = f64::EPSILON.sqrt();
let contained: Vec<usize> = (0..directions.ncols())
.filter(|&i| {
direction_sq[i] > 0.0 && (residual_sq[i] / direction_sq[i]).sqrt() <= containment_bar
})
.collect();
if contained.is_empty() {
return Ok(Array2::zeros((n, 0)));
}
if contained.len() == keep.len() {
return Ok(constraint_matrix.to_owned());
}
let mut out = Array2::<f64>::zeros((n, contained.len()));
for (slot, &i) in contained.iter().enumerate() {
for row in 0..n {
out[[row, slot]] = directions[[row, i]];
}
}
Ok(out)
}
#[derive(Clone, Debug)]
pub struct ParametricResidualization {
pub coefficient_transform: Array2<f64>,
pub row_space_correction: Array2<f64>,
}
pub fn parametric_residualization_for_design(
design: &DesignMatrix,
constraint_matrix: ArrayView2<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
) -> Result<ParametricResidualization, BasisError> {
let n = design.nrows();
let p = design.ncols();
let q = constraint_matrix.ncols();
if p == 0 {
return Err(BasisError::InsufficientColumnsForConstraint { found: 0 });
}
if q == 0 {
return Ok(ParametricResidualization {
coefficient_transform: Array2::eye(p),
row_space_correction: Array2::zeros((0, p)),
});
}
if constraint_matrix.nrows() != n {
return Err(BasisError::ConstraintMatrixRowMismatch {
basisrows: n,
constraintrows: constraint_matrix.nrows(),
});
}
let mut column_norms = vec![0.0_f64; q];
for (col, norm) in column_norms.iter_mut().enumerate() {
let mut norm_sq = 0.0_f64;
for row in 0..n {
let value = constraint_matrix[[row, col]];
let weight = weights.map_or(1.0, |ws| ws[row]);
norm_sq += weight * value * value;
}
*norm = norm_sq.sqrt();
}
let normalized = unit_normalize_constraint_columns(constraint_matrix, weights);
let mut constraint_gram = Array2::<f64>::zeros((q, q));
for i in 0..q {
for j in i..q {
let mut acc = 0.0_f64;
for row in 0..n {
let weight = weights.map_or(1.0, |ws| ws[row]);
acc += weight * normalized[[row, i]] * normalized[[row, j]];
}
constraint_gram[[i, j]] = acc;
constraint_gram[[j, i]] = acc;
}
}
let (constraint_evals, constraint_evecs) =
FaerEigh::eigh(&constraint_gram, Side::Lower).map_err(BasisError::LinalgError)?;
let constraint_top = constraint_evals.iter().cloned().fold(0.0_f64, f64::max);
let constraint_floor = constraint_top * (q as f64) * f64::EPSILON;
let mut constraint_pinv = Array2::<f64>::zeros((q, q));
for slot in 0..q {
if constraint_evals[slot] <= constraint_floor {
continue;
}
let scale = 1.0 / constraint_evals[slot];
for i in 0..q {
for j in 0..q {
constraint_pinv[[i, j]] +=
scale * constraint_evecs[[i, slot]] * constraint_evecs[[j, slot]];
}
}
}
let (cross, _gram) = design_cross_and_gram(design, normalized.view(), weights)?;
let regression = constraint_pinv.dot(&cross.t());
let mut residual_gram = Array2::<f64>::zeros((p, p));
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 residual_chunk = &basis_chunk - &normalized.slice(s![start..end, ..]).dot(®ression);
let weighted = match weights {
Some(ws) => {
let mut scaled = residual_chunk.clone();
for (mut row, &weight) in scaled
.axis_iter_mut(Axis(0))
.zip(ws.slice(s![start..end]).iter())
{
row *= weight;
}
scaled
}
None => residual_chunk.clone(),
};
residual_gram += &fast_atb(&residual_chunk, &weighted);
}
for i in 0..p {
for j in (i + 1)..p {
let averaged = 0.5 * (residual_gram[[i, j]] + residual_gram[[j, i]]);
residual_gram[[i, j]] = averaged;
residual_gram[[j, i]] = averaged;
}
}
let coefficient_transform = positive_spectral_whitener_from_gram(&residual_gram)?;
let mut row_space_correction = regression.dot(&coefficient_transform);
for (row, norm) in column_norms.iter().enumerate() {
let scale = if *norm > 0.0 && norm.is_finite() {
1.0 / norm
} else {
0.0
};
for col in 0..row_space_correction.ncols() {
row_space_correction[[row, col]] *= scale;
}
}
Ok(ParametricResidualization {
coefficient_transform,
row_space_correction,
})
}
pub 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 normalized_constraint = unit_normalize_constraint_columns(constraint_matrix, weights);
let (constraint_cross, gram) =
design_cross_and_gram(design, normalized_constraint.view(), weights)?;
orthogonality_transform_from_cross_and_gram(&constraint_cross, &gram)
}
fn unit_normalize_constraint_columns(
constraint_matrix: ArrayView2<'_, f64>,
weights: Option<ArrayView1<'_, f64>>,
) -> Array2<f64> {
let mut c = constraint_matrix.to_owned();
let (n, q) = c.dim();
for col in 0..q {
let mut norm_sq = 0.0_f64;
for row in 0..n {
let v = c[[row, col]];
let w = weights.map_or(1.0, |ws| ws[row]);
norm_sq += w * v * v;
}
let norm = norm_sq.sqrt();
if norm > 0.0 && norm.is_finite() {
let inv = 1.0 / norm;
for row in 0..n {
c[[row, col]] *= inv;
}
}
}
c
}
#[cfg(test)]
mod saturation_escalation_tests {
use super::*;
#[test]
fn starting_count_is_a_supported_low_rank_pilot_capped_by_default() {
assert_eq!(starting_num_centers(800, 2), 30);
assert_eq!(starting_num_centers(100_000, 1), 10);
assert_eq!(starting_num_centers(3, 5), 1);
assert_eq!(starting_num_centers(1, 2), 1);
}
#[test]
fn saturated_expansion_doubles_then_pins_at_validated_ceiling() {
assert_eq!(expanded_num_centers(30, 157), Some(60));
assert_eq!(expanded_num_centers(120, 157), Some(157));
assert_eq!(expanded_num_centers(157, 157), None);
assert_eq!(
expanded_num_centers(usize::MAX - 1, usize::MAX),
Some(usize::MAX)
);
}
#[test]
fn saturation_excludes_the_nullspace_and_tracks_edf() {
let tol = 1e-4;
assert!(basis_is_saturated(100.0, 100, 3, tol));
assert!(!basis_is_saturated(48.5, 100, 3, tol));
assert!(!basis_is_saturated(90.0, 100, 3, tol));
assert!(!basis_is_saturated(3.0, 3, 3, tol));
assert!(!basis_is_saturated(f64::NAN, 100, 3, tol));
}
#[test]
fn saturation_is_monotone_in_edf() {
let tol = 1e-3;
let (k, null) = (60usize, 3usize);
let full_width = k as f64;
let mut first_true: Option<f64> = None;
let mut e = full_width - 5.0;
while e <= full_width {
let sat = basis_is_saturated(e, k, null, tol);
if sat && first_true.is_none() {
first_true = Some(e);
}
if let Some(t) = first_true {
assert!(
basis_is_saturated(e.max(t), k, null, tol),
"saturation must not flip back to false as edf grows"
);
}
e += 0.25;
}
assert!(first_true.is_some(), "edf reaching capacity must saturate");
}
}
#[cfg(test)]
mod containment_tests {
use super::*;
fn dense(m: Array2<f64>) -> DesignMatrix {
DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(m))
}
#[test]
fn a_span_that_contains_the_constant_keeps_the_whole_constraint_block() {
let n = 40usize;
let mut basis = Array2::<f64>::zeros((n, 3));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
basis[[i, 0]] = 1.0;
basis[[i, 1]] = x;
basis[[i, 2]] = x * x;
}
let intercept = Array2::<f64>::ones((n, 1));
let contained =
contained_constraint_directions(&dense(basis), intercept.view(), None).expect("test");
assert_eq!(
contained.dim(),
(n, 1),
"the constant IS in this span, so the whole block is contained"
);
assert!(
contained.iter().all(|&v| (v - 1.0).abs() < 1e-14),
"an all-contained block must come back verbatim, not rotated"
);
}
#[test]
fn a_span_that_only_correlates_with_the_constant_contains_nothing() {
let n = 40usize;
let mut basis = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
basis[[i, 0]] = (-0.3 * x).exp();
basis[[i, 1]] = (-0.9 * x).exp();
}
let intercept = Array2::<f64>::ones((n, 1));
let contained =
contained_constraint_directions(&dense(basis.clone()), intercept.view(), None)
.expect("test");
assert_eq!(
contained.ncols(),
0,
"a merely-correlated constant is not contained and licenses no deletion"
);
let ones = Array1::<f64>::ones(n);
let cross = basis.t().dot(&ones);
let gram = basis.t().dot(&basis);
let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
let projected = evecs.t().dot(&cross);
let mut solved = Array1::<f64>::zeros(projected.len());
for i in 0..projected.len() {
solved[i] = projected[i] / evals[i];
}
let fitted = basis.dot(&evecs.dot(&solved));
let residual = &ones - &fitted;
let sine = residual.dot(&residual).sqrt() / ones.dot(&ones).sqrt();
assert!(
sine > 1.0e-3 && sine < 0.5,
"the fixture must be genuinely correlated-but-not-containing; sin θ = {sine}"
);
}
#[test]
fn a_mixed_block_keeps_exactly_the_contained_direction() {
let n = 40usize;
let mut basis = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
basis[[i, 0]] = 1.0;
basis[[i, 1]] = (-0.9 * x).exp();
}
let mut block = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
block[[i, 0]] = 1.0;
block[[i, 1]] = x;
}
let contained =
contained_constraint_directions(&dense(basis), block.view(), None).expect("test");
assert_eq!(
contained.ncols(),
1,
"one of the two block directions is in the span and the other is not"
);
let column = contained.column(0).to_owned();
let first = column[0];
assert!(
first.abs() > 1e-8,
"the kept direction must be non-degenerate"
);
assert!(
column.iter().all(|&v| (v / first - 1.0).abs() < 1e-8),
"the kept direction must be the constant, got {column:?}"
);
}
#[test]
fn residualizing_a_correlated_block_is_orthogonal_and_costs_no_dimension() {
let n = 40usize;
let mut basis = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
basis[[i, 0]] = (-0.3 * x).exp();
basis[[i, 1]] = (-0.9 * x).exp();
}
let intercept = Array2::<f64>::ones((n, 1));
let plan =
parametric_residualization_for_design(&dense(basis.clone()), intercept.view(), None)
.expect("test");
assert_eq!(
plan.coefficient_transform.ncols(),
2,
"a non-contained constraint costs no coefficient direction"
);
assert_eq!(plan.row_space_correction.dim(), (1, 2));
let realized =
basis.dot(&plan.coefficient_transform) - intercept.dot(&plan.row_space_correction);
let cross = realized.t().dot(&intercept);
let relative = cross.iter().map(|v| v * v).sum::<f64>().sqrt()
/ (realized.iter().map(|v| v * v).sum::<f64>().sqrt()
* intercept.iter().map(|v| v * v).sum::<f64>().sqrt());
let amplification = basis.iter().map(|v| v * v).sum::<f64>().sqrt()
/ realized.iter().map(|v| v * v).sum::<f64>().sqrt();
let floor = (n as f64) * f64::EPSILON * amplification;
assert!(
floor < 1.0e-8,
"the derived floor must stay far below the shipped ORTHOGONALITY_REL_RESIDUAL_TOL \
or this assertion is vacuous; got {floor:e} at amplification {amplification:e}"
);
assert!(
relative <= floor,
"residualized block must be orthogonal to its constraint at the accumulation's own \
floor; got {relative:e} against {floor:e}"
);
let mut target = Array1::<f64>::zeros(n);
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
target[i] = (-0.6 * x).exp();
}
let mut original = Array2::<f64>::zeros((n, 3));
original.slice_mut(s![.., ..1]).assign(&intercept);
original.slice_mut(s![.., 1..]).assign(&basis);
let mut residualized = Array2::<f64>::zeros((n, 3));
residualized.slice_mut(s![.., ..1]).assign(&intercept);
residualized.slice_mut(s![.., 1..]).assign(&realized);
let gap = |design: &Array2<f64>| -> f64 {
let gram = design.t().dot(design);
let rhs = design.t().dot(&target);
let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
let top = evals.iter().cloned().fold(0.0_f64, f64::max);
let projected = evecs.t().dot(&rhs);
let mut solved = Array1::<f64>::zeros(projected.len());
for i in 0..projected.len() {
if evals[i] > top * 1.0e-12 {
solved[i] = projected[i] / evals[i];
}
}
let fitted = design.dot(&evecs.dot(&solved));
let residual = &target - &fitted;
residual.dot(&residual).sqrt() / target.dot(&target).sqrt()
};
let original_gap = gap(&original);
let residualized_gap = gap(&residualized);
assert!(
(original_gap - residualized_gap).abs() <= 1.0e-10 * (1.0 + original_gap),
"residualization must preserve the model span: {original_gap:e} vs {residualized_gap:e}"
);
let deletion =
orthogonality_transform_for_design(&dense(basis.clone()), intercept.view(), None)
.expect("test");
assert_eq!(
deletion.ncols(),
1,
"the deletion costs exactly the dimension this test is about"
);
let mut deleted = Array2::<f64>::zeros((n, 2));
deleted.slice_mut(s![.., ..1]).assign(&intercept);
deleted.slice_mut(s![.., 1..]).assign(&basis.dot(&deletion));
let deleted_gap = gap(&deleted);
assert!(
deleted_gap > 10.0 * original_gap.max(1.0e-14),
"the fixture must be one where the deletion actually loses something: \
{original_gap:e} -> {deleted_gap:e}"
);
}
#[test]
fn residualizing_a_contained_block_reproduces_the_classical_deletion() {
let n = 40usize;
let mut basis = Array2::<f64>::zeros((n, 3));
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
basis[[i, 0]] = 1.0;
basis[[i, 1]] = x;
basis[[i, 2]] = x * x;
}
let intercept = Array2::<f64>::ones((n, 1));
let plan =
parametric_residualization_for_design(&dense(basis.clone()), intercept.view(), None)
.expect("test");
assert_eq!(
plan.coefficient_transform.ncols(),
2,
"the constant IS in this span, so the rank test drops exactly one direction"
);
let realized =
basis.dot(&plan.coefficient_transform) - intercept.dot(&plan.row_space_correction);
let deletion =
orthogonality_transform_for_design(&dense(basis.clone()), intercept.view(), None)
.expect("test");
let deleted = basis.dot(&deletion);
assert_eq!(deleted.ncols(), realized.ncols());
let reproduces = |from: &Array2<f64>, to: &Array2<f64>| -> f64 {
let gram = from.t().dot(from);
let rhs = from.t().dot(to);
let (evals, evecs) = FaerEigh::eigh(&gram, Side::Lower).expect("gram");
let top = evals.iter().cloned().fold(0.0_f64, f64::max);
let mut solved = evecs.t().dot(&rhs);
for i in 0..evals.len() {
let scale = if evals[i] > top * 1.0e-12 {
1.0 / evals[i]
} else {
0.0
};
for j in 0..solved.ncols() {
solved[[i, j]] *= scale;
}
}
let approximation = from.dot(&evecs.dot(&solved));
let gap = to - &approximation;
gap.iter().map(|v| v * v).sum::<f64>().sqrt()
/ to.iter().map(|v| v * v).sum::<f64>().sqrt().max(1.0e-300)
};
assert!(
reproduces(&realized, &deleted) < 1.0e-12,
"the deletion's span must be inside the residualization's"
);
assert!(
reproduces(&deleted, &realized) < 1.0e-12,
"the residualization's span must be inside the deletion's"
);
let cross = realized.t().dot(&intercept);
let relative = cross.iter().map(|v| v * v).sum::<f64>().sqrt()
/ (realized.iter().map(|v| v * v).sum::<f64>().sqrt()
* intercept.iter().map(|v| v * v).sum::<f64>().sqrt());
assert!(relative < 1.0e-14, "got {relative:e}");
}
}