use std::collections::{BTreeMap, HashMap};
use ndarray::ArrayView1;
use crate::basis::{
BSplineBasisSpec, BSplineBoundaryConditions, BSplineEndpointBoundaryCondition,
BSplineIdentifiability, BSplineKnotSpec, CenterCountRequest, CenterStrategy, DuchonBasisSpec,
DuchonNullspaceOrder, DuchonOperatorPenaltySpec, MaternBasisSpec, MaternIdentifiability,
MaternNu, SpatialIdentifiability, SphericalSplineBasisSpec, ThinPlateBasisSpec,
auto_spatial_center_strategy, default_num_centers, default_spatial_center_strategy,
plan_spatial_basis, resolve_duchon_orders,
};
use crate::inference::data::{EncodedDataset as Dataset, missing_column_message};
use crate::inference::formula_dsl::{
ParsedTerm, SmoothKind, option_bool, option_f64, option_f64_strict, option_usize,
option_usize_any, option_usize_any_strict, option_usize_strict,
};
use crate::inference::model::ColumnKindTag;
use crate::resource::ResourcePolicy;
use crate::smooth::{
ByVarKind, FactorSmoothFlavour, FactorSmoothSpec, LinearCoefficientGeometry, LinearTermSpec,
RandomEffectTermSpec, ShapeConstraint, SmoothBasisSpec, SmoothTermSpec,
TensorBSplineIdentifiability, TensorBSplineSpec, TermCollectionSpec,
};
pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, String> {
col_map
.get(name)
.copied()
.ok_or_else(|| missing_column_message(col_map, name, None))
}
pub fn resolve_role_col(
col_map: &HashMap<String, usize>,
name: &str,
role: &str,
) -> Result<usize, String> {
col_map
.get(name)
.copied()
.ok_or_else(|| missing_column_message(col_map, name, Some(role)))
}
pub fn column_map_with_alias(
col_map: &HashMap<String, usize>,
alias: &str,
target_column: &str,
) -> HashMap<String, usize> {
let mut aliased = col_map.clone();
if let Some(idx) = col_map.get(target_column).copied() {
aliased.entry(alias.to_string()).or_insert(idx);
}
aliased
}
pub fn build_termspec(
terms: &[ParsedTerm],
ds: &Dataset,
col_map: &HashMap<String, usize>,
inference_notes: &mut Vec<String>,
policy: &ResourcePolicy,
) -> Result<TermCollectionSpec, String> {
let mut linear_terms = Vec::<LinearTermSpec>::new();
let mut random_terms = Vec::<RandomEffectTermSpec>::new();
let mut smooth_terms = Vec::<SmoothTermSpec>::new();
let smooth_coordinate_count = terms
.iter()
.map(|term| match term {
ParsedTerm::Smooth { vars, .. } => vars.len(),
_ => 0,
})
.sum::<usize>();
for t in terms {
match t {
ParsedTerm::Linear {
name,
explicit,
coefficient_min,
coefficient_max,
} => {
let col = resolve_col(col_map, name)?;
let auto_kind =
ds.column_kinds.get(col).copied().ok_or_else(|| {
format!("internal column-kind lookup failed for '{name}'")
})?;
if *explicit {
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
double_penalty: true,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: *coefficient_min,
coefficient_max: *coefficient_max,
});
} else {
match auto_kind {
ColumnKindTag::Continuous | ColumnKindTag::Binary => {
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
double_penalty: true,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: *coefficient_min,
coefficient_max: *coefficient_max,
});
}
ColumnKindTag::Categorical => {
if coefficient_min.is_some() || coefficient_max.is_some() {
return Err(format!(
"coefficient constraints are not supported for categorical auto-random-effect term '{name}'; use group({name}) or an unconstrained numeric term"
));
}
random_terms.push(RandomEffectTermSpec {
name: name.clone(),
feature_col: col,
drop_first_level: false,
frozen_levels: None,
});
}
}
}
}
ParsedTerm::BoundedLinear {
name,
min,
max,
prior,
} => {
let col = resolve_col(col_map, name)?;
let auto_kind =
ds.column_kinds.get(col).copied().ok_or_else(|| {
format!("internal column-kind lookup failed for '{name}'")
})?;
if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
return Err(format!(
"bounded() currently supports only numeric columns, got categorical '{name}'"
));
}
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Bounded {
min: *min,
max: *max,
prior: prior.clone(),
},
coefficient_min: None,
coefficient_max: None,
});
}
ParsedTerm::RandomEffect { name } => {
let col = resolve_col(col_map, name)?;
random_terms.push(RandomEffectTermSpec {
name: name.clone(),
feature_col: col,
drop_first_level: false,
frozen_levels: None,
});
}
ParsedTerm::Smooth {
label,
vars,
kind,
options,
} => {
let cols = vars
.iter()
.map(|v| resolve_col(col_map, v))
.collect::<Result<Vec<_>, _>>()?;
let mut inner_options = options.clone();
let by_name = inner_options.remove("by");
let mut basis = build_smooth_basis(
*kind,
vars,
&cols,
&inner_options,
ds,
inference_notes,
policy,
smooth_coordinate_count,
)?;
if let Some(by_name) = by_name {
let by_col = resolve_col(col_map, &by_name)?;
let by_kind_tag = ds.column_kinds.get(by_col).copied().ok_or_else(|| {
format!("internal column-kind lookup failed for by variable '{by_name}'")
})?;
let by_kind = match by_kind_tag {
ColumnKindTag::Categorical => {
let levels = sorted_levels_for_column(ds.values.column(by_col))?;
let ordered = inner_options
.get("ordered")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "ordered"
)
})
.unwrap_or(false)
|| by_name.to_ascii_lowercase().contains("ord");
if !terms.iter().any(|t| matches!(t, ParsedTerm::Linear { name, .. } | ParsedTerm::RandomEffect { name } if name == &by_name)) {
inference_notes.push(format!("factor by-smooth s(..., by={by_name}) is usually identifiable with an explicit main-effect term for '{by_name}' (for example '+ {by_name}' or '+ group({by_name})')."));
}
ByVarKind::Factor {
feature_col: by_col,
ordered,
frozen_levels: Some(levels),
}
}
ColumnKindTag::Continuous | ColumnKindTag::Binary => ByVarKind::Numeric {
feature_col: by_col,
},
};
basis = SmoothBasisSpec::BySmooth {
smooth: Box::new(basis),
by_kind,
};
}
smooth_terms.push(SmoothTermSpec {
name: label.clone(),
basis,
shape: ShapeConstraint::None,
});
}
ParsedTerm::LinkWiggle { .. }
| ParsedTerm::TimeWiggle { .. }
| ParsedTerm::LinkConfig { .. }
| ParsedTerm::SurvivalConfig { .. } => {
}
}
}
Ok(TermCollectionSpec {
linear_terms,
random_effect_terms: random_terms,
smooth_terms,
})
}
fn sorted_levels_for_column(col: ArrayView1<'_, f64>) -> Result<Vec<u64>, String> {
let mut levels = std::collections::BTreeSet::<u64>::new();
for &v in col.iter() {
if !v.is_finite() {
return Err("categorical column contains non-finite values".to_string());
}
levels.insert(v.to_bits());
}
Ok(levels.into_iter().collect())
}
fn endpoint_bc_raw<'a>(options: &'a BTreeMap<String, String>, side: &str) -> Option<&'a String> {
let aliases: &[&str] = match side {
"left" => &["bc_left", "left_bc", "start_bc"],
"right" => &["bc_right", "right_bc", "end_bc"],
_ => &[],
};
aliases.iter().find_map(|k| options.get(*k))
}
fn endpoint_anchor_value(options: &BTreeMap<String, String>, side: &str) -> Option<f64> {
let side_keys: &[&str] = match side {
"left" => &["anchor_left", "left_anchor"],
"right" => &["anchor_right", "right_anchor"],
_ => &[],
};
for k in side_keys {
if let Some(v) = option_f64(options, k) {
return Some(v);
}
}
for k in &["anchor", "anchor_value", "value"] {
if let Some(v) = option_f64(options, k) {
return Some(v);
}
}
None
}
#[derive(Clone, Copy)]
enum SideFilter {
Both,
Left,
Right,
}
fn parse_bspline_endpoint_condition(
options: &BTreeMap<String, String>,
side: &str,
global_bc: Option<&str>,
side_filter: SideFilter,
) -> Result<BSplineEndpointBoundaryCondition, String> {
let applies_global = matches!(
(side, side_filter),
(_, SideFilter::Both) | ("left", SideFilter::Left) | ("right", SideFilter::Right)
);
let raw = endpoint_bc_raw(options, side)
.map(String::as_str)
.or_else(|| if applies_global { global_bc } else { None })
.unwrap_or("free")
.trim()
.to_ascii_lowercase();
match raw.as_str() {
"free" | "none" | "open" => Ok(BSplineEndpointBoundaryCondition::Free),
"clamped" | "clamp" | "zero_derivative" | "zero-derivative" => {
Ok(BSplineEndpointBoundaryCondition::Clamped)
}
"anchored" | "anchor" | "zero" | "zero_value" | "zero-value" => {
let value = endpoint_anchor_value(options, side).unwrap_or(0.0);
if value != 0.0 {
return Err(format!(
"anchored {side} endpoint with non-zero value {value} is not supported yet; \
pass anchor value 0 (or omit `anchor_{side}=`) and subtract the offset from \
`y` before fitting if you need to pin the boundary at a non-zero level."
));
}
Ok(BSplineEndpointBoundaryCondition::Anchored { value })
}
other => Err(format!(
"unsupported B-spline boundary condition '{other}' for {side} endpoint; use free|clamped|anchored"
)),
}
}
fn parse_bspline_boundary_conditions(
options: &BTreeMap<String, String>,
) -> Result<BSplineBoundaryConditions, String> {
let global_bc = options.get("bc").map(String::as_str);
let side_filter = match options
.get("side")
.map(|s| s.trim().to_ascii_lowercase())
.as_deref()
{
None | Some("both") => SideFilter::Both,
Some("left") | Some("start") => SideFilter::Left,
Some("right") | Some("end") => SideFilter::Right,
Some(other) => {
return Err(format!(
"unsupported B-spline boundary side '{other}'; use left|right|both"
));
}
};
Ok(BSplineBoundaryConditions {
left: parse_bspline_endpoint_condition(options, "left", global_bc, side_filter)?,
right: parse_bspline_endpoint_condition(options, "right", global_bc, side_filter)?,
})
}
fn bspline_bc_declares_periodic_axis(options: &BTreeMap<String, String>) -> bool {
options
.get("bc")
.map(|raw| {
let vals = split_list_option(raw);
vals.len() == 1
&& matches!(
vals[0]
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase()
.as_str(),
"periodic" | "cyclic" | "cc"
)
})
.unwrap_or(false)
}
pub fn build_smooth_basis(
kind: SmoothKind,
vars: &[String],
cols: &[usize],
options: &BTreeMap<String, String>,
ds: &Dataset,
inference_notes: &mut Vec<String>,
policy: &ResourcePolicy,
smooth_coordinate_count: usize,
) -> Result<SmoothBasisSpec, String> {
for (var, &col) in vars.iter().zip(cols.iter()) {
if unique_count_column(ds.values.column(col)) <= 1 {
return Err(format!(
"smooth term over '{var}' has only one unique value in the training data \
— a smooth on a constant column is degenerate and would only fit the response mean. \
Remove `{var}` from the smooth, drop the term, or check the data."
));
}
}
let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
let has_periodic_option = options.contains_key("periodic")
|| options.contains_key("cyclic")
|| options
.get("bc")
.map(|bc| {
bc.to_ascii_lowercase().contains("periodic")
|| bc.to_ascii_lowercase().contains("cyclic")
})
.unwrap_or(false);
let type_opt = options
.get("type")
.or_else(|| options.get("bs"))
.map(|s| s.to_ascii_lowercase())
.unwrap_or_else(|| match kind {
SmoothKind::Te => "tensor".to_string(),
SmoothKind::S if cols.len() == 1 => "bspline".to_string(),
SmoothKind::S if has_periodic_option => "tensor".to_string(),
SmoothKind::S => "tps".to_string(),
});
match type_opt.as_str() {
"fs" | "factor-smooth" | "factor_smooth" | "sz" | "re" => {
validate_known_options(
type_opt.as_str(),
options,
&[
"type",
"bs",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"degree",
"penalty_order",
"m",
"double_penalty",
],
)?;
if cols.len() != 2 {
return Err(format!(
"{} smooth currently expects exactly two variables: one continuous and one categorical/group variable",
type_opt
));
}
let k0 = ds
.column_kinds
.get(cols[0])
.copied()
.ok_or_else(|| "column-kind lookup failed".to_string())?;
let k1 = ds
.column_kinds
.get(cols[1])
.copied()
.ok_or_else(|| "column-kind lookup failed".to_string())?;
let (group_idx, cont_idx) = match (k0, k1) {
(ColumnKindTag::Categorical, _) => (0usize, 1usize),
(_, ColumnKindTag::Categorical) => (1usize, 0usize),
(ColumnKindTag::Binary, ColumnKindTag::Continuous) => (0usize, 1usize),
(ColumnKindTag::Continuous, ColumnKindTag::Binary) => (1usize, 0usize),
_ => {
return Err(format!(
"{} smooth requires one categorical/binary grouping variable and one numeric variable, got kinds {:?} and {:?}",
type_opt, k0, k1
));
}
};
let group_col = cols[group_idx];
let cont_col = cols[cont_idx];
let levels = sorted_levels_for_column(ds.values.column(group_col))?;
let degree = option_usize(options, "degree").unwrap_or(3);
let (minv, maxv) = col_minmax(ds.values.column(cont_col))?;
let default_internal = heuristic_knots_for_column(ds.values.column(cont_col));
let k = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"]);
let n_knots = if let Some(k) = k {
if k < degree + 1 {
return Err(format!(
"factor smooth: k={} too small for degree {}; expected k >= {}",
k,
degree,
degree + 1
));
}
k - degree - 1
} else {
default_internal
};
let marginal = BSplineBasisSpec {
degree,
penalty_order: option_usize(options, "penalty_order").unwrap_or(2),
knotspec: BSplineKnotSpec::Generate {
data_range: (minv, maxv),
num_internal_knots: n_knots,
},
double_penalty: true,
identifiability: BSplineIdentifiability::None,
boundary_conditions: Default::default(),
};
let flavour = match type_opt.as_str() {
"fs" | "factor-smooth" | "factor_smooth" => {
let m = option_usize(options, "m").unwrap_or(2);
FactorSmoothFlavour::Fs {
m_null_penalty_orders: vec![m],
}
}
"sz" => FactorSmoothFlavour::Sz,
"re" => FactorSmoothFlavour::Re,
_ => unreachable!(),
};
Ok(SmoothBasisSpec::FactorSmooth {
spec: FactorSmoothSpec {
continuous_cols: vec![cont_col],
group_col,
marginal,
flavour,
group_frozen_levels: Some(levels),
},
})
}
"tensor" | "te" | "tensor-bspline" => {
validate_known_options(
"te",
options,
&[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"degree",
"penalty_order",
"bc",
"periodic",
"cyclic",
"period",
"periods",
"period_start",
"period_end",
"origin",
"origins",
"period_origin",
"period-origin",
"domain_origin",
"double_penalty",
"identifiability",
],
)?;
if cols.len() < 2 {
return Err(format!(
"tensor smooth requires >=2 variables: {}",
vars.join(", ")
));
}
let tensor_double_penalty = option_bool(options, "double_penalty").unwrap_or(false);
let degree = 3usize;
let knots_internal = option_usize_list_any(options, &["knots"])?
.map(|v| expand_margin_usize_option("knots", v, cols.len()))
.transpose()?;
let basis_dim =
option_usize_list_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])?
.map(|v| expand_margin_usize_option("k", v, cols.len()))
.transpose()?;
if knots_internal.is_some() && basis_dim.is_some() {
return Err(
"tensor smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)"
.to_string(),
);
}
let inferred = knots_internal.is_none() && basis_dim.is_none();
let mut internal_knots_by_dim = if let Some(k_values) = basis_dim {
let min_k = degree + 1;
for &k in &k_values {
if k < min_k {
return Err(format!(
"tensor smooth: k={} too small for degree {}; expected k >= {}",
k, degree, min_k
));
}
}
k_values.into_iter().map(|k| k - min_k).collect()
} else if let Some(knots) = knots_internal {
knots
} else {
cols.iter()
.map(|&c| heuristic_knots_for_column(ds.values.column(c)))
.collect()
};
if inferred {
let effective_n = tensor_effective_support_count(ds, cols);
cap_inferred_tensor_internal_knots(&mut internal_knots_by_dim, degree, effective_n);
}
if inferred && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
for n_knots in &mut internal_knots_by_dim {
*n_knots = (*n_knots).min(1);
}
}
if inferred {
inference_notes.push(format!(
"Automatically set tensor smooth '{}' internal knots per margin to [{}] from per-axis cardinality and rank-product limits. Override with knots=... or k=....",
vars.join(","),
internal_knots_by_dim
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
validate_tensor_bc_entries(options, cols.len())?;
let periodic_axes = parse_periodic_axes(options, cols.len())?;
let periods = parse_periods(options, &periodic_axes)?;
let origins = parse_period_origins(options, &periodic_axes)?;
let specs = cols
.iter()
.enumerate()
.map(|(dim, &c)| {
let (minv, maxv) = col_minmax(ds.values.column(c))?;
let n_knots = internal_knots_by_dim[dim];
let knotspec = if periodic_axes[dim] {
let period = periods[dim].ok_or_else(|| {
format!(
"periodic tensor margin {} ('{}') requires period=[...] entry",
dim, vars[dim]
)
})?;
let domain_start = origins[dim].unwrap_or(minv);
BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_start + period),
num_basis: n_knots + degree + 1,
}
} else {
BSplineKnotSpec::Generate {
data_range: (minv, maxv),
num_internal_knots: n_knots,
}
};
Ok(BSplineBasisSpec {
degree,
penalty_order: 2,
knotspec,
double_penalty: tensor_double_penalty,
identifiability: BSplineIdentifiability::None,
boundary_conditions: Default::default(),
})
})
.collect::<Result<Vec<_>, String>>()?;
Ok(SmoothBasisSpec::TensorBSpline {
feature_cols: cols.to_vec(),
spec: TensorBSplineSpec {
marginalspecs: specs,
double_penalty: tensor_double_penalty,
identifiability: parse_tensor_identifiability(options)?,
},
})
}
"periodic" | "cyclic" | "periodic-bspline" | "cc" => {
validate_known_options(
"periodic",
options,
&[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"degree",
"penalty_order",
"period",
"periods",
"period_start",
"period_end",
"origin",
"period_origin",
"period-origin",
"domain_origin",
"double_penalty",
],
)?;
if cols.len() != 1 {
return Err(format!(
"periodic smooth expects one variable, got {}",
cols.len()
));
}
let c = cols[0];
let (minv, maxv) = col_minmax(ds.values.column(c))?;
let degree = option_usize(options, "degree").unwrap_or(3);
let mut default_internal = heuristic_knots_for_column(ds.values.column(c));
if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
default_internal = default_internal.min(1);
}
let default_basis = default_internal + degree + 1;
let num_basis = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
.unwrap_or(default_basis);
if num_basis < degree + 1 {
return Err(format!(
"periodic smooth: k={} too small for degree {}; expected k >= {}",
num_basis,
degree,
degree + 1
));
}
let (domain_start, period) = parse_periodic_domain_1d(options, minv, maxv)?;
Ok(SmoothBasisSpec::BSpline1D {
feature_col: c,
spec: BSplineBasisSpec {
degree,
penalty_order: option_usize(options, "penalty_order").unwrap_or(2),
knotspec: BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_start + period),
num_basis,
},
double_penalty: smooth_double_penalty,
identifiability: BSplineIdentifiability::default(),
boundary_conditions: Default::default(),
},
})
}
"bspline" | "ps" | "p-spline" => {
validate_known_options(
"bspline",
options,
&[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"degree",
"penalty_order",
"bc",
"bc_left",
"bc_right",
"left_bc",
"right_bc",
"start_bc",
"end_bc",
"anchor",
"anchor_value",
"value",
"anchor_left",
"left_anchor",
"anchor_right",
"right_anchor",
"periodic",
"period",
"periods",
"period_start",
"period_end",
"origin",
"double_penalty",
"identifiability",
],
)?;
if cols.len() != 1 {
return Err(format!(
"bspline smooth expects one variable, got {}",
cols.len()
));
}
let c = cols[0];
let (minv, maxv) = col_minmax(ds.values.column(c))?;
let degree = option_usize(options, "degree").unwrap_or(3);
let default_internal = heuristic_knots_for_column(ds.values.column(c));
let (mut n_knots, inferred) =
parse_ps_internal_knots(options, degree, default_internal)?;
if inferred && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
n_knots = n_knots.min(1);
}
if inferred {
let unique = unique_count_column(ds.values.column(c));
let ceiling = ((unique as f64).cbrt() as usize).max(20);
inference_notes.push(format!(
"Automatically set {} internal knots for smooth '{}' from {} unique values (rule: clamp(unique/4, 4..max(20, cbrt(unique))) = clamp(unique/4, 4..{})). Override with knots=... or k=....",
n_knots,
vars.join(","),
unique,
ceiling,
));
}
let periodic_axes = parse_periodic_axes(options, 1)?;
let boundary_conditions =
if periodic_axes[0] && bspline_bc_declares_periodic_axis(options) {
BSplineBoundaryConditions::default()
} else {
parse_bspline_boundary_conditions(options)?
};
let periods = parse_periods(options, &periodic_axes)?;
let origins = parse_period_origins(options, &periodic_axes)?;
let knotspec = if periodic_axes[0] {
if !boundary_conditions.is_free() {
return Err(
"periodic B-splines cannot also declare endpoint boundary conditions"
.to_string(),
);
}
{
let (domain_start, p_value) = if periods[0].is_some() {
(origins[0].unwrap_or(minv), periods[0].unwrap())
} else {
parse_periodic_domain_1d(options, minv, maxv)?
};
BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_start + p_value),
num_basis: n_knots + degree + 1,
}
}
} else {
BSplineKnotSpec::Generate {
data_range: (minv, maxv),
num_internal_knots: n_knots,
}
};
Ok(SmoothBasisSpec::BSpline1D {
feature_col: c,
spec: BSplineBasisSpec {
degree,
penalty_order: option_usize(options, "penalty_order").unwrap_or(2),
knotspec,
double_penalty: smooth_double_penalty,
identifiability: BSplineIdentifiability::default(),
boundary_conditions,
},
})
}
"tps" | "thinplate" | "thin-plate" => {
validate_known_options(
"thinplate",
options,
&[
"type",
"bs",
"by",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"include_intercept",
"double_penalty",
"identifiability",
"scale_dims",
],
)?;
let plan = plan_spatial_basis(
ds.values.nrows(),
cols.len(),
CenterCountRequest::Default,
DuchonNullspaceOrder::Linear,
option_bool(options, "scale_dims").unwrap_or(false),
policy,
)
.map_err(|e| e.to_string())?;
let centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
spatial_center_strategy_for_dimension(centers, cols.len())
} else {
auto_spatial_center_strategy(centers, cols.len())
};
Ok(SmoothBasisSpec::ThinPlate {
feature_cols: cols.to_vec(),
spec: ThinPlateBasisSpec {
center_strategy,
length_scale: option_f64_strict(options, "length_scale")?.unwrap_or(0.0),
double_penalty: smooth_double_penalty,
identifiability: parse_spatial_identifiability(options)?,
radial_reparam: None,
},
input_scales: None,
})
}
"sphere" | "sos" | "spherical" => {
validate_known_options(
"sphere",
options,
&[
"type",
"bs",
"by",
"method",
"m",
"order",
"penalty_order",
"radians",
"units",
"max_degree",
"max-degree",
"max_l",
"max-l",
"harmonic_degree",
"l",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"double_penalty",
"kernel",
"wahba_kernel",
"wahba-kernel",
],
)?;
if cols.len() != 2 {
return Err(format!(
"sphere smooth expects exactly two variables (latitude, longitude), got {}",
cols.len()
));
}
let plan = plan_spatial_basis(
ds.values.nrows(),
2,
CenterCountRequest::Default,
DuchonNullspaceOrder::Zero,
false,
policy,
)
.map_err(|e| e.to_string())?;
let centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
let center_strategy = CenterStrategy::FarthestPoint {
num_centers: centers,
};
let penalty_order = option_usize(options, "m")
.or_else(|| option_usize(options, "order"))
.or_else(|| option_usize(options, "penalty_order"))
.unwrap_or(2);
if !(1..=4).contains(&penalty_order) {
return Err(format!(
"sphere smooth penalty order must be one of 1, 2, 3, 4; got {penalty_order}"
));
}
let radians = option_bool(options, "radians").unwrap_or_else(|| {
options
.get("units")
.map(|u| {
u.trim().to_ascii_lowercase() == "radians"
|| u.trim().to_ascii_lowercase() == "rad"
})
.unwrap_or(false)
});
let (method, method_kernel) = match options
.get("method")
.map(|s| s.trim().to_ascii_lowercase())
.as_deref()
{
None | Some("wahba") | Some("kernel") => (
crate::basis::SphereMethod::Wahba,
Option::<crate::basis::SphereWahbaKernel>::None,
),
Some("wahba_sobolev")
| Some("wahba-sobolev")
| Some("sobolev")
| Some("sobolev_wahba")
| Some("sobolev-wahba") => (
crate::basis::SphereMethod::Wahba,
Some(crate::basis::SphereWahbaKernel::Sobolev),
),
Some("wahba_pseudo") | Some("wahba-pseudo") | Some("pseudo")
| Some("pseudo_wahba") | Some("pseudo-wahba") | Some("mgcv") | Some("sos") => (
crate::basis::SphereMethod::Wahba,
Some(crate::basis::SphereWahbaKernel::Pseudo),
),
Some("harmonic")
| Some("harmonics")
| Some("spherical_harmonics")
| Some("spherical-harmonics")
| Some("sh") => (crate::basis::SphereMethod::Harmonic, None),
Some(other) => {
return Err(format!(
"unsupported sphere method '{other}'; use one of: \
wahba | wahba_sobolev (default Wahba) | wahba_pseudo (mgcv `bs=\"sos\"` compatible) | harmonic"
));
}
};
let explicit_kernel = match options
.get("kernel")
.or_else(|| options.get("wahba_kernel"))
.or_else(|| options.get("wahba-kernel"))
.map(|s| s.trim().to_ascii_lowercase())
.as_deref()
{
None => None,
Some("sobolev") | Some("true") | Some("h") | Some("hm") => {
Some(crate::basis::SphereWahbaKernel::Sobolev)
}
Some("pseudo")
| Some("pseudo_spline")
| Some("pseudo-spline")
| Some("mgcv")
| Some("sos") => Some(crate::basis::SphereWahbaKernel::Pseudo),
Some(other) => {
return Err(format!(
"unsupported sphere kernel '{other}'; use one of: sobolev | pseudo"
));
}
};
let wahba_kernel = explicit_kernel
.or(method_kernel)
.unwrap_or(crate::basis::SphereWahbaKernel::Sobolev);
let max_degree = option_usize_any(
options,
&[
"max_degree",
"max-degree",
"max_l",
"max-l",
"harmonic_degree",
"l",
],
);
Ok(SmoothBasisSpec::Sphere {
feature_cols: cols.to_vec(),
spec: SphericalSplineBasisSpec {
center_strategy,
penalty_order,
double_penalty: smooth_double_penalty,
radians,
method,
max_degree,
wahba_kernel,
},
})
}
"matern" => {
validate_known_options(
"matern",
options,
&[
"type",
"bs",
"by",
"nu",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"include_intercept",
"double_penalty",
"identifiability",
"scale_dims",
],
)?;
let plan = plan_spatial_basis(
ds.values.nrows(),
cols.len(),
CenterCountRequest::Default,
DuchonNullspaceOrder::Zero,
option_bool(options, "scale_dims").unwrap_or(false),
policy,
)
.map_err(|e| e.to_string())?;
let centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
spatial_center_strategy_for_dimension(centers, cols.len())
} else {
auto_spatial_center_strategy(centers, cols.len())
};
let nu = parse_matern_nu(options.get("nu").map(String::as_str).unwrap_or("5/2"))?;
if matches!(nu, MaternNu::Half) && cols.len() >= 2 {
return Err(format!(
"matern() with nu=1/2 is not supported for d>=2 (got {} covariates): \
the exponential kernel's Laplacian is singular at center collisions, \
which makes the operator-collocation penalty non-invertible. \
Choose nu>=3/2 (e.g. nu=3/2 or the default nu=5/2) for multi-dimensional smooths.",
cols.len()
));
}
let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
Some(vec![0.0; cols.len()])
} else {
None
};
Ok(SmoothBasisSpec::Matern {
feature_cols: cols.to_vec(),
spec: MaternBasisSpec {
center_strategy,
length_scale: option_f64_strict(options, "length_scale")?.unwrap_or(0.0),
nu,
include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
double_penalty: smooth_double_penalty,
identifiability: parse_matern_identifiability(options)?,
aniso_log_scales,
},
input_scales: None,
})
}
"duchon" => {
validate_known_options(
"duchon",
options,
&[
"type",
"bs",
"by",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"power",
"p",
"nullspace_order",
"order",
"identifiability",
"periodic",
"cyclic",
"period",
"period_start",
"period_end",
"scale_dims",
"double_penalty",
],
)?;
if options.contains_key("double_penalty") {
return Err(format!(
"Duchon smooth '{}' does not support double_penalty; Duchon uses mass, tension, and stiffness operator penalties.",
vars.join(", ")
));
}
let requested_nullspace_order = parse_duchon_order(options)?;
let length_scale = option_f64_strict(options, "length_scale")?;
let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
DuchonPowerPolicy::Explicit(req_power) => {
let (resolved_nullspace, min_admissible_power) = resolve_duchon_orders(
cols.len(),
requested_nullspace_order,
2,
length_scale,
);
let final_power = if resolved_nullspace == requested_nullspace_order {
req_power.max(min_admissible_power)
} else {
req_power
};
if final_power != req_power {
inference_notes.push(format!(
"Note: explicit Duchon power={} is below the minimum admissible \
power={} for D2 (stiffness) collocation at dimension={}, \
nullspace_order={:?} (requires 2(p+s) > d+2). Auto-escalated \
to power={} so all three Duchon operator penalties (mass, \
tension, stiffness) remain active.",
req_power,
min_admissible_power,
cols.len(),
requested_nullspace_order,
final_power,
));
}
(requested_nullspace_order, final_power)
}
DuchonPowerPolicy::MinimumAdmissibleForTripleOperator => {
let resolved = resolve_duchon_orders(
cols.len(),
requested_nullspace_order,
2,
length_scale,
);
if resolved.0 != requested_nullspace_order {
inference_notes.push(format!(
"Note: scale-free Duchon CPD against polynomial nullspace requires order ≥ {:?} \
at dimension {} (Wendland 8.17, 2s < d); auto-escalated from {:?}. \
Specify length_scale=... to use the hybrid Duchon-Matern kernel.",
resolved.0,
cols.len(),
requested_nullspace_order,
));
}
resolved
}
};
let plan = plan_spatial_basis(
ds.values.nrows(),
cols.len(),
CenterCountRequest::Default,
nullspace_order,
option_bool(options, "scale_dims").unwrap_or(false),
policy,
)
.map_err(|e| e.to_string())?;
let centers_explicit = has_explicit_countwith_basis_alias(options, "centers");
let requested_centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
let polynomial_cols = match nullspace_order {
DuchonNullspaceOrder::Zero => 1,
DuchonNullspaceOrder::Linear => cols.len() + 1,
DuchonNullspaceOrder::Degree(degree) => {
crate::basis::duchon_nullspace_dimension(cols.len(), degree)
}
};
if requested_centers <= polynomial_cols {
return Err(format!(
"Duchon smooth '{}' requested basis dimension {} but order={:?} in {}D needs {} polynomial null-space columns; choose centers/k > {}",
vars.join(", "),
requested_centers,
nullspace_order,
cols.len(),
polynomial_cols,
polynomial_cols,
));
}
let mut centers = requested_centers;
if !centers_explicit && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
centers = centers.max(polynomial_cols + 4);
}
let center_strategy = if centers_explicit {
spatial_center_strategy_for_dimension(centers, cols.len())
} else {
auto_spatial_center_strategy(centers, cols.len())
};
let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
Some(vec![0.0; cols.len()])
} else {
None
};
let operator_penalties = DuchonOperatorPenaltySpec::default();
let periodic = option_bool(options, "cyclic").unwrap_or(false)
|| option_bool(options, "periodic").unwrap_or(false);
Ok(SmoothBasisSpec::Duchon {
feature_cols: cols.to_vec(),
spec: DuchonBasisSpec {
center_strategy,
length_scale,
power,
nullspace_order,
identifiability: parse_spatial_identifiability(options)?,
aniso_log_scales,
operator_penalties,
periodic,
},
input_scales: None,
})
}
other => Err(format!("unsupported smooth type '{other}'")),
}
}
pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
for smooth in spec.smooth_terms.iter_mut() {
match &mut smooth.basis {
SmoothBasisSpec::Matern {
feature_cols,
spec: matern,
..
} => {
if matern.aniso_log_scales.is_none() {
let d = feature_cols.len();
matern.aniso_log_scales = Some(vec![0.0; d]);
}
}
SmoothBasisSpec::Duchon {
feature_cols,
spec: duchon,
..
} => {
if duchon.aniso_log_scales.is_none() {
let d = feature_cols.len();
duchon.aniso_log_scales = Some(vec![0.0; d]);
}
}
_ => {}
}
}
}
pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
default_spatial_center_strategy(num_centers, d)
}
pub fn col_minmax(col: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
let min = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
if !min.is_finite() || !max.is_finite() {
return Err("non-finite data encountered while inferring knot range".to_string());
}
if (max - min).abs() < 1e-12 {
Ok((min, min + 1e-6))
} else {
Ok((min, max))
}
}
pub fn heuristic_knots(n: usize) -> usize {
let n_f = n as f64;
let base = n_f.sqrt() as usize;
let n_cbrt = n_f.cbrt();
let ceiling = (n_cbrt as usize).max(30);
base.clamp(6, ceiling)
}
pub fn unique_count_column(col: ArrayView1<'_, f64>) -> usize {
use std::collections::HashSet;
let mut set = HashSet::<u64>::with_capacity(col.len());
for &v in col {
let norm = if v == 0.0 { 0.0 } else { v };
set.insert(norm.to_bits());
}
set.len().max(1)
}
pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
let unique = unique_count_column(col);
let ceiling = ((unique as f64).cbrt() as usize).max(20);
(unique / 4).clamp(4, ceiling)
}
fn cap_inferred_tensor_internal_knots(internal_knots: &mut [usize], degree: usize, n: usize) {
if internal_knots.is_empty() {
return;
}
let min_basis = degree + 1;
let max_product = inferred_tensor_basis_product_cap(n, internal_knots.len());
loop {
let product = internal_knots
.iter()
.try_fold(1usize, |acc, &knots| acc.checked_mul(knots + min_basis));
if matches!(product, Some(p) if p <= max_product) {
break;
}
let Some((idx, _)) = internal_knots
.iter()
.enumerate()
.filter(|(_, knots)| **knots > 1)
.max_by_key(|(_, knots)| **knots + min_basis)
else {
break;
};
internal_knots[idx] -= 1;
}
}
fn tensor_effective_support_count(ds: &Dataset, cols: &[usize]) -> usize {
let product = cols.iter().try_fold(1usize, |acc, &col| {
acc.checked_mul(unique_count_column(ds.values.column(col)))
});
product.unwrap_or(usize::MAX).min(ds.values.nrows()).max(1)
}
fn inferred_tensor_basis_product_cap(n: usize, ndim: usize) -> usize {
let upper = 128usize;
let n_cap = (4.0 * (n.max(1) as f64).sqrt()).round() as usize;
let dim_floor = 4usize.saturating_pow(ndim as u32).max(64).min(upper);
n_cap.clamp(dim_floor, upper)
}
pub fn heuristic_centers(n: usize, d: usize) -> usize {
default_num_centers(n, d)
}
fn parse_math_f64(raw: &str) -> Result<f64, String> {
let t = raw.trim().trim_matches('"').trim_matches('\'').trim();
if t.eq_ignore_ascii_case("none") || t.eq_ignore_ascii_case("null") {
return Err("None/null is not a number".to_string());
}
let lower = t
.to_ascii_lowercase()
.replace(' ', "")
.replace('π', "pi")
.replace('τ', "tau");
match lower.as_str() {
"pi" => return Ok(std::f64::consts::PI),
"tau" | "2pi" | "2*pi" => return Ok(2.0 * std::f64::consts::PI),
_ => {}
}
if let Some(rest) = lower.strip_suffix("*pi") {
let coef = if rest.is_empty() {
1.0
} else {
rest.parse::<f64>()
.map_err(|_| format!("invalid numeric expression '{raw}'"))?
};
return Ok(coef * std::f64::consts::PI);
}
if let Some(rest) = lower.strip_prefix("pi*") {
let coef = rest
.parse::<f64>()
.map_err(|_| format!("invalid numeric expression '{raw}'"))?;
return Ok(coef * std::f64::consts::PI);
}
lower
.parse::<f64>()
.map_err(|_| format!("invalid numeric expression '{raw}'"))
}
fn split_list_option(raw: &str) -> Vec<String> {
let t = raw.trim();
let inner = t
.strip_prefix('[')
.and_then(|u| u.strip_suffix(']'))
.unwrap_or(t);
inner.split(',').map(|v| v.trim().to_string()).collect()
}
fn option_usize_list_any(
options: &BTreeMap<String, String>,
keys: &[&str],
) -> Result<Option<Vec<usize>>, String> {
for key in keys {
if let Some(raw) = options.get(*key) {
let vals = split_list_option(raw);
if vals.is_empty() {
return Err(format!("{key} must contain at least one integer"));
}
let parsed = vals
.iter()
.map(|v| {
v.parse::<usize>().map_err(|_| {
format!("{key} entries must be non-negative integers, got '{v}'")
})
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(Some(parsed));
}
}
Ok(None)
}
fn expand_margin_usize_option(
name: &str,
values: Vec<usize>,
ndim: usize,
) -> Result<Vec<usize>, String> {
match values.len() {
1 => Ok(vec![values[0]; ndim]),
n if n == ndim => Ok(values),
n => Err(format!(
"{name} must be scalar or have one entry per margin ({ndim}), got {n}"
)),
}
}
fn parse_periodic_axes(
options: &BTreeMap<String, String>,
ndim: usize,
) -> Result<Vec<bool>, String> {
let mut out = vec![false; ndim];
let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) else {
if let Some(raw_bc) = options.get("bc") {
let vals = split_list_option(raw_bc);
if vals.len() != ndim {
return Err(format!(
"bc must have one entry per margin ({ndim}), got {}",
vals.len()
));
}
for (i, v) in vals.iter().enumerate() {
let l = v.trim_matches('"').trim_matches('\'').to_ascii_lowercase();
out[i] = matches!(l.as_str(), "periodic" | "cyclic" | "cc");
}
}
return Ok(out);
};
let vals = split_list_option(raw);
if vals.len() == ndim
&& vals.iter().all(|v| {
matches!(
v.to_ascii_lowercase().as_str(),
"true" | "false" | "yes" | "no"
)
})
{
for (i, v) in vals.iter().enumerate() {
out[i] = matches!(v.to_ascii_lowercase().as_str(), "true" | "yes");
}
} else {
for v in vals {
if v.is_empty() {
continue;
}
let axis = v
.parse::<usize>()
.map_err(|_| format!("periodic axes must be zero-based integers, got '{v}'"))?;
if axis >= ndim {
return Err(format!(
"periodic axis {axis} out of range for {ndim}D smooth"
));
}
out[axis] = true;
}
}
Ok(out)
}
fn validate_tensor_bc_entries(
options: &BTreeMap<String, String>,
ndim: usize,
) -> Result<(), String> {
let Some(raw_bc) = options.get("bc") else {
return Ok(());
};
let vals = split_list_option(raw_bc);
if vals.len() != ndim {
return Err(format!(
"bc must have one entry per margin ({ndim}), got {}",
vals.len()
));
}
for (dim, value) in vals.iter().enumerate() {
let l = value
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase();
if matches!(
l.as_str(),
"periodic" | "cyclic" | "cc" | "natural" | "free" | "none"
) {
continue;
}
return Err(format!(
"tensor smooth bc entry {dim}={value:?} is not supported. Tensor margins currently support only periodic/cyclic/cc or natural/free/none; use separate s(..., bc=...) terms for endpoint clamped/anchored 1D boundary conditions."
));
}
Ok(())
}
fn parse_periods(
options: &BTreeMap<String, String>,
periodic: &[bool],
) -> Result<Vec<Option<f64>>, String> {
let ndim = periodic.len();
let mut out = vec![None; ndim];
let Some(raw) = options.get("period").or_else(|| options.get("periods")) else {
return Ok(out);
};
let vals = split_list_option(raw);
if vals.len() == 1 && periodic.iter().filter(|&&b| b).count() == 1 {
let value = parse_math_f64(&vals[0])?;
if value <= 0.0 || !value.is_finite() {
return Err("period entries must be positive finite values".to_string());
}
if let Some(axis) = periodic.iter().position(|&b| b) {
out[axis] = Some(value);
}
return Ok(out);
}
if vals.len() != ndim {
return Err(format!(
"period must have one entry per margin ({ndim}), got {}",
vals.len()
));
}
for (i, v) in vals.iter().enumerate() {
let l = v.to_ascii_lowercase();
if matches!(l.as_str(), "none" | "null" | "na" | "") {
continue;
}
let value = parse_math_f64(v)?;
if value <= 0.0 || !value.is_finite() {
return Err("period entries must be positive finite values".to_string());
}
out[i] = Some(value);
}
Ok(out)
}
fn parse_period_origins(
options: &BTreeMap<String, String>,
periodic: &[bool],
) -> Result<Vec<Option<f64>>, String> {
let ndim = periodic.len();
let mut out = vec![None; ndim];
let Some(raw) = options.get("origin").or_else(|| options.get("origins")) else {
return Ok(out);
};
let vals = split_list_option(raw);
if vals.len() == 1 && periodic.iter().filter(|&&b| b).count() == 1 {
if let Some(axis) = periodic.iter().position(|&b| b) {
out[axis] = Some(parse_math_f64(&vals[0])?);
}
return Ok(out);
}
if vals.len() != ndim {
return Err(format!(
"origin must have one entry per margin ({ndim}), got {}",
vals.len()
));
}
for (i, v) in vals.iter().enumerate() {
let l = v.to_ascii_lowercase();
if matches!(l.as_str(), "none" | "null" | "na" | "") {
continue;
}
out[i] = Some(parse_math_f64(v)?);
}
Ok(out)
}
fn option_math_f64_any(
options: &BTreeMap<String, String>,
keys: &[&str],
) -> Result<Option<f64>, String> {
for key in keys {
if let Some(raw) = options.get(*key) {
return parse_math_f64(raw).map(Some);
}
}
Ok(None)
}
fn parse_periodic_domain_1d(
options: &BTreeMap<String, String>,
data_min: f64,
_data_max: f64,
) -> Result<(f64, f64), String> {
let start = option_math_f64_any(
options,
&[
"period_start",
"period-start",
"domain_start",
"domain-start",
"start",
],
)?;
let end = option_math_f64_any(
options,
&[
"period_end",
"period-end",
"domain_end",
"domain-end",
"end",
],
)?;
match (start, end) {
(Some(domain_start), Some(domain_end)) => {
let period = domain_end - domain_start;
if !period.is_finite() || period <= 0.0 {
return Err(format!(
"period_end must be greater than period_start for periodic smooths, got start={domain_start}, end={domain_end}"
));
}
Ok((domain_start, period))
}
(Some(_), None) | (None, Some(_)) => Err(
"periodic smooths require both period_start and period_end when either is provided"
.to_string(),
),
(None, None) => {
let explicit_period = parse_periods(options, &[true])?[0];
let explicit_origin = option_math_f64_any(
options,
&["origin", "period_origin", "period-origin", "domain_origin"],
)?;
match (explicit_period, explicit_origin) {
(Some(period), origin) => Ok((origin.unwrap_or(data_min), period)),
(None, _) => {
Err("periodic=true requires an explicit `period=<value>` (or \
`period_start=<lo>, period_end=<hi>`). Silent inference \
from data range would set period = data_max − data_min, \
which is sample-dependent and rarely what users mean \
(e.g. uniform draws on [0, 2π] give period ≈ 2π − 2ε, \
not 2π, leading to off-by-ε wrap discontinuities)."
.to_string())
}
}
}
}
}
pub fn parse_ps_internal_knots(
options: &BTreeMap<String, String>,
degree: usize,
default_internal_knots: usize,
) -> Result<(usize, bool), String> {
const MIN_EXPRESSIVE_INTERNAL_KNOTS: usize = 2;
let knots_internal = option_usize_strict(options, "knots")?;
let basis_dim = option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?;
if knots_internal.is_some() && basis_dim.is_some() {
return Err(
"ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)"
.to_string(),
);
}
if let Some(k) = basis_dim {
let min_k = degree + 1;
if k < min_k {
return Err(format!(
"ps/bspline smooth: k={} too small for degree {}; expected k >= {}",
k, degree, min_k
));
}
Ok(((k - min_k).max(MIN_EXPRESSIVE_INTERNAL_KNOTS), false))
} else {
Ok((
knots_internal.unwrap_or(default_internal_knots),
knots_internal.is_none(),
))
}
}
pub fn validate_known_options(
term_name: &str,
options: &BTreeMap<String, String>,
known: &[&str],
) -> Result<(), String> {
let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
for key in options.keys() {
if !known_set.contains(&key.as_str()) {
let key_l = key.to_ascii_lowercase();
let mut suggestions: Vec<&str> = known
.iter()
.filter(|k| {
let kl = k.to_ascii_lowercase();
kl.contains(&key_l) || key_l.contains(&kl) || {
let n = kl
.chars()
.zip(key_l.chars())
.take_while(|(a, b)| a == b)
.count();
n >= 3
}
})
.copied()
.collect();
suggestions.sort_unstable();
suggestions.dedup();
let hint = if suggestions.is_empty() {
String::new()
} else {
format!(" — did you mean one of [{}]?", suggestions.join(", "))
};
return Err(format!(
"{term_name}() does not accept option `{key}`{hint} Known options: [{}]",
{
let mut sorted = known.to_vec();
sorted.sort_unstable();
sorted.join(", ")
}
));
}
}
Ok(())
}
pub fn parse_countwith_basis_alias(
options: &BTreeMap<String, String>,
primarykey: &str,
default_count: usize,
) -> Result<usize, String> {
let primary = option_usize_strict(options, primarykey)?;
let basis_dim = option_usize_any_strict(
options,
&["k", "basis_dim", "basis-dim", "basisdim", "knots"],
)?;
if primary.is_some() && basis_dim.is_some() {
return Err(format!(
"specify either {}=<count> or k=<basis_dim> (not both)",
primarykey
));
}
Ok(primary.or(basis_dim).unwrap_or(default_count))
}
fn has_explicit_countwith_basis_alias(
options: &BTreeMap<String, String>,
primarykey: &str,
) -> bool {
options.contains_key(primarykey)
|| ["k", "basis_dim", "basis-dim", "basisdim", "knots"]
.iter()
.any(|alias| options.contains_key(*alias))
}
pub fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
let trimmed = raw.trim();
let lower = trimmed.to_ascii_lowercase();
if lower == "half" {
return Ok(MaternNu::Half);
}
let value = if let Some((num, den)) = trimmed.split_once('/') {
let num = num
.trim()
.parse::<f64>()
.map_err(|_| unsupported_matern_nu_message(raw))?;
let den = den
.trim()
.parse::<f64>()
.map_err(|_| unsupported_matern_nu_message(raw))?;
if den == 0.0 || !num.is_finite() || !den.is_finite() {
return Err(unsupported_matern_nu_message(raw));
}
num / den
} else {
trimmed
.parse::<f64>()
.map_err(|_| unsupported_matern_nu_message(raw))?
};
const TOL: f64 = 1e-12;
if (value - 0.5).abs() <= TOL {
Ok(MaternNu::Half)
} else if (value - 1.5).abs() <= TOL {
Ok(MaternNu::ThreeHalves)
} else if (value - 2.5).abs() <= TOL {
Ok(MaternNu::FiveHalves)
} else if (value - 3.5).abs() <= TOL {
Ok(MaternNu::SevenHalves)
} else if (value - 4.5).abs() <= TOL {
Ok(MaternNu::NineHalves)
} else {
Err(unsupported_matern_nu_message(raw))
}
}
fn unsupported_matern_nu_message(raw: &str) -> String {
format!(
"unsupported Matern nu '{raw}'; supported half-integer values are 1/2, 3/2, 5/2, 7/2, and 9/2"
)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DuchonPowerPolicy {
Explicit(usize),
MinimumAdmissibleForTripleOperator,
}
pub fn parse_duchon_power_policy(
options: &BTreeMap<String, String>,
) -> Result<DuchonPowerPolicy, String> {
if let Some(raw_nu) = options.get("nu") {
return Err(format!(
"Duchon smooths use power=<integer>, not nu='{}'. Use power=0, power=1, etc.",
raw_nu
));
}
match options.get("power") {
Some(raw) => raw.parse::<usize>().map(DuchonPowerPolicy::Explicit).map_err(|_| {
format!(
"invalid Duchon power '{}'; expected a non-negative integer such as power=0 or power=1",
raw
)
}),
None => Ok(DuchonPowerPolicy::MinimumAdmissibleForTripleOperator),
}
}
pub fn parse_duchon_power(options: &BTreeMap<String, String>) -> Result<usize, String> {
match parse_duchon_power_policy(options)? {
DuchonPowerPolicy::Explicit(power) => Ok(power),
DuchonPowerPolicy::MinimumAdmissibleForTripleOperator => Ok(2),
}
}
pub fn parse_duchon_order(
options: &BTreeMap<String, String>,
) -> Result<DuchonNullspaceOrder, String> {
match options.get("order") {
None => Ok(DuchonNullspaceOrder::Zero),
Some(raw) => match raw.parse::<usize>() {
Ok(0) => Ok(DuchonNullspaceOrder::Zero),
Ok(1) => Ok(DuchonNullspaceOrder::Linear),
Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
Err(_) => Err(format!(
"invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
raw
)),
},
}
}
fn parse_matern_identifiability(
options: &BTreeMap<String, String>,
) -> Result<MaternIdentifiability, String> {
let Some(raw) = options.get("identifiability").map(String::as_str) else {
return Ok(MaternIdentifiability::default());
};
match raw.trim().to_ascii_lowercase().as_str() {
"none" => Ok(MaternIdentifiability::None),
"sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered" => {
Ok(MaternIdentifiability::CenterSumToZero)
}
"linear" | "center_linear_orthogonal" | "center-linear-orthogonal" => {
Ok(MaternIdentifiability::CenterLinearOrthogonal)
}
other => Err(format!(
"invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
)),
}
}
fn parse_spatial_identifiability(
options: &BTreeMap<String, String>,
) -> Result<SpatialIdentifiability, String> {
let Some(raw) = options.get("identifiability").map(String::as_str) else {
return Ok(SpatialIdentifiability::default());
};
match raw.trim().to_ascii_lowercase().as_str() {
"none" => Ok(SpatialIdentifiability::None),
"orthogonal"
| "orthogonal_to_parametric"
| "orthogonal-to-parametric"
| "parametric_orthogonal" => Ok(SpatialIdentifiability::OrthogonalToParametric),
"frozen" => Err(
"spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric".to_string(),
),
other => Err(format!(
"invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
)),
}
}
fn parse_tensor_identifiability(
options: &BTreeMap<String, String>,
) -> Result<TensorBSplineIdentifiability, String> {
let Some(raw) = options.get("identifiability").map(String::as_str) else {
return Ok(TensorBSplineIdentifiability::default());
};
match raw.trim().to_ascii_lowercase().as_str() {
"none" => Ok(TensorBSplineIdentifiability::None),
"sum_tozero" | "sum-to-zero" | "centered" => Ok(TensorBSplineIdentifiability::SumToZero),
"frozen" | "frozen_transform" | "frozen-transform" => Err(
"tensor identifiability 'frozen' is internal-only; use none or sum_tozero".to_string(),
),
other => Err(format!(
"invalid tensor identifiability '{other}'; expected one of: none, sum_tozero"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference::formula_dsl::parse_formula;
use crate::inference::model::{DataSchema, SchemaColumn};
use ndarray::Array2;
use std::collections::BTreeMap;
fn continuous_dataset(headers: &[&str], rows: Vec<Vec<f64>>) -> Dataset {
let nrows = rows.len();
let ncols = headers.len();
let values = Array2::from_shape_vec(
(nrows, ncols),
rows.into_iter().flat_map(|row| row.into_iter()).collect(),
)
.expect("rectangular test data");
Dataset {
headers: headers.iter().map(|name| name.to_string()).collect(),
values,
schema: DataSchema {
columns: headers
.iter()
.map(|name| SchemaColumn {
name: name.to_string(),
kind: ColumnKindTag::Continuous,
levels: vec![],
})
.collect(),
},
column_kinds: vec![ColumnKindTag::Continuous; ncols],
}
}
fn inferred_tensor_basis_product(ds: &Dataset) -> usize {
let parsed = parse_formula("y ~ te(theta, h)").expect("parse tensor formula");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
ds,
&col_map,
&mut notes,
&ResourcePolicy::default_library(),
)
.expect("build tensor termspec");
let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected tensor smooth");
};
spec.marginalspecs
.iter()
.map(|marginal| match marginal.knotspec {
BSplineKnotSpec::Generate {
num_internal_knots, ..
} => num_internal_knots + marginal.degree + 1,
BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
BSplineKnotSpec::Automatic {
num_internal_knots: Some(num_internal_knots),
..
} => num_internal_knots + marginal.degree + 1,
BSplineKnotSpec::Automatic {
num_internal_knots: None,
..
} => panic!("test helper cannot infer automatic knot count"),
BSplineKnotSpec::Provided(ref knots) => {
knots.len().saturating_sub(marginal.degree + 1)
}
})
.product()
}
#[test]
fn parse_cylinder_periodic_options_match_requested_forms() {
let mut opts = BTreeMap::new();
opts.insert("periodic".to_string(), "[0]".to_string());
opts.insert("period".to_string(), "[2*pi, None]".to_string());
let axes = parse_periodic_axes(&opts, 2).expect("axes");
let periods = parse_periods(&opts, &axes).expect("periods");
assert_eq!(axes, vec![true, false]);
assert!((periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
assert_eq!(periods[1], None);
let mut bc_opts = BTreeMap::new();
bc_opts.insert("bc".to_string(), "['periodic', 'natural']".to_string());
bc_opts.insert("period".to_string(), "[2*pi, None]".to_string());
let bc_axes = parse_periodic_axes(&bc_opts, 2).expect("bc axes");
let bc_periods = parse_periods(&bc_opts, &bc_axes).expect("bc periods");
assert_eq!(bc_axes, vec![true, false]);
assert!((bc_periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
assert_eq!(bc_periods[1], None);
let mut unicode_opts = BTreeMap::new();
unicode_opts.insert("periodic".to_string(), "[0,1]".to_string());
unicode_opts.insert("period".to_string(), "[2π, τ]".to_string());
let unicode_axes = parse_periodic_axes(&unicode_opts, 2).expect("unicode axes");
let unicode_periods = parse_periods(&unicode_opts, &unicode_axes).expect("unicode periods");
assert_eq!(unicode_axes, vec![true, true]);
assert!((unicode_periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
assert!((unicode_periods[1].unwrap() - std::f64::consts::TAU).abs() < 1e-12);
}
#[test]
fn parse_single_axis_periodic_zero_as_axis_not_false() {
let mut opts = BTreeMap::new();
opts.insert("periodic".to_string(), "[0]".to_string());
opts.insert("period".to_string(), "2*pi".to_string());
opts.insert("origin".to_string(), "0".to_string());
let axes = parse_periodic_axes(&opts, 1).expect("axes");
let periods = parse_periods(&opts, &axes).expect("periods");
let origins = parse_period_origins(&opts, &axes).expect("origins");
assert_eq!(axes, vec![true]);
assert!((periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
assert_eq!(origins[0], Some(0.0));
}
#[test]
fn one_dimensional_bspline_accepts_bc_periodic_alias() {
let ds = continuous_dataset(
&["y", "theta"],
(0..16)
.map(|i| {
let theta = std::f64::consts::TAU * i as f64 / 16.0;
vec![theta.sin(), theta]
})
.collect(),
);
let parsed =
parse_formula("y ~ s(theta, bc=periodic, period=2*pi, origin=0, k=8)").expect("parse");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("periodic bc alias should build");
let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected 1D B-spline");
};
assert!(matches!(
&spec.knotspec,
BSplineKnotSpec::PeriodicUniform {
data_range,
num_basis: 8
} if *data_range == (0.0, std::f64::consts::TAU)
));
}
#[test]
fn default_sphere_smooth_uses_spherical_farthest_point_centers() {
let ds = continuous_dataset(
&["y", "lat", "lon"],
(0..24)
.map(|i| {
let t = i as f64 / 24.0;
let lat = -60.0 + 120.0 * t;
let lon = -180.0 + 360.0 * ((7 * i) % 24) as f64 / 24.0;
vec![lat.to_radians().sin(), lat, lon]
})
.collect(),
);
let parsed = parse_formula("y ~ sphere(lat, lon)").expect("parse");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build sphere termspec");
let SmoothBasisSpec::Sphere { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected sphere term");
};
assert!(matches!(
spec.center_strategy,
CenterStrategy::FarthestPoint { .. }
));
}
#[test]
fn one_dimensional_duchon_defaults_to_scale_free_length_scale() {
let ds = continuous_dataset(
&["y", "x"],
(0..32)
.map(|i| {
let x = i as f64 / 31.0;
vec![(std::f64::consts::TAU * x).sin(), x]
})
.collect(),
);
let parsed = parse_formula("y ~ duchon(x)").expect("parse");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build default duchon termspec");
let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected Duchon term");
};
assert_eq!(spec.length_scale, None);
}
#[test]
fn one_dimensional_duchon_length_scale_opts_into_hybrid_mode() {
let ds = continuous_dataset(
&["y", "x"],
(0..32)
.map(|i| {
let x = i as f64 / 31.0;
vec![(std::f64::consts::TAU * x).sin(), x]
})
.collect(),
);
let parsed = parse_formula("y ~ duchon(x, length_scale=0.25)").expect("parse");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build hybrid duchon termspec");
let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected Duchon term");
};
assert_eq!(spec.length_scale, Some(0.25));
}
#[test]
fn parse_matern_nu_accepts_equivalent_half_integer_forms() {
let cases = [
("1/2", MaternNu::Half),
(" 1 / 2 ", MaternNu::Half),
(".5", MaternNu::Half),
("0.50", MaternNu::Half),
("half", MaternNu::Half),
("3 / 2", MaternNu::ThreeHalves),
("1.50", MaternNu::ThreeHalves),
("5 / 2", MaternNu::FiveHalves),
("2.500000000000", MaternNu::FiveHalves),
("7 / 2", MaternNu::SevenHalves),
("3.50", MaternNu::SevenHalves),
("9 / 2", MaternNu::NineHalves),
("4.50", MaternNu::NineHalves),
];
for (raw, expected) in cases {
let parsed = parse_matern_nu(raw).expect(raw);
assert!(
matches!(
(parsed, expected),
(MaternNu::Half, MaternNu::Half)
| (MaternNu::ThreeHalves, MaternNu::ThreeHalves)
| (MaternNu::FiveHalves, MaternNu::FiveHalves)
| (MaternNu::SevenHalves, MaternNu::SevenHalves)
| (MaternNu::NineHalves, MaternNu::NineHalves)
),
"parsed {raw:?} as {parsed:?}, expected {expected:?}"
);
}
}
#[test]
fn parse_matern_nu_rejects_unsupported_or_invalid_values() {
for raw in ["1", "2", "11/2", "1/0", "nan", "fast"] {
let err = parse_matern_nu(raw).expect_err(raw);
assert!(
err.contains("supported half-integer values"),
"unexpected error for {raw:?}: {err}"
);
}
}
#[test]
fn parse_ps_k_promotes_underexpressive_cubic_basis() {
let mut opts = BTreeMap::new();
opts.insert("k".to_string(), "4".to_string());
let (internal, inferred) = parse_ps_internal_knots(&opts, 3, 20).expect("k=4");
assert_eq!(internal, 2);
assert!(!inferred);
opts.insert("k".to_string(), "6".to_string());
let (internal, inferred) = parse_ps_internal_knots(&opts, 3, 20).expect("k=6");
assert_eq!(internal, 2);
assert!(!inferred);
opts.insert("k".to_string(), "10".to_string());
let (internal, inferred) = parse_ps_internal_knots(&opts, 3, 20).expect("k=10");
assert_eq!(internal, 6);
assert!(!inferred);
}
#[test]
fn parse_tensor_periods_and_origins_aliases() {
let mut opts = BTreeMap::new();
opts.insert("bc".to_string(), "['periodic', 'periodic']".to_string());
opts.insert("periods".to_string(), "[7, 24]".to_string());
opts.insert("origins".to_string(), "[0, -12]".to_string());
let axes = parse_periodic_axes(&opts, 2).expect("axes");
let periods = parse_periods(&opts, &axes).expect("periods");
let origins = parse_period_origins(&opts, &axes).expect("origins");
assert_eq!(axes, vec![true, true]);
assert_eq!(periods, vec![Some(7.0), Some(24.0)]);
assert_eq!(origins, vec![Some(0.0), Some(-12.0)]);
}
#[test]
fn tensor_smooth_honors_per_margin_k_list() {
let ds = continuous_dataset(
&["y", "theta", "h"],
(0..20)
.map(|i| {
let theta = std::f64::consts::TAU * i as f64 / 20.0;
let h = -1.0 + 2.0 * (i % 5) as f64 / 4.0;
vec![theta.cos() + h, theta, h]
})
.collect(),
);
let parsed = parse_formula(
"y ~ te(theta, h, periodic=[0], period=[2*pi, None], origin=[0, None], k=[9,5])",
)
.expect("parse tensor formula");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build tensor terms");
let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected tensor B-spline");
};
let dims = spec
.marginalspecs
.iter()
.map(|m| match m.knotspec {
BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
BSplineKnotSpec::Generate {
num_internal_knots, ..
} => num_internal_knots + m.degree + 1,
_ => panic!("unexpected tensor marginal knotspec"),
})
.collect::<Vec<_>>();
assert_eq!(dims, vec![9, 5]);
}
#[test]
fn explicit_basis_sizes_are_not_small_n_clamped() {
let ds = continuous_dataset(
&["y", "x1", "x2", "x3", "x4", "x5"],
(0..12)
.map(|i| {
let x = i as f64 / 11.0;
vec![x.sin(), x, x * x, x + 0.1, 1.0 - x, (2.0 * x).sin()]
})
.collect(),
);
let parsed = parse_formula("y ~ s(x1, k=10) + s(x2) + s(x3) + s(x4) + s(x5)")
.expect("parse multi-smooth formula");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build multi-smooth terms");
let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected first smooth to be B-spline");
};
assert!(matches!(
&spec.knotspec,
BSplineKnotSpec::Generate {
num_internal_knots: 6,
..
}
));
}
#[test]
fn explicit_duchon_centers_are_not_small_n_bumped() {
let ds = continuous_dataset(
&["y", "x1", "x2", "x3", "x4", "x5"],
(0..12)
.map(|i| {
let x = i as f64 / 11.0;
vec![x.sin(), x, x * x, x + 0.1, 1.0 - x, (2.0 * x).sin()]
})
.collect(),
);
let parsed = parse_formula("y ~ duchon(x1, centers=3) + s(x2) + s(x3) + s(x4) + s(x5)")
.expect("parse multi-smooth formula");
let col_map = ds.column_map();
let mut notes = Vec::new();
let terms = build_termspec(
&parsed.terms,
&ds,
&col_map,
&mut notes,
&crate::resource::ResourcePolicy::default_library(),
)
.expect("build multi-smooth terms");
let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
panic!("expected first smooth to be Duchon");
};
assert!(matches!(
spec.center_strategy,
CenterStrategy::EqualMass { num_centers: 3 }
));
}
#[test]
fn inferred_tensor_basis_cap_uses_coordinate_support_not_duplicate_rows() {
let mut unique_rows = Vec::new();
for i in 0..50 {
let theta = i as f64 / 50.0;
for j in 0..16 {
let h = -1.0 + 2.0 * (j as f64) / 15.0;
let y = theta.cos() + h;
unique_rows.push(vec![y, theta, h]);
}
}
let mut repeated_rows = Vec::new();
for _ in 0..12 {
repeated_rows.extend(unique_rows.iter().cloned());
}
let unique = continuous_dataset(&["y", "theta", "h"], unique_rows);
let repeated = continuous_dataset(&["y", "theta", "h"], repeated_rows);
let unique_basis = inferred_tensor_basis_product(&unique);
let repeated_basis = inferred_tensor_basis_product(&repeated);
assert_eq!(
unique_basis, repeated_basis,
"duplicating existing tensor coordinates must not inflate inferred basis width"
);
}
#[test]
fn parse_bspline_boundary_aliases_and_side_selector() {
let mut opts = BTreeMap::new();
opts.insert("bc".to_string(), "anchored".to_string());
opts.insert("side".to_string(), "left".to_string());
opts.insert("anchor".to_string(), "2.5".to_string());
let err = parse_bspline_boundary_conditions(&opts)
.expect_err("non-zero left anchor must be rejected");
assert!(
err.contains("left") && err.contains("2.5"),
"rejection should name the affected side and value: {err}"
);
let mut opts = BTreeMap::new();
opts.insert("start_bc".to_string(), "clamped".to_string());
opts.insert("end_bc".to_string(), "zero".to_string());
opts.insert("right_anchor".to_string(), "-1.0".to_string());
let err = parse_bspline_boundary_conditions(&opts)
.expect_err("non-zero right anchor must be rejected");
assert!(
err.contains("right") && err.contains("-1"),
"rejection should name the affected side and value: {err}"
);
let mut opts = BTreeMap::new();
opts.insert("start_bc".to_string(), "clamped".to_string());
opts.insert("end_bc".to_string(), "zero".to_string());
let parsed = parse_bspline_boundary_conditions(&opts).expect("boundary aliases");
assert!(matches!(
parsed.left,
BSplineEndpointBoundaryCondition::Clamped
));
assert!(matches!(
parsed.right,
BSplineEndpointBoundaryCondition::Anchored { value } if value.abs() < 1e-12
));
}
}