use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
use ndarray::{Array2, ArrayView1};
use crate::basis::{
BSplineBasisSpec, BSplineBoundaryConditions, BSplineEndpointBoundaryCondition,
BSplineIdentifiability, BSplineKnotSpec, CenterCountRequest, CenterStrategy,
ConstantCurvatureBasisSpec, ConstantCurvatureIdentifiability, DuchonBasisSpec,
DuchonNullspaceOrder, DuchonOperatorPenaltySpec, DuchonSpectralBasis, MaternBasisSpec,
MaternIdentifiability, MaternLengthScale, MaternNu, MeasureJetBasisSpec,
MeasureJetIdentifiability, OneDimensionalBoundary, SpatialIdentifiability, SphereMethod,
SphereWahbaKernel, SphericalSplineBasisSpec, SphericalSplineIdentifiability,
ThinPlateBasisSpec, auto_spatial_center_strategy, count_unique_coordinate_rows,
default_num_centers, default_spatial_center_strategy, default_spherical_harmonic_degree,
plan_spatial_basis, select_r_uniform_subsample_centers, thin_plate_penalty_order,
};
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, parsed_term_column_names,
strip_quotes,
};
use crate::smooth::{
BySmoothKind, ByVarKind, ByVariableSpec, FactorSmoothFlavour, FactorSmoothSpec,
LinearCoefficientGeometry, LinearTermSpec, RandomEffectTermSpec, ShapeConstraint,
SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
TensorBSplinePenaltyDecomposition, TensorBSplineSpec, TermCollectionSpec,
};
use gam_data::{ColumnKindTag, DataError, EncodedDataset as Dataset};
use gam_problem::types::ColIdx;
use gam_runtime::resource::ResourcePolicy;
const DEFAULT_BSPLINE_DEGREE: usize = 3;
const DEFAULT_PENALTY_ORDER: usize = 2;
const SPHERE_TRUNCATION_LMAX_RANGE: std::ops::RangeInclusive<usize> = 5..=200;
const CYCLIC_DEFAULT_BASIS_DIM: usize = 12;
const FACTOR_SMOOTH_DEFAULT_BASIS_DIM: usize = 10;
const DEFAULT_PCA_CHUNK_SIZE: usize = 4096;
#[derive(Clone, Debug)]
pub enum TermBuilderError {
MissingColumn { reason: String },
ColumnNotFound {
name: String,
role: Option<String>,
available: Vec<String>,
similar: Vec<String>,
tsv_hint: bool,
},
IncompatibleConfig { reason: String },
InvalidOption { reason: String },
UnsupportedFeature { reason: String },
DegenerateData { reason: String },
MalformedFormula { reason: String },
}
impl std::fmt::Display for TermBuilderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TermBuilderError::MissingColumn { reason }
| TermBuilderError::IncompatibleConfig { reason }
| TermBuilderError::InvalidOption { reason }
| TermBuilderError::UnsupportedFeature { reason }
| TermBuilderError::DegenerateData { reason }
| TermBuilderError::MalformedFormula { reason } => f.write_str(reason),
TermBuilderError::ColumnNotFound {
name,
role,
available,
similar,
tsv_hint,
} => {
let canonical = DataError::ColumnNotFound {
name: name.clone(),
role: role.clone(),
available: available.clone(),
similar: similar.clone(),
tsv_hint: *tsv_hint,
};
std::fmt::Display::fmt(&canonical, f)
}
}
}
}
impl From<TermBuilderError> for String {
fn from(err: TermBuilderError) -> String {
err.to_string()
}
}
impl From<String> for TermBuilderError {
fn from(reason: String) -> Self {
Self::IncompatibleConfig { reason }
}
}
impl From<DataError> for TermBuilderError {
fn from(err: DataError) -> Self {
match err {
DataError::ColumnNotFound {
name,
role,
available,
similar,
tsv_hint,
} => Self::ColumnNotFound {
name,
role,
available,
similar,
tsv_hint,
},
DataError::SchemaMismatch { reason }
| DataError::ParseError { reason }
| DataError::EncodingFailure { reason }
| DataError::EmptyInput { reason }
| DataError::InvalidValue { reason } => Self::MissingColumn { reason },
DataError::DegenerateColumn { column, problem } => Self::DegenerateData {
reason: format!("column '{column}' {problem}"),
},
}
}
}
impl TermBuilderError {
#[inline]
fn missing_column(reason: impl Into<String>) -> Self {
TermBuilderError::MissingColumn {
reason: reason.into(),
}
}
#[inline]
fn incompatible_config(reason: impl Into<String>) -> Self {
TermBuilderError::IncompatibleConfig {
reason: reason.into(),
}
}
#[inline]
fn invalid_option(reason: impl Into<String>) -> Self {
TermBuilderError::InvalidOption {
reason: reason.into(),
}
}
#[inline]
fn unsupported_feature(reason: impl Into<String>) -> Self {
TermBuilderError::UnsupportedFeature {
reason: reason.into(),
}
}
#[inline]
fn degenerate_data(reason: impl Into<String>) -> Self {
TermBuilderError::DegenerateData {
reason: reason.into(),
}
}
#[inline]
fn malformed_formula(reason: impl Into<String>) -> Self {
TermBuilderError::MalformedFormula {
reason: reason.into(),
}
}
}
pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, DataError> {
col_map
.get(name)
.copied()
.ok_or_else(|| DataError::column_not_found(col_map, name, None))
}
pub fn resolve_role_col(
col_map: &HashMap<String, usize>,
name: &str,
role: &str,
) -> Result<usize, DataError> {
col_map
.get(name)
.copied()
.ok_or_else(|| DataError::column_not_found(col_map, name, Some(role)))
}
fn encoded_levels_for_column(ds: &Dataset, col: ColIdx) -> Vec<(u64, String)> {
let mut seen = BTreeSet::<u64>::new();
for value in ds.values.column(col.get()) {
if value.is_finite() {
seen.insert(gam_data::canonical_level_bits(*value));
}
}
let schema_levels = ds
.schema
.columns
.get(col.get())
.map(|column| column.levels.as_slice())
.unwrap_or(&[]);
seen.into_iter()
.enumerate()
.map(|(idx, bits)| {
let fallback = format!("level{}", idx + 1);
let label = schema_levels.get(idx).cloned().unwrap_or(fallback);
(bits, label)
})
.collect()
}
const DEFAULT_SIZING_ROWS_OPTION: &str = "__default_sizing_rows";
fn min_categorical_by_level_rows(ds: &Dataset, by_col: usize) -> Option<usize> {
let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
for value in ds.values.column(by_col) {
if value.is_finite() {
*counts
.entry(gam_data::canonical_level_bits(*value))
.or_insert(0) += 1;
}
}
counts.values().copied().min()
}
fn inject_by_level_sizing_rows(
inner_options: &mut BTreeMap<String, String>,
ds: &Dataset,
by_col: usize,
) {
if matches!(
ds.column_kinds.get(by_col).copied(),
Some(ColumnKindTag::Categorical)
) && let Some(min_rows) = min_categorical_by_level_rows(ds, by_col)
{
inner_options.insert(DEFAULT_SIZING_ROWS_OPTION.to_string(), min_rows.to_string());
}
}
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 const MARGINAL_SLOPE_Z_ALIAS: &str = "z";
pub fn marginal_slope_z_alias_is_live(col_map: &HashMap<String, usize>, z_column: &str) -> bool {
col_map.contains_key(z_column) && !col_map.contains_key(MARGINAL_SLOPE_Z_ALIAS)
}
pub fn build_termspec(
terms: &[ParsedTerm],
ds: &Dataset,
col_map: &HashMap<String, usize>,
inference_notes: &mut Vec<String>,
policy: &ResourcePolicy,
) -> Result<TermCollectionSpec, TermBuilderError> {
let mut consumed_columns = BTreeSet::new();
parsed_term_column_names(terms, &mut consumed_columns);
for name in consumed_columns {
let column = resolve_col(col_map, &name)?;
if let Some(row) = ds
.values
.column(column)
.iter()
.position(|value| !value.is_finite())
{
return Err(TermBuilderError::degenerate_data(format!(
"model term column '{name}' contains a non-finite value at row {}",
row + 1
)));
}
}
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,
double_penalty,
coefficient_min,
coefficient_max,
} => {
let col = resolve_col(col_map, name)?;
let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
TermBuilderError::missing_column(format!(
"internal column-kind lookup failed for '{name}'"
))
.to_string()
})?;
if *explicit {
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
feature_cols: vec![col],
categorical_levels: vec![],
double_penalty: *double_penalty,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: *coefficient_min,
coefficient_max: *coefficient_max,
frozen_function_mass: None,
});
} else {
match auto_kind {
ColumnKindTag::Continuous | ColumnKindTag::Binary => {
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
feature_cols: vec![col],
categorical_levels: vec![],
double_penalty: *double_penalty,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: *coefficient_min,
coefficient_max: *coefficient_max,
frozen_function_mass: None,
});
}
ColumnKindTag::Categorical => {
if coefficient_min.is_some() || coefficient_max.is_some() {
return Err(TermBuilderError::incompatible_config(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,
penalized: true,
frozen_levels: None,
lenient_unseen: false,
});
}
}
}
}
ParsedTerm::BoundedLinear {
name,
min,
max,
prior,
double_penalty,
} => {
let col = resolve_col(col_map, name)?;
let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
TermBuilderError::missing_column(format!(
"internal column-kind lookup failed for '{name}'"
))
.to_string()
})?;
if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
return Err(TermBuilderError::incompatible_config(format!(
"bounded() currently supports only numeric columns, got categorical '{name}'"
)));
}
linear_terms.push(LinearTermSpec {
name: name.clone(),
feature_col: col,
feature_cols: vec![col],
categorical_levels: vec![],
double_penalty: *double_penalty,
coefficient_geometry: LinearCoefficientGeometry::Bounded {
min: *min,
max: *max,
prior: prior.clone(),
},
coefficient_min: None,
coefficient_max: None,
frozen_function_mass: None,
});
}
ParsedTerm::RandomEffect {
name,
lenient_unseen,
} => {
let col = resolve_col(col_map, name)?;
random_terms.push(RandomEffectTermSpec {
name: name.clone(),
feature_col: col,
drop_first_level: false,
penalized: true,
frozen_levels: None,
lenient_unseen: *lenient_unseen,
});
}
ParsedTerm::Smooth {
label,
vars,
kind,
options,
} => {
let smooth_vars = vars.clone();
let by_name = options.get("by").cloned();
let cols = smooth_vars
.iter()
.map(|v| resolve_col(col_map, v))
.collect::<Result<Vec<_>, _>>()?;
let mut inner_options = options.clone();
inner_options.remove("by");
inner_options.remove("ordered");
let shape = match inner_options.remove("shape") {
None => ShapeConstraint::None,
Some(raw) => crate::smooth::parse_shape_constraint(&raw)
.map_err(TermBuilderError::invalid_option)?,
};
if let Some(by_name) = by_name.as_deref() {
let by_col = resolve_col(col_map, by_name)?;
inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
}
let inner_basis = build_smooth_basis(
*kind,
&smooth_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)?;
match ds.column_kinds.get(by_col).copied().ok_or_else(|| {
format!("internal column-kind lookup failed for by variable '{by_name}'")
})? {
ColumnKindTag::Categorical => {
let levels = encoded_levels_for_column(ds, ColIdx::new(by_col));
let penalized_group_owner_present =
terms.iter().any(|other| match other {
ParsedTerm::RandomEffect { name, .. } => name == &by_name,
ParsedTerm::Linear {
name,
explicit: false,
..
} if name == &by_name => col_map
.get(name)
.and_then(|c| ds.column_kinds.get(*c).copied())
.map(|kind| matches!(kind, ColumnKindTag::Categorical))
.unwrap_or(false),
_ => false,
});
if !random_terms.iter().any(|rt| rt.name == by_name)
&& !penalized_group_owner_present
{
random_terms.push(RandomEffectTermSpec {
name: by_name.clone(),
feature_col: by_col,
drop_first_level: true,
penalized: false,
frozen_levels: None,
lenient_unseen: false,
});
}
for (level_bits, level_label) in levels {
smooth_terms.push(SmoothTermSpec {
frozen_parametric_residualization: None,
name: format!("{label}:by={by_name}[{level_label}]"),
basis: SmoothBasisSpec::ByVariable {
inner: Box::new(inner_basis.clone()),
by_col,
kind: BySmoothKind::Level { level_bits },
by: ByVariableSpec::Level {
value_bits: level_bits,
label: level_label,
},
},
shape: shape.clone(),
joint_null_rotation: None,
});
}
}
ColumnKindTag::Binary | ColumnKindTag::Continuous => {
let mut inner_basis = inner_basis;
if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
&& !options.contains_key("identifiability")
{
crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner_basis);
}
smooth_terms.push(SmoothTermSpec {
frozen_parametric_residualization: None,
name: label.clone(),
basis: SmoothBasisSpec::ByVariable {
inner: Box::new(inner_basis),
by_col,
kind: BySmoothKind::Numeric,
by: ByVariableSpec::Numeric,
},
shape,
joint_null_rotation: None,
});
}
}
} else {
smooth_terms.push(SmoothTermSpec {
frozen_parametric_residualization: None,
name: label.clone(),
basis: inner_basis,
shape,
joint_null_rotation: None,
});
}
}
ParsedTerm::LinkWiggle { .. }
| ParsedTerm::TimeWiggle { .. }
| ParsedTerm::LinkConfig { .. }
| ParsedTerm::SurvivalConfig { .. } => {
}
ParsedTerm::SlopeSurface { .. } => {
return Err(TermBuilderError::malformed_formula(
"slope(...) declarations must be resolved by the marginal-slope formula path before building a term spec",
));
}
ParsedTerm::Interaction {
vars,
double_penalty,
} => {
let main_effect_present = |target: &str| -> bool {
terms.iter().any(|other| match other {
ParsedTerm::Linear { name, .. }
| ParsedTerm::BoundedLinear { name, .. }
| ParsedTerm::RandomEffect { name, .. } => name == target,
_ => false,
})
};
let parent_present = |drop_var: &str| -> bool {
vars.iter()
.filter(|v| v.as_str() != drop_var)
.all(|v| main_effect_present(v))
};
let mut numeric_cols = Vec::<usize>::new();
let mut categorical_factors =
Vec::<(String, usize, Vec<(u64, String)>, bool)>::new();
for var in vars {
let col = resolve_col(col_map, var)?;
let kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
TermBuilderError::missing_column(format!(
"internal column-kind lookup failed for '{var}'"
))
.to_string()
})?;
match kind {
ColumnKindTag::Continuous | ColumnKindTag::Binary => numeric_cols.push(col),
ColumnKindTag::Categorical => {
let mut levels = encoded_levels_for_column(ds, ColIdx::new(col));
let treatment_coded = parent_present(var);
if treatment_coded && levels.len() > 1 {
levels.remove(0);
}
if levels.is_empty() {
return Err(TermBuilderError::incompatible_config(format!(
"interaction `{}` references categorical column `{var}` with no usable levels",
vars.join(":")
)));
}
categorical_factors.push((var.clone(), col, levels, treatment_coded));
}
}
}
let label = vars.join(":");
if categorical_factors.is_empty() {
linear_terms.push(LinearTermSpec {
name: label,
feature_col: numeric_cols[0],
feature_cols: numeric_cols,
categorical_levels: vec![],
double_penalty: *double_penalty,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
frozen_function_mass: None,
});
inference_notes.push(format!(
"wired linear interaction `{}` as product of numeric columns",
vars.join(":")
));
} else {
let mut cells: Vec<Vec<(usize, u64, String)>> = vec![Vec::new()];
for (_var, col, levels, _treatment_coded) in &categorical_factors {
let mut next = Vec::with_capacity(cells.len() * levels.len());
for cell in &cells {
for (bits, level_label) in levels {
let mut extended = cell.clone();
extended.push((*col, *bits, level_label.clone()));
next.push(extended);
}
}
cells = next;
}
let any_dummy_coded = categorical_factors
.iter()
.any(|(_, _, _, treatment_coded)| !*treatment_coded);
if numeric_cols.is_empty() && any_dummy_coded {
let reference_cell: Vec<(usize, u64)> = categorical_factors
.iter()
.map(|(_, col, _, _)| {
let levels = encoded_levels_for_column(ds, ColIdx::new(*col));
(*col, levels[0].0)
})
.collect();
cells.retain(|cell| {
!reference_cell.iter().all(|(rcol, rbits)| {
cell.iter()
.any(|(col, bits, _)| col == rcol && bits == rbits)
})
});
}
let n_cells = cells.len();
for cell in cells {
let cell_suffix = cell
.iter()
.map(|(_, _, level_label)| level_label.as_str())
.collect::<Vec<_>>()
.join(":");
let categorical_levels =
cell.iter().map(|(col, bits, _)| (*col, *bits)).collect();
let feature_col = numeric_cols
.first()
.copied()
.unwrap_or(categorical_factors[0].1);
linear_terms.push(LinearTermSpec {
name: format!("{label}:{cell_suffix}"),
feature_col,
feature_cols: numeric_cols.clone(),
categorical_levels,
double_penalty: *double_penalty,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
frozen_function_mass: None,
});
}
let all_treatment_coded = !any_dummy_coded;
let coding = if all_treatment_coded {
"treatment-coded"
} else {
"marginality-aware (full dummy / saturated)"
};
inference_notes.push(format!(
"wired factor-aware linear interaction `{}` as {} {} cell column(s)",
vars.join(":"),
n_cells,
coding
));
}
}
}
}
let spec = TermCollectionSpec {
linear_terms,
random_effect_terms: random_terms,
smooth_terms,
};
inference_notes.extend(crate::smooth::collect_smooth_structure_warnings(
&spec,
&ds.headers,
"model",
));
Ok(spec)
}
fn split_list_option(raw: &str) -> Vec<String> {
let t = raw.trim();
let inner = t
.strip_prefix('[')
.and_then(|u| u.strip_suffix(']'))
.or_else(|| {
t.strip_prefix("c(")
.or_else(|| t.strip_prefix("C("))
.or_else(|| t.strip_prefix('('))
.and_then(|u| u.strip_suffix(')'))
})
.unwrap_or(t);
inner
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect()
}
fn parse_numeric_expr(raw: &str) -> Result<f64, String> {
let mut acc = 1.0f64;
let normalized = raw.replace(' ', "");
if normalized.eq_ignore_ascii_case("none") {
return Err("None is not numeric".to_string());
}
for factor in normalized.split('*') {
if factor.is_empty() {
return Err(format!("invalid numeric expression '{raw}'"));
}
let value = if factor.eq_ignore_ascii_case("pi") || factor == "π" {
std::f64::consts::PI
} else if factor.eq_ignore_ascii_case("tau") || factor == "τ" {
std::f64::consts::TAU
} else if let Some(prefix) = factor
.strip_suffix("pi")
.or_else(|| factor.strip_suffix("π"))
{
let coefficient = if prefix.is_empty() {
1.0
} else {
prefix
.parse::<f64>()
.map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
};
coefficient * std::f64::consts::PI
} else if let Some(prefix) = factor
.strip_suffix("tau")
.or_else(|| factor.strip_suffix("τ"))
{
let coefficient = if prefix.is_empty() {
1.0
} else {
prefix
.parse::<f64>()
.map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
};
coefficient * std::f64::consts::TAU
} else {
factor
.parse::<f64>()
.map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
};
acc *= value;
}
Ok(acc)
}
fn option_numeric_expr(
options: &BTreeMap<String, String>,
key: &str,
) -> Result<Option<f64>, String> {
match options.get(key) {
None => Ok(None),
Some(raw) => parse_numeric_expr(raw)
.map(Some)
.map_err(|err| format!("option `{key}={raw}` is not a valid numeric value: {err}")),
}
}
fn parse_periods_option(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<Option<Vec<Option<f64>>>, String> {
let Some(raw) = options.get("period") else {
return Ok(None);
};
let values = split_list_option(raw);
let mut periods = vec![None; dim];
if values.len() == 1 && dim == 1 {
periods[0] = Some(parse_numeric_expr(&values[0])?);
} else {
if values.len() != dim {
return Err(format!(
"period list length {} must match smooth dimension {}",
values.len(),
dim
));
}
for (i, v) in values.iter().enumerate() {
if v.eq_ignore_ascii_case("none") {
continue;
}
periods[i] = Some(parse_numeric_expr(v)?);
}
}
Ok(Some(periods))
}
fn parse_periodic_axes_option(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<Option<Vec<Option<f64>>>, String> {
let Some(raw_axes) = options.get("periodic").or_else(|| options.get("cyclic")) else {
let declared = parse_periods_option(options, dim)?;
return Ok(match declared {
Some(periods) if periods.iter().any(Option::is_some) => Some(periods),
_ => None,
});
};
let mut periods = parse_periods_option(options, dim)?.unwrap_or_else(|| vec![None; dim]);
let lowered = raw_axes.trim().to_ascii_lowercase();
if matches!(lowered.as_str(), "true" | "yes" | "y") {
return Ok(Some(periods));
}
if matches!(lowered.as_str(), "false" | "no" | "n") {
return Ok(None);
}
let axes = split_list_option(raw_axes);
if axes.is_empty() {
return Ok(Some(periods));
}
let is_bool = |t: &str| {
matches!(
t.to_ascii_lowercase().as_str(),
"true" | "yes" | "y" | "false" | "no" | "n"
)
};
let is_truthy = |t: &str| matches!(t.to_ascii_lowercase().as_str(), "true" | "yes" | "y");
if axes.len() == 1 && is_bool(&axes[0]) {
if !is_truthy(&axes[0]) {
return Ok(None);
}
return Ok(Some(periods));
}
if axes.iter().all(|a| is_bool(a)) {
if axes.len() != dim {
return Err(format!(
"periodic flag list length {} must match smooth dimension {dim}",
axes.len()
));
}
if !axes.iter().any(|a| is_truthy(a)) {
return Ok(None);
}
for (i, a) in axes.iter().enumerate() {
if !is_truthy(a) {
periods[i] = None;
}
}
return Ok(Some(periods));
}
for a in &axes {
let axis = a
.parse::<usize>()
.map_err(|err| format!("invalid periodic axis '{a}': {err}"))?;
if axis >= dim {
return Err(format!(
"periodic axis {axis} out of range for {dim}D smooth"
));
}
if periods[axis].is_none() {
return Err(format!(
"periodic axis {axis} requires period[{axis}] to be finite"
));
}
}
let listed: std::collections::BTreeSet<usize> = axes
.iter()
.filter_map(|a| a.parse::<usize>().ok())
.collect();
for i in 0..dim {
if !listed.contains(&i) {
periods[i] = None;
}
}
Ok(Some(periods))
}
fn parse_option_list(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
let inner = trimmed
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.or_else(|| {
trimmed
.strip_prefix("c(")
.or_else(|| trimmed.strip_prefix("C("))
.or_else(|| trimmed.strip_prefix('('))
.and_then(|v| v.strip_suffix(')'))
})
.unwrap_or(trimmed);
inner
.split(',')
.map(|v| {
v.trim()
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase()
})
.filter(|v| !v.is_empty())
.collect()
}
fn axes_with_declared_period(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<Vec<bool>, String> {
let mut axes = vec![false; dim];
if let Some(raw) = options.get("period").or_else(|| options.get("periods")) {
let values = split_list_option(raw);
if values.len() == dim {
for (axis, value) in values.iter().enumerate() {
if !value.trim().eq_ignore_ascii_case("none") {
axes[axis] = true;
}
}
}
}
if dim == 1
&& PERIOD_ENDPOINT_OPTION_KEYS
.iter()
.any(|key| options.contains_key(*key))
{
axes[0] = true;
}
Ok(axes)
}
const PERIOD_ENDPOINT_OPTION_KEYS: [&str; 4] = ["period_start", "period_end", "start", "end"];
const PERIOD_LENGTH_OPTION_KEYS: [&str; 2] = ["period", "periods"];
const PERIOD_ORIGIN_OPTION_KEYS: [&str; 5] = [
"origin",
"origins",
"period_origin",
"period-origin",
"domain_origin",
];
fn reject_unconsumable_period_declaration(
term_name: &str,
options: &BTreeMap<String, String>,
periodic_axes: &[bool],
) -> Result<(), String> {
if periodic_axes.iter().any(|periodic| *periodic) {
return Ok(());
}
let dim = periodic_axes.len();
if let Some(key) = PERIOD_LENGTH_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
let hint = if dim > 1 {
format!(
"a scalar `{key}=` does not say which of the {dim} margins wraps; write one entry \
per margin (e.g. {key}=[<value>, None]) or name the axis with periodic=<axis>"
)
} else {
"declare it on a periodic axis or drop it".to_string()
};
return Err(TermBuilderError::invalid_option(format!(
"{term_name}(): `{key}=` declares a period, but no axis of this smooth is periodic — {hint}"
))
.to_string());
}
if let Some(key) = PERIOD_ORIGIN_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::invalid_option(format!(
"{term_name}(): `{key}=` places the start of a periodic domain, but this smooth \
declares no period; add period=<value> or drop it"
))
.to_string());
}
if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::invalid_option(format!(
"{term_name}(): `{key}=` declares a periodic domain endpoint, but no axis of this \
smooth is periodic; on a tensor smooth use periods=[...] with origins=[...], which \
name their margin"
))
.to_string());
}
Ok(())
}
fn reject_unconsumable_radial_period_declaration(
term_name: &str,
options: &BTreeMap<String, String>,
dim: usize,
periodic: Option<&[Option<f64>]>,
boundary_is_cyclic: bool,
) -> Result<(), String> {
if dim > 1
&& let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::invalid_option(format!(
"{term_name}(): `{key}=` names one axis's periodic domain and is only read on a \
one-dimensional radial smooth; this one has {dim} covariates, so give the wrap as \
period=[…] with one entry per axis"
))
.to_string());
}
let any_axis_wraps = boundary_is_cyclic
|| periodic.is_some_and(|axes| {
(dim == 1 && !axes.is_empty()) || axes.iter().any(Option::is_some)
});
if any_axis_wraps {
return Ok(());
}
let declared = ["periodic", "cyclic"]
.iter()
.chain(PERIOD_LENGTH_OPTION_KEYS.iter())
.chain(PERIOD_ENDPOINT_OPTION_KEYS.iter())
.find(|key| options.contains_key(**key));
let Some(key) = declared else {
return Ok(());
};
if matches!(*key, "periodic" | "cyclic")
&& options
.get(*key)
.map(|raw| raw.trim().to_ascii_lowercase())
.is_some_and(|raw| matches!(raw.as_str(), "false" | "no" | "n"))
{
return Ok(());
}
Err(TermBuilderError::invalid_option(format!(
"{term_name}(): `{key}=` declares periodicity, but no axis of this smooth ends up \
periodic. A radial smooth derives its wrap from the center lattice only in one \
dimension (this one has {dim}), so name the period per axis: \
period=[<value>, None, …]"
))
.to_string())
}
fn parse_periodic_axes(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<Vec<bool>, String> {
let mut axes = vec![false; dim];
let mut explicitly_aperiodic = false;
if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
let lowered = raw.trim().to_ascii_lowercase();
if matches!(lowered.as_str(), "true" | "yes" | "y") {
axes.fill(true);
} else if matches!(lowered.as_str(), "false" | "no" | "n") {
explicitly_aperiodic = true;
} else {
for axis_raw in parse_option_list(raw) {
let axis = axis_raw
.parse::<usize>()
.map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
if axis >= dim {
return Err(format!(
"periodic axis {axis} out of range for {dim}D smooth"
));
}
axes[axis] = true;
}
}
}
if !explicitly_aperiodic
&& let Some(raw) = options.get("boundary").or_else(|| options.get("bc"))
{
let boundary = parse_option_list(raw);
if boundary.len() == dim {
for (axis, value) in boundary.iter().enumerate() {
if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
axes[axis] = true;
}
}
} else if dim == 1
&& matches!(
boundary.first().map(String::as_str),
Some("periodic" | "cyclic" | "cc")
)
{
axes[0] = true;
}
}
fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
Ok(axes)
}
fn fold_in_declared_periods(
options: &BTreeMap<String, String>,
dim: usize,
axes: &mut [bool],
explicitly_aperiodic: bool,
) -> Result<(), String> {
let declared = axes_with_declared_period(options, dim)?;
if explicitly_aperiodic && declared.iter().any(|d| *d) {
return Err(TermBuilderError::incompatible_config(
"periodic=false denies the periodicity that the smooth's own period declaration \
asserts; drop one of the two",
)
.to_string());
}
for (axis, declared_axis) in declared.into_iter().enumerate() {
axes[axis] |= declared_axis;
}
Ok(())
}
fn parse_optional_numeric_list(
options: &BTreeMap<String, String>,
keys: &[&str],
dim: usize,
) -> Result<Vec<Option<f64>>, String> {
let Some(raw) = keys.iter().find_map(|key| options.get(*key)) else {
return Ok(vec![None; dim]);
};
let values = split_list_option(raw);
let mut out = vec![None; dim];
if values.len() == 1 && dim == 1 {
if !values[0].eq_ignore_ascii_case("none") {
out[0] = Some(parse_numeric_expr(&values[0])?);
}
return Ok(out);
}
if values.len() != dim {
return Err(format!(
"numeric option list length {} must match smooth dimension {}",
values.len(),
dim
));
}
for (i, value) in values.iter().enumerate() {
if !value.eq_ignore_ascii_case("none") {
out[i] = Some(parse_numeric_expr(value)?);
}
}
Ok(out)
}
fn parse_periods(
options: &BTreeMap<String, String>,
periodic_axes: &[bool],
) -> Result<Vec<Option<f64>>, String> {
let dim = periodic_axes.len();
let lone_periodic_broadcast = options
.get("period")
.or_else(|| options.get("periods"))
.and_then(|raw| {
let values = split_list_option(raw);
if values.len() != 1 || dim <= 1 {
return None;
}
let mut iter = periodic_axes.iter().enumerate().filter(|(_, p)| **p);
let first = iter.next()?;
if iter.next().is_some() {
return None;
}
Some((first.0, values.into_iter().next()?))
});
let periods = if let Some((axis, value)) = lone_periodic_broadcast {
let mut out = vec![None; dim];
if !value.eq_ignore_ascii_case("none") {
out[axis] = Some(parse_numeric_expr(&value)?);
}
out
} else {
parse_optional_numeric_list(options, &["period", "periods"], dim)?
};
for (axis, (periodic, period)) in periodic_axes.iter().zip(periods.iter()).enumerate() {
if *periodic
&& let Some(value) = period
&& (!value.is_finite() || *value <= 0.0)
{
return Err(format!(
"period for periodic axis {axis} must be finite and positive, got {value}"
));
}
}
Ok(periods)
}
fn parse_period_origins(
options: &BTreeMap<String, String>,
periodic_axes: &[bool],
) -> Result<Vec<Option<f64>>, String> {
parse_optional_numeric_list(
options,
&[
"origin",
"origins",
"period_origin",
"period-origin",
"domain_origin",
],
periodic_axes.len(),
)
}
fn parse_tensor_periodic_axes(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<Vec<bool>, String> {
let mut axes = vec![false; dim];
if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
let lowered = raw.trim().to_ascii_lowercase();
match lowered.as_str() {
"true" | "yes" | "y" => {
axes.fill(true);
}
"false" | "no" | "n" => {
}
_ => {
let entries = parse_option_list(raw);
let all_bool = !entries.is_empty()
&& entries.iter().all(|v| {
matches!(
v.as_str(),
"true" | "yes" | "y" | "false" | "no" | "n" | "none"
)
});
let all_zero_one =
!entries.is_empty() && entries.iter().all(|v| v == "0" || v == "1");
let has_repeat = {
let mut seen = std::collections::BTreeSet::new();
!entries.iter().all(|v| seen.insert(v.clone()))
};
let numeric_mask = all_zero_one && entries.len() == dim && has_repeat;
if all_bool || numeric_mask {
if entries.len() != dim {
return Err(format!(
"periodic list length {} must match smooth dimension {}",
entries.len(),
dim
));
}
for (i, v) in entries.iter().enumerate() {
axes[i] = matches!(v.as_str(), "true" | "yes" | "y" | "1");
}
} else {
for axis_raw in entries {
let axis = axis_raw
.parse::<usize>()
.map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
if axis >= dim {
return Err(format!(
"periodic axis {axis} out of range for {dim}D smooth"
));
}
axes[axis] = true;
}
}
}
}
}
if let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) {
let boundary = parse_option_list(raw);
if boundary.len() == 1 {
if matches!(boundary[0].as_str(), "periodic" | "cyclic" | "cc") {
axes.fill(true);
}
} else if boundary.len() == dim {
for (axis, value) in boundary.iter().enumerate() {
if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
axes[axis] = true;
}
}
}
}
if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
&& bs_selector_is_vector(raw)
{
let per_margin = parse_option_list(raw);
if per_margin.len() == dim {
for (axis, margin_bs) in per_margin.iter().enumerate() {
if matches!(canonicalize_smooth_type(margin_bs), "cc" | "cp" | "cyclic") {
axes[axis] = true;
}
}
}
}
let explicitly_aperiodic = options
.get("periodic")
.or_else(|| options.get("cyclic"))
.is_some_and(|raw| {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"false" | "no" | "n"
)
});
fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
Ok(axes)
}
fn validate_tensor_boundary_tokens(
options: &BTreeMap<String, String>,
dim: usize,
) -> Result<(), String> {
let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) else {
return Ok(());
};
let entries = parse_option_list(raw);
if entries.len() != 1 && entries.len() != dim {
return Err(TermBuilderError::invalid_option(format!(
"tensor smooth bc/boundary={raw:?} has {} entries but the smooth has {dim} margins; \
pass one token per margin or a single token for all of them",
entries.len()
))
.to_string());
}
for (axis, value) in entries.iter().enumerate() {
let inert = matches!(
value.trim().to_ascii_lowercase().as_str(),
"clamped" | "open" | "natural" | "free" | "none" | "" | "periodic" | "cyclic" | "cc"
);
if !inert {
return Err(TermBuilderError::unsupported_feature(format!(
"tensor smooth margin {axis} boundary token '{value}' is not supported \
(got bc/boundary={raw:?} on a {dim}-D tensor); tensor margins accept the periodic \
selectors (periodic/cyclic/cc) or the non-periodic markers (clamped/open/natural/free). \
Apply anchored/zero-value endpoint constraints with a 1-D s(x, bc=...) term instead."
))
.to_string());
}
}
Ok(())
}
fn tensor_k_axis_option_axis(
key: &str,
cols: &[usize],
ds: &Dataset,
) -> Result<Option<usize>, String> {
let Some(suffix) = key.strip_prefix("k_") else {
return Ok(None);
};
if suffix.is_empty() {
return Err("tensor k axis option must be named k_<axis> or k_<variable>".to_string());
}
if let Ok(axis) = suffix.parse::<usize>() {
return if axis < cols.len() {
Ok(Some(axis))
} else {
Err(format!(
"tensor k axis option `{key}` references axis {axis}, but the smooth has {} margins",
cols.len()
))
};
}
let mut matches = cols
.iter()
.enumerate()
.filter(|(_, col)| ds.headers.get(**col).is_some_and(|name| name == suffix))
.map(|(axis, _)| axis);
let first = matches.next();
if matches.next().is_some() {
return Err(format!(
"tensor k axis option `{key}` matches more than one margin named `{suffix}`"
));
}
first.map(Some).ok_or_else(|| {
let margin_names = cols
.iter()
.enumerate()
.map(|(axis, col)| {
let name = ds
.headers
.get(*col)
.map(String::as_str)
.unwrap_or("<unnamed>");
format!("{axis}:{name}")
})
.collect::<Vec<_>>()
.join(", ");
format!(
"tensor k axis option `{key}` does not match a margin index or name; tensor margins are [{margin_names}]"
)
})
}
fn is_tensor_k_axis_option_key(key: &str) -> bool {
key.strip_prefix("k_")
.is_some_and(|suffix| !suffix.is_empty())
}
fn parse_tensor_k_list(
options: &BTreeMap<String, String>,
cols: &[usize],
ds: &Dataset,
) -> Result<(Vec<usize>, bool), String> {
let mut axis_values = vec![None; cols.len()];
let mut saw_axis_alias = false;
for (key, value) in options {
let Some(axis) = tensor_k_axis_option_axis(key, cols, ds)? else {
continue;
};
saw_axis_alias = true;
if axis_values[axis].is_some() {
return Err(format!("tensor k axis {axis} is specified more than once"));
}
let k: usize = value
.parse()
.map_err(|err| format!("invalid tensor k option `{key}={value}`: {err}"))?;
axis_values[axis] = Some(k);
}
let raw = options
.get("k")
.or_else(|| options.get("basis_dim"))
.or_else(|| options.get("basis-dim"))
.or_else(|| options.get("basisdim"));
if saw_axis_alias {
if raw.is_some() {
return Err(
"tensor k axis aliases cannot be combined with k= or basis_dim=".to_string(),
);
}
if let Some(missing_axis) = axis_values.iter().position(Option::is_none) {
let margin_name = cols
.get(missing_axis)
.and_then(|col| ds.headers.get(*col))
.map(String::as_str)
.unwrap_or("<unnamed>");
return Err(format!(
"tensor k axis aliases must specify every margin; missing axis {missing_axis} ({margin_name})"
));
}
return Ok((
axis_values
.into_iter()
.map(|k| k.expect("missing axis values rejected above"))
.collect(),
false,
));
}
let Some(raw) = raw else {
let inferred = heuristic_tensor_margin_knots(cols, ds);
return Ok((inferred, true));
};
let entries = split_list_option(raw);
if entries.len() == 1 {
let k: usize = entries[0]
.parse()
.map_err(|err| format!("invalid tensor k '{}': {err}", entries[0]))?;
return Ok((vec![k; cols.len()], false));
}
if entries.len() != cols.len() {
return Err(format!(
"tensor k list length {} must match smooth dimension {}",
entries.len(),
cols.len()
));
}
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
let k: usize = entry
.parse()
.map_err(|err| format!("invalid tensor k '{entry}': {err}"))?;
out.push(k);
}
Ok((out, false))
}
fn parse_tensor_identifiability(
options: &BTreeMap<String, String>,
kind: SmoothKind,
) -> Result<TensorBSplineIdentifiability, String> {
let Some(raw) = options.get("identifiability").map(String::as_str) else {
return Ok(match kind {
SmoothKind::Ti => TensorBSplineIdentifiability::MarginalSumToZero,
_ => TensorBSplineIdentifiability::default(),
});
};
match raw.trim().to_ascii_lowercase().as_str() {
"none" => Ok(TensorBSplineIdentifiability::None),
"sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
| "sumtozero" => Ok(TensorBSplineIdentifiability::SumToZero),
"marginal_sum_tozero" | "marginal-sum-to-zero" | "marginal_sumtozero"
| "marginalsumtozero" | "interaction" => {
Ok(TensorBSplineIdentifiability::MarginalSumToZero)
}
other => Err(TermBuilderError::unsupported_feature(format!(
"invalid tensor identifiability '{other}'; expected one of: none, sum_tozero, marginal_sum_tozero"
))
.to_string()),
}
}
fn parse_bspline_identifiability(
options: &BTreeMap<String, String>,
) -> Result<Option<BSplineIdentifiability>, String> {
let Some(raw) = options.get("identifiability").map(String::as_str) else {
return Ok(None);
};
match raw.trim().to_ascii_lowercase().as_str() {
"none" => Ok(Some(BSplineIdentifiability::None)),
"sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
| "sumtozero" => Ok(Some(BSplineIdentifiability::WeightedSumToZero {
weights: None,
})),
"linear" | "remove_linear_trend" | "remove-linear-trend" | "removelineartrend"
| "center_linear_orthogonal" | "center-linear-orthogonal" => {
Ok(Some(BSplineIdentifiability::RemoveLinearTrend))
}
"frozen" | "frozen_transform" | "orthogonal" | "orthogonal_to_design_columns" => {
Err(TermBuilderError::unsupported_feature(format!(
"B-spline identifiability '{}' is internal-only (it is minted by design freezing \
or needs an explicit design-column block); use one of: none, sum_tozero, linear",
raw.trim()
))
.to_string())
}
other => Err(TermBuilderError::unsupported_feature(format!(
"invalid B-spline identifiability '{other}'; expected one of: none, sum_tozero, linear"
))
.to_string()),
}
}
#[derive(Debug, Clone, Copy, Default)]
struct BSplineIdentifiabilityContext {
has_anchor: bool,
periodic: bool,
natural_cubic_regression: bool,
}
fn resolve_bspline_identifiability(
options: &BTreeMap<String, String>,
structural_default: BSplineIdentifiability,
context: BSplineIdentifiabilityContext,
) -> Result<BSplineIdentifiability, String> {
let Some(explicit) = parse_bspline_identifiability(options)? else {
return Ok(structural_default);
};
if context.has_anchor && !matches!(explicit, BSplineIdentifiability::None) {
return Err(TermBuilderError::incompatible_config(
"an anchored endpoint already fixes the smooth's level (the global intercept is \
suppressed), so it cannot also carry a centering identifiability constraint; \
drop the anchor or use identifiability='none'",
)
.to_string());
}
if matches!(explicit, BSplineIdentifiability::RemoveLinearTrend) {
if context.periodic {
return Err(TermBuilderError::incompatible_config(
"identifiability='linear' removes the constant and linear directions using \
open-knot Greville geometry, which a periodic basis does not span; use 'none' \
or 'sum_tozero' on a periodic smooth",
)
.to_string());
}
if context.natural_cubic_regression {
return Err(TermBuilderError::incompatible_config(
"identifiability='linear' needs B-spline knot/degree geometry, which the natural \
cubic regression basis (bs='cr'/'cs') does not carry; use 'none' or 'sum_tozero', \
or switch to bs='ps'",
)
.to_string());
}
}
Ok(explicit)
}
fn bspline_boundary_declares_periodic_axis(options: &BTreeMap<String, String>) -> bool {
options
.get("boundary")
.or_else(|| options.get("bc"))
.map(|raw| {
parse_option_list(raw)
.into_iter()
.any(|value| matches!(value.as_str(), "periodic" | "cyclic" | "cc"))
})
.unwrap_or(false)
}
pub(crate) fn canonicalize_smooth_type(raw: &str) -> &str {
match raw {
"tp" => "tps",
"gp" => "matern",
"curv" | "constant_curvature" | "mkappa" => "curvature",
"mjs" | "measure_jet" | "web" => "measurejet",
other => other,
}
}
pub(crate) fn tensor_margin_bs_is_supported(margin_bs: &str) -> bool {
matches!(
canonicalize_smooth_type(margin_bs),
"tps" | "ps" | "bs" | "bspline" | "cr" | "cs" | "cc" | "cp" | "cyclic"
)
}
pub(crate) fn smooth_options_declare_periodic(options: &BTreeMap<String, String>) -> bool {
options.contains_key("periodic")
|| options.contains_key("cyclic")
|| options
.get("boundary")
.or_else(|| options.get("bc"))
.map(|boundary| {
boundary.to_ascii_lowercase().contains("periodic")
|| boundary.to_ascii_lowercase().contains("cyclic")
})
.unwrap_or(false)
}
pub(crate) fn bs_selector_is_vector(raw: &str) -> bool {
let trimmed = raw.trim();
let bracketed = (trimmed.starts_with('[') && trimmed.ends_with(']'))
|| (trimmed.starts_with("c(") || trimmed.starts_with("C(")) && trimmed.ends_with(')')
|| (trimmed.starts_with('(') && trimmed.ends_with(')'));
bracketed && !parse_option_list(trimmed).is_empty()
}
pub fn resolve_smooth_type_name(
kind: SmoothKind,
n_cols: usize,
options: &BTreeMap<String, String>,
) -> String {
let selector = options.get("type").or_else(|| options.get("bs"));
if let Some(raw) = selector
&& bs_selector_is_vector(raw)
&& matches!(kind, SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2)
{
return "tensor".to_string();
}
selector
.map(|s| canonicalize_smooth_type(&s.to_ascii_lowercase()).to_string())
.unwrap_or_else(|| match kind {
SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2 => "tensor".to_string(),
SmoothKind::S if n_cols == 1 => "bspline".to_string(),
SmoothKind::S if smooth_options_declare_periodic(options) => "tensor".to_string(),
SmoothKind::S => "tps".to_string(),
})
}
pub fn smooth_type_uses_spatial_center_heuristic(canonical_type: &str) -> bool {
matches!(canonical_type, "tps" | "matern" | "duchon")
}
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> {
let stripped_sizing_options;
let (options, sizing_rows) = match options.get(DEFAULT_SIZING_ROWS_OPTION) {
Some(raw) => {
let rows = raw.parse::<usize>().map_err(|_| {
format!("internal by-level sizing rows carrier is not a count: '{raw}'")
})?;
let mut cleaned = options.clone();
cleaned.remove(DEFAULT_SIZING_ROWS_OPTION);
stripped_sizing_options = cleaned;
(&stripped_sizing_options, rows)
}
None => (options, ds.values.nrows()),
};
let coord_cols: Vec<(&String, usize)> = vars
.iter()
.zip(cols.iter().copied())
.filter(|(_, col)| !matches!(ds.column_kinds.get(*col), Some(ColumnKindTag::Categorical)))
.collect();
if !coord_cols.is_empty() {
let views: Vec<ArrayView1<'_, f64>> = coord_cols
.iter()
.map(|(_, col)| ds.values.column(*col))
.collect();
let n_rows = views[0].len();
let mut distinct_points = std::collections::HashSet::<Vec<u64>>::new();
for r in 0..n_rows {
let key: Vec<u64> = views
.iter()
.map(|v| gam_data::canonical_level_bits(v[r]))
.collect();
distinct_points.insert(key);
if distinct_points.len() > 1 {
break;
}
}
if distinct_points.len() <= 1 {
return Err(TermBuilderError::degenerate_data(if coord_cols.len() == 1 {
let var = coord_cols[0].0;
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."
)
} else {
let names = coord_cols
.iter()
.map(|(v, _)| v.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(
"smooth term over ({names}) has only one unique joint coordinate in the training \
data — every coordinate is constant, so the smooth is degenerate and would only \
fit the response mean. Drop the term or check the data."
)
})
.to_string());
}
if matches!(
resolve_smooth_type_name(kind, cols.len(), options).as_str(),
"sphere" | "s2" | "sos"
) {
for (axis, (var, col)) in coord_cols.iter().enumerate() {
let column = ds.values.column(*col);
let mut distinct = std::collections::HashSet::<u64>::new();
for &value in column.iter() {
distinct.insert(gam_data::canonical_level_bits(value));
if distinct.len() > 1 {
break;
}
}
if distinct.len() <= 1 {
let slice = if axis == 0 {
"a single parallel (constant latitude)"
} else {
"a single meridian (constant longitude)"
};
return Err(TermBuilderError::degenerate_data(format!(
"sphere smooth has a constant '{var}' column — every point lies on \
{slice}, so the 2-sphere term is degenerate and unidentifiable along \
that axis. A spherical smooth needs genuine variation in BOTH latitude \
and longitude; vary '{var}', drop the term, or fit a 1-D smooth on the \
varying coordinate."
))
.to_string());
}
}
}
}
if let Some(by_name) = options.get("by").cloned() {
let by_col = options
.get("__by_col")
.and_then(|raw| raw.parse::<usize>().ok())
.or_else(|| vars.iter().position(|v| v == &by_name).map(|idx| cols[idx]))
.ok_or_else(|| format!("unknown by= column '{by_name}'"))?;
let mut inner_options = options.clone();
inner_options.remove("by");
inner_options.remove("__by_col");
inner_options.remove("id");
inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
let mut inner = build_smooth_basis(
kind,
vars,
cols,
&inner_options,
ds,
inference_notes,
policy,
smooth_coordinate_count,
)?;
if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
&& !options.contains_key("identifiability")
{
crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner);
}
let by_kind = match ds.column_kinds.get(by_col).copied() {
Some(ColumnKindTag::Categorical) => ByVarKind::Factor {
feature_col: by_col,
ordered: option_bool(options, "ordered").unwrap_or(false),
frozen_levels: None,
},
Some(ColumnKindTag::Continuous | ColumnKindTag::Binary) => ByVarKind::Numeric {
feature_col: by_col,
},
None => {
return Err(format!(
"internal column-kind lookup failed for by='{by_name}'"
));
}
};
return Ok(SmoothBasisSpec::BySmooth {
smooth: Box::new(inner),
by_kind,
});
}
let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
let type_opt = resolve_smooth_type_name(kind, cols.len(), options);
if matches!(type_opt.as_str(), "fs" | "sz" | "re") {
if type_opt == "re" {
validate_random_effect_smooth_options(options)?;
} else {
validate_known_options(type_opt.as_str(), options, FACTOR_SMOOTH_OPTION_KEYS)?;
}
if cols.len() != 2 {
return Err(format!(
"{} factor-smooth currently expects exactly two variables (one numeric, one categorical)",
type_opt
));
}
let kinds = cols
.iter()
.map(|&c| ds.column_kinds.get(c).copied())
.collect::<Vec<_>>();
let (cont_idx, group_idx) = if type_opt == "re" {
match (kinds[0], kinds[1]) {
(Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
(_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
_ => (1usize, 0usize),
}
} else {
match (kinds[0], kinds[1]) {
(_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
(Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
_ => {
return Err(format!(
"{} factor-smooth requires one categorical factor variable",
type_opt
));
}
}
};
let c = cols[cont_idx];
let (minv, maxv) = col_minmax(ds.values.column(c))?;
let degree = if type_opt == "re" {
1
} else {
option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE)
};
let pooled_internal = heuristic_knots_for_column(ds.values.column(c));
let default_internal = if type_opt == "re" {
0
} else {
let min_group_resolution =
min_per_group_unique_count(ds.values.column(c), ds.values.column(cols[group_idx]));
let basis_cap = min_group_resolution.saturating_sub(2).max(degree + 2);
let internal_cap = basis_cap.saturating_sub(degree + 1);
let capped = pooled_internal.min(internal_cap.max(1));
let fs_default_internal = FACTOR_SMOOTH_DEFAULT_BASIS_DIM
.saturating_sub(degree + 1)
.max(1);
capped.min(fs_default_internal)
};
let (n_knots, _, effective_degree) =
parse_ps_internal_knots(options, degree, default_internal)?;
let penalty_order = parse_penalty_order_alias(options)?
.unwrap_or(if effective_degree > 1 { 2 } else { 1 })
.min(effective_degree);
let marginal_knotspec = resolve_nonperiodic_bspline_knotspec(
options,
ds.values.column(c),
(minv, maxv),
effective_degree,
n_knots,
)?;
let marginal = BSplineBasisSpec {
degree: effective_degree,
penalty_order,
knotspec: marginal_knotspec,
double_penalty: option_bool(options, "double_penalty")
.unwrap_or(type_opt.as_str() != "sz"),
identifiability: BSplineIdentifiability::None,
boundary_conditions: Default::default(),
boundary: OneDimensionalBoundary::Open,
};
let flavour = match type_opt.as_str() {
"fs" => FactorSmoothFlavour::Fs {},
"sz" => FactorSmoothFlavour::Sz,
"re" => FactorSmoothFlavour::Re,
other => {
return Err(format!(
"internal: factor-smooth flavour dispatch reached unexpected type `{}`",
other
));
}
};
return Ok(SmoothBasisSpec::FactorSmooth {
spec: FactorSmoothSpec {
continuous_cols: vec![c],
group_col: cols[group_idx],
marginal,
flavour,
group_frozen_levels: None,
frozen_global_orthogonality: None,
},
});
}
match type_opt.as_str() {
"cyclic" | "cc" | "cp" | "cyclic-ps" | "periodic" => {
validate_known_options("cyclic", options, CYCLIC_SMOOTH_OPTION_KEYS)?;
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(DEFAULT_BSPLINE_DEGREE);
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 cyclic_default_basis_cap = CYCLIC_DEFAULT_BASIS_DIM.max(degree + 1);
let default_basis = (default_internal + degree + 1).min(cyclic_default_basis_cap);
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 periodic_axes = [true];
let periods = parse_periods(options, &periodic_axes)?;
let origins = parse_period_origins(options, &periodic_axes)?;
let has_endpoint_decl = ["period_start", "start", "period_end", "end"]
.iter()
.any(|key| options.contains_key(*key));
let (domain_start, period) = if let Some(p) = periods[0] {
(origins[0].unwrap_or(minv), p)
} else if has_endpoint_decl {
parse_periodic_domain_1d(options, minv, maxv)?
} else {
let span = maxv - minv;
if !(span.is_finite() && span > 0.0) {
return Err(format!(
"cyclic smooth requires a positive observed data range to derive \
its period, got [{minv}, {maxv}]"
));
}
(origins[0].unwrap_or(minv), span)
};
let identifiability = resolve_bspline_identifiability(
options,
BSplineIdentifiability::default(),
BSplineIdentifiabilityContext {
periodic: true,
..Default::default()
},
)?;
Ok(SmoothBasisSpec::BSpline1D {
feature_col: c,
spec: BSplineBasisSpec {
degree,
penalty_order: option_usize(options, "penalty_order")
.unwrap_or(DEFAULT_PENALTY_ORDER),
knotspec: BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_start + period),
num_basis,
},
double_penalty: smooth_double_penalty,
identifiability,
boundary_conditions: Default::default(),
boundary: OneDimensionalBoundary::Cyclic {
start: domain_start,
end: domain_start + period,
},
},
})
}
"bspline" | "ps" | "p-spline" | "cr" | "cs" => {
let validation_name = match type_opt.as_str() {
"cr" => "cr",
"cs" => "cs",
_ => "bspline",
};
validate_known_options(validation_name, options, BSPLINE_SMOOTH_OPTION_KEYS)?;
if cols.len() != 1 {
return Err(TermBuilderError::incompatible_config(format!(
"bspline smooth expects one variable, got {}",
cols.len()
))
.to_string());
}
let c = cols[0];
let (minv, maxv) = col_minmax(ds.values.column(c))?;
let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
let default_internal = heuristic_knots_for_column(ds.values.column(c));
let (mut n_knots, inferred, effective_degree) =
parse_ps_internal_knots(options, degree, default_internal)?;
let periodic_axes = parse_periodic_axes(options, 1).map_err(|e| e.to_string())?;
reject_unconsumable_period_declaration(validation_name, options, &periodic_axes)?;
if periodic_axes[0] && effective_degree != degree {
return Err(TermBuilderError::invalid_option(format!(
"periodic smooth: k={} too small for degree {}; expected k >= {}",
effective_degree + 1,
degree,
degree + 1
))
.to_string());
}
let heuristic_knots = n_knots;
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 mut note = format!(
"Automatically set {} internal knots for smooth '{}' from {} unique values (rule: clamp(unique/4, 4..{}) = {}; basis dimension = internal knots + degree + 1).",
n_knots,
vars.join(","),
unique,
MAX_DEFAULT_INTERNAL_KNOTS,
heuristic_knots,
);
if n_knots != heuristic_knots {
note.push_str(&format!(
" Reduced to {} because the fit has only {} rows and {} smooth coordinates.",
n_knots,
ds.values.nrows(),
smooth_coordinate_count,
));
}
note.push_str(" Override with knots=... or k=....");
inference_notes.push(note);
}
let boundary_conditions =
if periodic_axes[0] && bspline_boundary_declares_periodic_axis(options) {
BSplineBoundaryConditions::default()
} else {
parse_bspline_boundary_conditions(options).map_err(|e| e.to_string())?
};
let structural_identifiability = if boundary_conditions.has_anchor() {
BSplineIdentifiability::None
} else {
BSplineIdentifiability::default()
};
let identifiability = resolve_bspline_identifiability(
options,
structural_identifiability,
BSplineIdentifiabilityContext {
has_anchor: boundary_conditions.has_anchor(),
periodic: periodic_axes[0],
natural_cubic_regression: !periodic_axes[0]
&& (type_opt == "cr" || type_opt == "cs"),
},
)?;
let periods = parse_periods(options, &periodic_axes).map_err(|e| e.to_string())?;
let origins =
parse_period_origins(options, &periodic_axes).map_err(|e| e.to_string())?;
let (knotspec, boundary) = if periodic_axes[0] {
if !boundary_conditions.is_free() {
return Err(TermBuilderError::incompatible_config(
"periodic B-splines cannot also declare endpoint boundary conditions",
)
.to_string());
}
{
let (domain_start, p_value) = if let Some(period) = periods[0] {
(origins[0].unwrap_or(minv), period)
} else {
parse_periodic_domain_1d(options, minv, maxv).map_err(|e| e.to_string())?
};
let domain_end = domain_start + p_value;
(
BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_end),
num_basis: n_knots + effective_degree + 1,
},
OneDimensionalBoundary::Cyclic {
start: domain_start,
end: domain_end,
},
)
}
} else if type_opt == "cr" || type_opt == "cs" {
let k_cr = (n_knots + effective_degree + 1).max(CR_MIN_KNOTS);
let knotspec = match capped_cr_marginal_knotspec(
ds.values.column(c),
k_cr,
&vars.join(","),
inference_notes,
)? {
Some(cr_knotspec) => cr_knotspec,
None => resolve_nonperiodic_bspline_knotspec(
options,
ds.values.column(c),
(minv, maxv),
effective_degree,
n_knots,
)?,
};
(knotspec, parse_cyclic_boundary(options, minv, maxv)?)
} else {
(
resolve_nonperiodic_bspline_knotspec(
options,
ds.values.column(c),
(minv, maxv),
effective_degree,
n_knots,
)?,
parse_cyclic_boundary(options, minv, maxv)?,
)
};
let double_penalty = smooth_double_penalty;
let penalty_order = option_usize(options, "penalty_order")
.unwrap_or(DEFAULT_PENALTY_ORDER)
.min(effective_degree);
Ok(SmoothBasisSpec::BSpline1D {
feature_col: c,
spec: BSplineBasisSpec {
degree: effective_degree,
penalty_order,
knotspec,
double_penalty,
identifiability,
boundary,
boundary_conditions,
},
})
}
"tps" | "thinplate" | "thin-plate" => {
validate_known_options("thinplate", options, THINPLATE_SMOOTH_OPTION_KEYS)?;
let plan = plan_spatial_basis(
sizing_rows,
cols.len(),
CenterCountRequest::Default,
DuchonNullspaceOrder::Linear,
option_bool(options, "scale_dims").unwrap_or(false),
policy,
)
.map_err(|e| e.to_string())?;
let default_centers = plan.centers;
let centers = parse_countwith_basis_alias(
options,
"centers",
cap_default_spatial_centers(options, default_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())
};
if options.contains_key("include_intercept") {
return Err(TermBuilderError::unsupported_feature(
"thinplate() does not support include_intercept: the thin-plate basis already spans its polynomial null space (the constant and linear terms), so an appended constant column would be exactly collinear with it. matern() takes the option because its kernel basis carries no polynomial null space.",
)
.to_string());
}
let periodic = parse_periodic_axes_option(options, cols.len())?;
reject_unconsumable_radial_period_declaration(
"thinplate",
options,
cols.len(),
periodic.as_deref(),
false,
)?;
Ok(SmoothBasisSpec::ThinPlate {
feature_cols: cols.to_vec(),
spec: ThinPlateBasisSpec {
center_strategy,
periodic,
length_scale: option_f64(options, "length_scale").unwrap_or(0.0),
double_penalty: smooth_double_penalty,
identifiability: parse_spatial_identifiability(options)
.map_err(|e| e.to_string())?,
radial_reparam: None,
},
input_scale: None,
})
}
"sphere" | "s2" | "sos" => {
validate_known_options("sphere", options, SPHERE_SMOOTH_OPTION_KEYS)?;
if cols.len() != 2 {
return Err(format!(
"sphere smooth expects exactly two variables (lat, lon), got {}",
cols.len()
));
}
let radians = option_bool(options, "radians").unwrap_or_else(|| {
options
.get("units")
.map(|u| u.eq_ignore_ascii_case("radian") || u.eq_ignore_ascii_case("radians"))
.unwrap_or(false)
});
let degree_requested = options.contains_key("degree")
|| options.contains_key("l")
|| options.contains_key("max_degree")
|| options.contains_key("max-degree");
let kernel = options
.get("kernel")
.or_else(|| options.get("method"))
.map(|raw| strip_quotes(raw).trim().to_ascii_lowercase())
.unwrap_or_else(|| {
if degree_requested {
"harmonic".to_string()
} else {
"sobolev".to_string()
}
});
let (method, wahba_kernel) = match kernel.as_str() {
"sobolev" | "wahba" | "wahba_sobolev" | "wahba-sobolev" => {
(SphereMethod::Wahba, SphereWahbaKernel::Sobolev)
}
"pseudo" | "mgcv" | "sos" | "wahba_pseudo" | "wahba-pseudo" => {
(SphereMethod::Wahba, SphereWahbaKernel::Pseudo)
}
"harmonic" | "spherical_harmonic" | "spherical-harmonic" => {
(SphereMethod::Harmonic, SphereWahbaKernel::Sobolev)
}
other => {
return Err(format!(
"unsupported sphere kernel '{other}'; expected sobolev, pseudo, or harmonic"
));
}
};
let wahba_kernel = match option_usize_any(options, &["lmax", "l_max", "l-max"]) {
None => wahba_kernel,
Some(_) if matches!(method, SphereMethod::Harmonic) => {
return Err(
"sphere smooth: lmax= states the truncation of a Wahba reproducing kernel \
and does not apply to kernel=harmonic; use degree=/max_degree= to set the \
harmonic degree"
.to_string(),
);
}
Some(lmax) => {
if !(SPHERE_TRUNCATION_LMAX_RANGE).contains(&lmax) {
return Err(format!(
"sphere smooth: lmax={lmax} is out of range; the truncated Wahba \
kernels support lmax in {}..={} (the device kernel bakes it in as a \
compile-time bound)",
SPHERE_TRUNCATION_LMAX_RANGE.start(),
SPHERE_TRUNCATION_LMAX_RANGE.end()
));
}
let lmax = lmax as u16;
match wahba_kernel {
SphereWahbaKernel::Sobolev | SphereWahbaKernel::SobolevTruncated { .. } => {
SphereWahbaKernel::SobolevTruncated { lmax }
}
SphereWahbaKernel::Pseudo | SphereWahbaKernel::PseudoTruncated { .. } => {
SphereWahbaKernel::PseudoTruncated { lmax }
}
}
}
};
let max_degree = if matches!(method, SphereMethod::Harmonic) {
let degree =
option_usize_any(options, &["degree", "l", "max_degree", "max-degree"])
.or_else(|| option_usize(options, "centers"))
.or_else(|| {
option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
.and_then(|k| (1..=128).find(|&l| l * (l + 2) >= k))
})
.unwrap_or_else(|| default_spherical_harmonic_degree(sizing_rows));
if degree == 0 {
return Err("sphere smooth requires degree/max_degree >= 1".to_string());
}
if degree > 32 {
return Err(format!(
"sphere smooth max_degree={} is too large for the dense harmonic engine (limit 32)",
degree
));
}
Some(degree)
} else {
None
};
let penalty_order =
parse_penalty_order_alias(options)?.unwrap_or(DEFAULT_PENALTY_ORDER);
let center_strategy = if matches!(method, SphereMethod::Wahba) {
let mut centers = parse_countwith_basis_alias(
options,
"centers",
default_num_centers(sizing_rows, cols.len()),
)?;
if penalty_order >= 4 {
centers = centers.max(30);
}
CenterStrategy::FarthestPoint {
num_centers: centers,
}
} else {
CenterStrategy::FarthestPoint { num_centers: 0 }
};
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,
identifiability: SphericalSplineIdentifiability::CenterSumToZero,
},
})
}
"curvature" => {
validate_known_options("curvature", options, CURVATURE_SMOOTH_OPTION_KEYS)?;
let kappa_opt = option_f64(options, "kappa");
let kappa_fixed = kappa_opt.is_some();
let kappa = kappa_opt.unwrap_or(0.0);
if !kappa.is_finite() {
return Err("curvature smooth requires a finite kappa".to_string());
}
let length_scale_opt = option_f64(options, "length_scale");
let length_scale_fixed = length_scale_opt.is_some();
let length_scale = length_scale_opt.unwrap_or(0.0);
if !length_scale.is_finite() || length_scale < 0.0 {
return Err(format!(
"curvature smooth length_scale must be positive (or omitted for auto); got {length_scale}"
));
}
let centers = parse_countwith_basis_alias(
options,
"centers",
default_num_centers(sizing_rows, cols.len()),
)?;
if centers < 2 {
return Err("curvature smooth requires at least 2 centers".to_string());
}
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::ConstantCurvature {
feature_cols: cols.to_vec(),
spec: ConstantCurvatureBasisSpec {
center_strategy,
kappa,
kappa_fixed,
length_scale,
length_scale_fixed,
double_penalty: option_bool(options, "double_penalty").unwrap_or(false),
identifiability: ConstantCurvatureIdentifiability::CenterSumToZero,
},
})
}
"measurejet" => {
validate_known_options("measurejet", options, MEASURE_JET_SMOOTH_OPTION_KEYS)?;
let order_s = option_f64(options, "s").unwrap_or(0.0);
if !(order_s.is_finite() && (order_s == 0.0 || (order_s > 0.0 && order_s < 2.0))) {
return Err(format!(
"measurejet smooth s must lie in (0, 2) (or be omitted for auto); got {order_s}"
));
}
let alpha =
option_f64(options, "alpha").unwrap_or(MeasureJetBasisSpec::default().alpha);
if !alpha.is_finite() {
return Err("measurejet smooth requires a finite alpha".to_string());
}
let tau0 = option_f64(options, "tau").unwrap_or(1e-3);
if !(tau0.is_finite() && tau0 >= 0.0) {
return Err(format!(
"measurejet smooth tau must be finite and nonnegative; got {tau0}"
));
}
let num_scales = option_usize(options, "scales").unwrap_or(0);
let length_scale = option_f64(options, "length_scale").unwrap_or(0.0);
if !length_scale.is_finite() || length_scale < 0.0 {
return Err(format!(
"measurejet smooth length_scale must be positive (or omitted for auto); got {length_scale}"
));
}
let centers = parse_countwith_basis_alias(
options,
"centers",
default_num_centers(sizing_rows, cols.len()),
)?;
if centers < 3 {
return Err("measurejet smooth requires at least 3 centers".to_string());
}
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 multiscale = option_bool(options, "multiscale").unwrap_or(false);
let learn_length_scale =
option_bool(options, "learn_length_scale").unwrap_or(length_scale == 0.0);
Ok(SmoothBasisSpec::MeasureJet {
feature_cols: cols.to_vec(),
spec: MeasureJetBasisSpec {
center_strategy,
order_s,
alpha,
tau0,
num_scales,
length_scale,
double_penalty: smooth_double_penalty,
learn_length_scale,
multiscale,
identifiability: MeasureJetIdentifiability::CenterSumToZero,
frozen_quadrature: None,
},
input_scale: None,
})
}
"matern" => {
validate_known_options("matern", options, MATERN_SMOOTH_OPTION_KEYS)?;
let plan = plan_spatial_basis(
sizing_rows,
cols.len(),
CenterCountRequest::Default,
DuchonNullspaceOrder::Zero,
option_bool(options, "scale_dims").unwrap_or(false),
policy,
)
.map_err(|e| e.to_string())?;
let univariate_floor = if cols.len() == 1 {
heuristic_knots_for_column(ds.values.column(cols[0]))
.saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
} else {
0
};
let centers = parse_countwith_basis_alias(
options,
"centers",
cap_default_spatial_centers(
options,
default_matern_center_count(
sizing_rows,
cols.len(),
plan.centers,
univariate_floor,
),
),
)?;
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(TermBuilderError::unsupported_feature(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()
))
.to_string());
}
let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
Some(vec![0.0; cols.len()])
} else {
None
};
let periodic = parse_periodic_axes_option(options, cols.len())?;
reject_unconsumable_radial_period_declaration(
"matern",
options,
cols.len(),
periodic.as_deref(),
false,
)?;
Ok(SmoothBasisSpec::Matern {
feature_cols: cols.to_vec(),
spec: MaternBasisSpec {
center_strategy,
periodic,
length_scale: option_f64(options, "length_scale")
.map(MaternLengthScale::fixed)
.unwrap_or_else(MaternLengthScale::auto),
nu,
include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
double_penalty: smooth_double_penalty,
identifiability: parse_matern_identifiability(options)
.map_err(|e| e.to_string())?,
aniso_log_scales,
},
input_scale: None,
})
}
"duchon" | "ds" => {
validate_known_options("duchon", options, DUCHON_SMOOTH_OPTION_KEYS)?;
if options.contains_key("double_penalty") {
return Err(TermBuilderError::incompatible_config(format!(
"Duchon smooth '{}' does not support double_penalty; the Duchon smoother already ships its native reproducing-norm penalty plus a null-space shrinkage ridge.",
vars.join(", ")
))
.to_string());
}
let requested_nullspace_order = parse_duchon_order_opt(options)?;
let length_scale = option_f64_strict(options, "length_scale")?;
let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
DuchonPowerPolicy::Explicit(req_power) => {
if length_scale.is_some() && req_power.fract() != 0.0 {
return Err(TermBuilderError::incompatible_config(format!(
"hybrid Duchon-Matern smooth '{}' (length_scale=...) requires an integer power, got power={}; \
drop length_scale to use the scale-free structural kernel with a fractional power.",
vars.join(", "),
req_power,
))
.to_string());
}
(
requested_nullspace_order.unwrap_or(DuchonNullspaceOrder::Linear),
req_power,
)
}
DuchonPowerPolicy::CubicStructuralDefault => {
match length_scale {
None => {
let (default_order, s) =
crate::basis::duchon_cubic_default(cols.len());
(requested_nullspace_order.unwrap_or(default_order), s)
}
Some(_) => {
let (default_order, s_frac) =
crate::basis::duchon_cubic_default(cols.len());
(
requested_nullspace_order.unwrap_or(default_order),
s_frac.floor(),
)
}
}
}
};
let plan = plan_spatial_basis(
sizing_rows,
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 polynomial_cols = match nullspace_order {
DuchonNullspaceOrder::Zero => 1,
DuchonNullspaceOrder::Linear => cols.len() + 1,
DuchonNullspaceOrder::Degree(degree) => {
crate::basis::duchon_nullspace_dimension(cols.len(), degree)
}
};
let univariate_floor = if cols.len() == 1 {
heuristic_knots_for_column(ds.values.column(cols[0]))
.saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
} else {
0
};
let default_centers = default_duchon_center_count(
sizing_rows,
cols.len(),
plan.centers,
polynomial_cols,
univariate_floor,
);
let spectral_rank = option_usize(options, "rank");
let center_default = if spectral_rank.is_some() {
count_unique_coordinate_rows(ds.values.view(), &cols).min(2000)
} else {
cap_default_spatial_centers(options, default_centers)
};
let requested_centers =
parse_countwith_basis_alias(options, "centers", center_default)?;
if requested_centers > ds.values.nrows() {
return Err(TermBuilderError::incompatible_config(format!(
"Duchon smooth '{}' requested {requested_centers} centers but only {} rows are available",
vars.join(", "),
ds.values.nrows(),
))
.to_string());
}
if requested_centers <= polynomial_cols {
return Err(TermBuilderError::incompatible_config(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,
))
.to_string());
}
if let Some(rank) = spectral_rank
&& (rank <= polynomial_cols || rank > requested_centers)
{
return Err(TermBuilderError::incompatible_config(format!(
"Duchon smooth '{}' spectral rank must satisfy {} < rank <= centers (got rank={rank}, centers={requested_centers})",
vars.join(", "),
polynomial_cols,
))
.to_string());
}
let mut centers = requested_centers;
if !centers_explicit && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
centers = centers.max(polynomial_cols + 4);
}
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::all_disabled();
let mut periodic = parse_periodic_axes_option(options, cols.len())?;
if cols.len() == 1
&& let Some(axes) = periodic.as_mut()
&& axes.len() == 1
&& axes[0].is_none()
{
let (minv, maxv) = col_minmax(ds.values.column(cols[0]))?;
if maxv > minv {
axes[0] = Some(maxv - minv);
}
}
let boundary = if cols.len() == 1 {
let c = cols[0];
let (minv, maxv) = col_minmax(ds.values.column(c))?;
parse_cyclic_boundary(options, minv, maxv)?
} else {
OneDimensionalBoundary::Open
};
let is_periodic = periodic
.as_ref()
.is_some_and(|axes| axes.iter().any(Option::is_some))
|| matches!(boundary, OneDimensionalBoundary::Cyclic { .. });
reject_unconsumable_radial_period_declaration(
"duchon",
options,
cols.len(),
periodic.as_deref(),
matches!(boundary, OneDimensionalBoundary::Cyclic { .. }),
)?;
if spectral_rank.is_some() && is_periodic {
return Err(TermBuilderError::incompatible_config(
"Duchon spectral rank is defined for the scale-free open-domain kernel, \
not a periodic image expansion"
.to_string(),
)
.to_string());
}
let center_strategy = if spectral_rank.is_some() {
let mut coordinates = Array2::<f64>::zeros((ds.values.nrows(), cols.len()));
for (axis, &column) in cols.iter().enumerate() {
coordinates
.column_mut(axis)
.assign(&ds.values.column(column));
}
let sampled = select_r_uniform_subsample_centers(coordinates.view(), centers, 1)
.map_err(|error| error.to_string())?;
CenterStrategy::UserProvided(sampled)
} else if is_periodic {
if centers_explicit {
spatial_center_strategy_for_dimension(centers, cols.len())
} else {
auto_spatial_center_strategy(centers, cols.len())
}
} else {
duchon_center_strategy(centers, cols.len(), !centers_explicit)
};
let center_strategy = match spectral_rank {
Some(rank) => CenterStrategy::DuchonSpectral {
knots: Box::new(center_strategy),
basis: DuchonSpectralBasis::Fresh { rank },
},
None => center_strategy,
};
Ok(SmoothBasisSpec::Duchon {
feature_cols: cols.to_vec(),
spec: DuchonBasisSpec {
center_strategy,
periodic,
length_scale,
power,
nullspace_order,
identifiability: parse_spatial_identifiability(options)
.map_err(|e| e.to_string())?,
aniso_log_scales,
operator_penalties,
boundary,
radial_reparam: None,
},
input_scale: None,
})
}
"tensor" | "te" | "ti" | "t2" => {
validate_known_options("tensor", options, TENSOR_SMOOTH_OPTION_KEYS)?;
if cols.len() < 2 {
return Err(TermBuilderError::incompatible_config(format!(
"tensor smooth expects at least 2 variables, got {}",
cols.len()
))
.to_string());
}
let dim = cols.len();
if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
&& bs_selector_is_vector(raw)
{
let per_margin = parse_option_list(raw);
if per_margin.len() != dim {
return Err(TermBuilderError::invalid_option(format!(
"tensor smooth per-margin bs vector has {} entries but the smooth has {} margins",
per_margin.len(),
dim
))
.to_string());
}
for (axis, margin_bs) in per_margin.iter().enumerate() {
if !tensor_margin_bs_is_supported(margin_bs) {
return Err(TermBuilderError::unsupported_feature(format!(
"tensor smooth margin {axis} basis '{margin_bs}' is not a supported penalized-spline margin; \
tensor margins accept tp/tps/ps/bs/cr/cc"
))
.to_string());
}
}
}
validate_tensor_boundary_tokens(options, dim)?;
let periodic_axes = parse_tensor_periodic_axes(options, dim)?;
reject_unconsumable_period_declaration("tensor", options, &periodic_axes)?;
if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::invalid_option(format!(
"tensor(): `{key}=` declares one axis's periodic domain and has no per-margin \
form; on a tensor smooth give periods=[...] (with origins=[...] for the \
domain start), which name their margin"
))
.to_string());
}
let periods_opt = parse_periods(options, &periodic_axes)?;
let origins_opt = parse_period_origins(options, &periodic_axes)?;
let requested_degrees = parse_tensor_per_axis_usize(options, "degree", dim)?;
let requested_penalty_orders =
parse_tensor_per_axis_usize(options, "penalty_order", dim)?;
let axis_degree = |axis: usize| -> usize {
requested_degrees[axis].unwrap_or(DEFAULT_BSPLINE_DEGREE)
};
let axis_penalty_order = |axis: usize| -> usize {
requested_penalty_orders[axis]
.unwrap_or(if axis_degree(axis) > 1 { 2 } else { 1 })
};
let (mut k_list, k_inferred) = parse_tensor_k_list(options, cols, ds)?;
if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
for (axis, k) in k_list.iter_mut().enumerate() {
*k = (*k).min(axis_degree(axis) + 2);
}
}
if k_inferred {
inference_notes.push(format!(
"Automatically set per-margin basis sizes {:?} for tensor smooth '{}' \
(dimension-aware tensor budget: total ∏k kept near the mgcv-te default \
and within the data support, distributed geometrically across margins and \
capped per margin by each column's resolution). \
Override with k=<int> or k=[k0,k1,...].",
k_list,
vars.join(",")
));
}
let per_axis_bs: Vec<Option<String>> =
match options.get("bs").or_else(|| options.get("type")) {
Some(raw) if bs_selector_is_vector(raw) => {
let list = parse_option_list(raw);
(0..dim).map(|a| list.get(a).cloned()).collect()
}
Some(raw) => {
let scalar = raw
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase();
vec![Some(scalar); dim]
}
None => vec![None; dim],
};
let margin_wants_cr = |bs: &Option<String>| -> bool {
matches!(
bs.as_deref(),
None | Some("cr") | Some("cs") | Some("tp") | Some("tps")
)
};
let requested_knot_placement = explicit_knot_placement(options)?;
let mut margins: Vec<BSplineBasisSpec> = Vec::with_capacity(dim);
let mut emitted_periods: Vec<Option<f64>> = Vec::with_capacity(dim);
for axis in 0..dim {
let c = cols[axis];
let (data_min, data_max) = col_minmax(ds.values.column(c))?;
let k_requested = k_list[axis];
let n_distinct_axis = unique_count_column(ds.values.column(c));
let k_axis = k_requested.min(n_distinct_axis).max(2);
if k_axis < k_requested {
log::info!(
"tensor smooth: margin axis {axis} requested k={k_requested}, but the \
covariate has only {n_distinct_axis} distinct value(s); reducing this \
margin to k={k_axis} (mgcv-style data-support cap on the per-axis basis)."
);
}
if k_axis < 2 {
return Err(TermBuilderError::invalid_option(format!(
"tensor smooth: k[{axis}]={k_axis} too small; tensor margins require k >= 2"
))
.to_string());
}
let degree = axis_degree(axis);
let penalty_order = axis_penalty_order(axis);
let effective_degree = degree.min(k_axis - 1).max(1);
let effective_penalty_order = penalty_order.min(effective_degree);
let margin_is_cc = matches!(
canonicalize_smooth_type(per_axis_bs[axis].as_deref().unwrap_or("")),
"cc" | "cp" | "cyclic"
);
let (knotspec, boundary, axis_period) = if periodic_axes[axis] {
let (domain_start, period_value) = match periods_opt[axis] {
Some(period_value) => {
if !period_value.is_finite() || period_value <= 0.0 {
return Err(format!(
"tensor smooth axis {axis}: period must be a positive finite value, got {period_value}"
));
}
(origins_opt[axis].unwrap_or(data_min), period_value)
}
None if margin_is_cc => {
let span = data_max - data_min;
if !span.is_finite() || span <= 0.0 {
return Err(format!(
"tensor smooth axis {axis}: cyclic margin requires a positive \
observed data range to derive its period, got [{data_min}, {data_max}]"
));
}
(origins_opt[axis].unwrap_or(data_min), span)
}
None => {
return Err(format!(
"tensor smooth axis {axis} is periodic but requires an explicit \
period: pass period=<value> (scalar) or period=[..., <value>, ...]. \
Deriving the period from the observed data range is sample-dependent \
(off-by-ε seam), so it is not inferred."
));
}
};
let domain_end = domain_start + period_value;
(
BSplineKnotSpec::PeriodicUniform {
data_range: (domain_start, domain_end),
num_basis: k_axis,
},
OneDimensionalBoundary::Cyclic {
start: domain_start,
end: domain_end,
},
Some(period_value),
)
} else if margin_wants_cr(&per_axis_bs[axis])
&& requested_knot_placement.is_none()
&& requested_degrees[axis].is_none_or(|d| d == CR_MARGIN_DEGREE)
&& requested_penalty_orders[axis]
.is_none_or(|m| m == CR_MARGIN_PENALTY_ORDER)
&& k_axis >= 3
{
let cr_knots = crate::basis::select_cr_knots(ds.values.column(c), k_axis)
.map_err(|e| e.to_string())?;
(
BSplineKnotSpec::NaturalCubicRegression { knots: cr_knots },
OneDimensionalBoundary::Open,
None,
)
} else {
let num_internal_knots = k_axis - effective_degree - 1;
let knotspec = match requested_knot_placement
.unwrap_or(crate::basis::BSplineKnotPlacement::Uniform)
{
crate::basis::BSplineKnotPlacement::Uniform => BSplineKnotSpec::Generate {
data_range: (data_min, data_max),
num_internal_knots,
},
crate::basis::BSplineKnotPlacement::Quantile => {
crate::basis::auto_knot_vector_1d_quantile(
ds.values.column(c),
num_internal_knots,
effective_degree,
)
.map_err(|e| e.to_string())?;
BSplineKnotSpec::Automatic {
num_internal_knots: Some(num_internal_knots),
placement: crate::basis::BSplineKnotPlacement::Quantile,
}
}
};
(knotspec, OneDimensionalBoundary::Open, None)
};
margins.push(BSplineBasisSpec {
degree: effective_degree,
penalty_order: effective_penalty_order,
knotspec,
double_penalty: false,
identifiability: BSplineIdentifiability::None,
boundary,
boundary_conditions: BSplineBoundaryConditions::default(),
});
emitted_periods.push(axis_period);
}
let canon_cols: Vec<usize> = {
let mut perm: Vec<usize> = (0..dim).collect();
perm.sort_by_key(|&a| cols[a]);
if perm.iter().enumerate().any(|(i, &a)| i != a) {
margins = perm.iter().map(|&a| margins[a].clone()).collect();
emitted_periods = perm.iter().map(|&a| emitted_periods[a]).collect();
}
perm.iter().map(|&a| cols[a]).collect()
};
let any_periodic = emitted_periods.iter().any(|p| p.is_some());
let periods_vec = if any_periodic {
emitted_periods
} else {
Vec::new()
};
let tensor_double_penalty = smooth_double_penalty;
Ok(SmoothBasisSpec::TensorBSpline {
feature_cols: canon_cols,
spec: TensorBSplineSpec {
marginalspecs: margins,
periods: periods_vec,
double_penalty: tensor_double_penalty,
identifiability: parse_tensor_identifiability(options, kind)?,
penalty_decomposition: if matches!(kind, SmoothKind::T2)
|| type_opt.as_str() == "t2"
{
TensorBSplinePenaltyDecomposition::Separable
} else {
TensorBSplinePenaltyDecomposition::MarginalKroneckerSum
},
},
})
}
"pca" => {
validate_known_options("pca", options, PCA_SMOOTH_OPTION_KEYS)?;
let path = options
.get("lazy_path")
.or_else(|| options.get("pca_basis_path"))
.or_else(|| options.get("path"))
.map(|raw| PathBuf::from(strip_quotes(raw)));
let Some(path) = path else {
return Err(TermBuilderError::incompatible_config(
"pca smooth requires lazy_path=... on the formula path",
)
.to_string());
};
let k = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
.unwrap_or(0);
let chunk_size = option_usize(options, "chunk_size").unwrap_or(DEFAULT_PCA_CHUNK_SIZE);
Ok(SmoothBasisSpec::Pca {
feature_cols: cols.to_vec(),
basis_matrix: Array2::<f64>::zeros((cols.len(), k)),
centered: option_bool(options, "centered").unwrap_or(true),
smooth_penalty: option_f64(options, "smooth_penalty").unwrap_or(1.0),
center_mean: None,
pca_basis_path: Some(path),
chunk_size,
})
}
other => Err(TermBuilderError::unsupported_feature(format!(
"unsupported smooth type '{other}'"
))
.to_string()),
}
}
pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
for smooth in spec.smooth_terms.iter_mut() {
promote_thin_plate_for_scale_dimensions(&mut smooth.basis);
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]);
}
}
SmoothBasisSpec::ByVariable { .. }
| SmoothBasisSpec::FactorSumToZero { .. }
| SmoothBasisSpec::BSpline1D { .. }
| SmoothBasisSpec::BySmooth { .. }
| SmoothBasisSpec::FactorSmooth { .. }
| SmoothBasisSpec::ThinPlate { .. }
| SmoothBasisSpec::Sphere { .. }
| SmoothBasisSpec::ConstantCurvature { .. }
| SmoothBasisSpec::MeasureJet { .. }
| SmoothBasisSpec::Pca { .. }
| SmoothBasisSpec::TensorBSpline { .. } => {}
}
}
}
fn promote_thin_plate_for_scale_dimensions(basis: &mut SmoothBasisSpec) {
let SmoothBasisSpec::ThinPlate {
feature_cols,
spec,
input_scale,
} = &*basis
else {
return;
};
let d = feature_cols.len();
if d <= 1 {
return;
}
let m = thin_plate_penalty_order(d);
let nullspace_order = match m {
0 | 1 => DuchonNullspaceOrder::Zero,
2 => DuchonNullspaceOrder::Linear,
_ => DuchonNullspaceOrder::Degree(m - 1),
};
let duchon_spec = DuchonBasisSpec {
center_strategy: spec.center_strategy.clone(),
periodic: spec.periodic.clone(),
length_scale: None,
power: 0.0,
nullspace_order,
identifiability: spec.identifiability.clone(),
aniso_log_scales: Some(vec![0.0; d]),
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
radial_reparam: None,
};
let feature_cols = feature_cols.clone();
let input_scale = *input_scale;
*basis = SmoothBasisSpec::Duchon {
feature_cols,
spec: duchon_spec,
input_scale,
};
}
pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
if d <= 3 {
CenterStrategy::FarthestPoint { num_centers }
} else {
default_spatial_center_strategy(num_centers, d)
}
}
fn duchon_center_strategy(num_centers: usize, d: usize, automatic: bool) -> CenterStrategy {
let realized = if d == 1 {
CenterStrategy::UniformGrid {
points_per_dim: num_centers,
}
} else {
spatial_center_strategy_for_dimension(num_centers, d)
};
if automatic {
CenterStrategy::Auto(Box::new(realized))
} else {
realized
}
}
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(TermBuilderError::degenerate_data(
"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 unique_count_column(col: ArrayView1<'_, f64>) -> usize {
use std::collections::HashSet;
let mut set = HashSet::<u64>::with_capacity(col.len());
for &v in col {
set.insert(gam_data::canonical_level_bits(v));
}
set.len().max(1)
}
pub(crate) const CR_MIN_KNOTS: usize = 3;
fn capped_cr_marginal_knotspec(
col: ArrayView1<'_, f64>,
k_cr_requested: usize,
label: &str,
inference_notes: &mut Vec<String>,
) -> Result<Option<BSplineKnotSpec>, String> {
let n_distinct = unique_count_column(col);
let k_cr = k_cr_requested.min(n_distinct);
if k_cr < CR_MIN_KNOTS {
inference_notes.push(format!(
"Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis requested k={k_cr_requested}, \
but the covariate has only {n_distinct} distinct value(s) — too few to support a cubic \
regression spline (needs >= {CR_MIN_KNOTS} distinct values). Degraded to the linear \
B-spline marginal the default basis builds on the same data."
));
return Ok(None);
}
if k_cr < k_cr_requested {
inference_notes.push(format!(
"Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis reduced from k={k_cr_requested} \
to k={k_cr} to match the covariate's {n_distinct} distinct value(s) (mgcv-style \
data-support cap; a cr basis cannot place more value-knots than the data has)."
));
}
let cr_knots = crate::basis::select_cr_knots(col, k_cr).map_err(|e| e.to_string())?;
Ok(Some(BSplineKnotSpec::NaturalCubicRegression {
knots: cr_knots,
}))
}
fn min_per_group_unique_count(
feature_col: ArrayView1<'_, f64>,
group_col: ArrayView1<'_, f64>,
) -> usize {
use std::collections::{HashMap, HashSet};
let mut per_group: HashMap<u64, HashSet<u64>> = HashMap::new();
for (xi, gi) in feature_col.iter().zip(group_col.iter()) {
per_group
.entry(gam_data::canonical_level_bits(*gi))
.or_default()
.insert(gam_data::canonical_level_bits(*xi));
}
per_group
.values()
.map(|s| s.len())
.min()
.unwrap_or(1)
.max(1)
}
pub(crate) const MAX_DEFAULT_INTERNAL_KNOTS: usize = 8;
pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
let unique = unique_count_column(col);
(unique / 4).clamp(4, MAX_DEFAULT_INTERNAL_KNOTS)
}
fn heuristic_tensor_margin_knots(cols: &[usize], ds: &Dataset) -> Vec<usize> {
let d = cols.len().max(1);
let degree = DEFAULT_BSPLINE_DEGREE;
let min_k = degree + 2; let n = ds.values.nrows();
let per_margin_cap: Vec<usize> = cols
.iter()
.map(|&c| heuristic_knots_for_column(ds.values.column(c)).max(min_k))
.collect();
let mgcv_like_per_margin = match d {
2 => 7usize,
3 => 5usize,
_ => 4usize,
};
let mgcv_like_total = (mgcv_like_per_margin as f64).powi(d as i32);
let data_budget = (n as f64) * 0.8;
let p_target = mgcv_like_total
.max(min_k.pow(d as u32) as f64)
.min(data_budget);
let geo_per_margin = p_target.powf(1.0 / d as f64).round() as usize;
let unclamped: Vec<usize> = per_margin_cap
.iter()
.map(|&cap| geo_per_margin.clamp(min_k, cap))
.collect();
let mut k_list = unclamped;
loop {
let product: f64 = k_list.iter().map(|&k| k as f64).product();
if product >= p_target {
break;
}
let Some(idx) = k_list
.iter()
.zip(per_margin_cap.iter())
.enumerate()
.filter(|&(_, (k, cap))| k < cap)
.max_by_key(|&(_, (k, cap))| (cap - k, *cap))
.map(|(i, _)| i)
else {
break;
};
k_list[idx] += 1;
}
k_list
}
fn parse_endpoint_side(
value: &str,
context: &str,
) -> Result<BSplineEndpointBoundaryCondition, String> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "none" | "open" | "unconstrained" | "free" => {
Ok(BSplineEndpointBoundaryCondition::Free)
}
"clamped" | "clamp" | "zero_derivative" | "zero-derivative" => {
Ok(BSplineEndpointBoundaryCondition::Clamped)
}
"anchored" | "anchor" | "zero" | "zero_value" | "zero-value" => {
Ok(BSplineEndpointBoundaryCondition::Anchored { value: 0.0 })
}
other => Err(format!(
"unsupported {context} boundary condition '{other}'; expected free, clamped, or anchored"
)),
}
}
fn boundary_anchor_value(
options: &BTreeMap<String, String>,
side: &str,
fallback: Option<f64>,
) -> Option<f64> {
[
format!("anchor_{side}"),
format!("{side}_anchor"),
format!("anchor-value-{side}"),
]
.iter()
.find_map(|key| option_f64(options, key))
.or(fallback)
}
fn apply_anchor_value(
cond: BSplineEndpointBoundaryCondition,
value: Option<f64>,
) -> BSplineEndpointBoundaryCondition {
match cond {
BSplineEndpointBoundaryCondition::Anchored { .. } => {
BSplineEndpointBoundaryCondition::Anchored {
value: value.unwrap_or(0.0),
}
}
other => other,
}
}
fn parse_bspline_boundary_conditions(
options: &BTreeMap<String, String>,
) -> Result<BSplineBoundaryConditions, String> {
let fallback_anchor = option_f64(options, "anchor")
.or_else(|| option_f64(options, "anchor_value"))
.or_else(|| option_f64(options, "value"));
let global_boundary_conditions = options
.get("boundary_conditions")
.or_else(|| options.get("bc"))
.or_else(|| options.get("boundary"));
let mut boundary_conditions = BSplineBoundaryConditions::default();
if let Some(raw_boundary_conditions) = global_boundary_conditions {
let cond = parse_endpoint_side(raw_boundary_conditions, "boundary_conditions")?;
let side = options
.get("side")
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_else(|| "both".to_string());
match side.as_str() {
"both" | "all" | "endpoints" => {
boundary_conditions.left = cond;
boundary_conditions.right = cond;
}
"left" | "start" | "lower" => boundary_conditions.left = cond,
"right" | "end" | "upper" => boundary_conditions.right = cond,
other => {
return Err(format!(
"unsupported B-spline boundary side '{other}'; expected left, right, or both"
));
}
}
}
if let Some(raw) = options
.get("bc_left")
.or_else(|| options.get("left_bc"))
.or_else(|| options.get("bc_start"))
.or_else(|| options.get("start_bc"))
{
boundary_conditions.left = parse_endpoint_side(raw, "left endpoint")?;
}
if let Some(raw) = options
.get("bc_right")
.or_else(|| options.get("right_bc"))
.or_else(|| options.get("bc_end"))
.or_else(|| options.get("end_bc"))
{
boundary_conditions.right = parse_endpoint_side(raw, "right endpoint")?;
}
boundary_conditions.left = apply_anchor_value(
boundary_conditions.left,
boundary_anchor_value(options, "left", fallback_anchor),
);
boundary_conditions.right = apply_anchor_value(
boundary_conditions.right,
boundary_anchor_value(options, "right", fallback_anchor),
);
if options.contains_key("side") && global_boundary_conditions.is_none() {
return Err(TermBuilderError::invalid_option(
"`side=` selects which endpoint a boundary condition applies to, but this smooth declares none; add bc=<condition> or drop it",
)
.to_string());
}
if !boundary_conditions.has_anchor()
&& let Some(key) = ANCHOR_VALUE_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::invalid_option(format!(
"`{key}=` sets the value an ANCHORED endpoint is pinned to, but no endpoint of this smooth is anchored; add bc=anchored (or bc_left=/bc_right=anchored) or drop it"
))
.to_string());
}
Ok(boundary_conditions)
}
const ANCHOR_VALUE_OPTION_KEYS: [&str; 8] = [
"anchor",
"anchor_value",
"value",
"anchor_left",
"left_anchor",
"anchor_right",
"right_anchor",
"anchor-value-left",
];
fn parse_ps_internal_knots(
options: &BTreeMap<String, String>,
degree: usize,
default_internal_knots: usize,
) -> Result<(usize, bool, usize), String> {
let knots_internal = if knots_option_is_list(options) {
None
} else {
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(TermBuilderError::incompatible_config(
"ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)",
)
.to_string());
}
if let Some(k) = basis_dim {
if k < 2 {
return Err(TermBuilderError::invalid_option(format!(
"ps/bspline smooth: k={} too small; B-spline basis requires k >= 2",
k
))
.to_string());
}
let effective_degree = degree.min(k - 1).max(1);
let num_internal_knots = k - effective_degree - 1;
Ok((num_internal_knots, false, effective_degree))
} else {
Ok((
knots_internal.unwrap_or(default_internal_knots),
knots_internal.is_none(),
degree,
))
}
}
fn knots_option_is_list(options: &BTreeMap<String, String>) -> bool {
options
.get("knots")
.map(|raw| {
let t = raw.trim();
t.starts_with('[') || t.starts_with("c(") || t.starts_with("C(") || t.starts_with('(')
})
.unwrap_or(false)
}
fn parse_explicit_internal_knots(
options: &BTreeMap<String, String>,
) -> Result<Option<Vec<f64>>, String> {
if !knots_option_is_list(options) {
return Ok(None);
}
let raw = options
.get("knots")
.expect("knots_option_is_list implies the key is present");
let tokens = split_list_option(raw);
if tokens.is_empty() {
return Err(TermBuilderError::invalid_option(format!(
"knots={raw} is an empty list; supply at least one internal knot position \
(e.g. knots=[0.2, 0.5, 0.8]) or a scalar count (e.g. knots=8)"
))
.to_string());
}
let mut positions = Vec::with_capacity(tokens.len());
for tok in &tokens {
let value = parse_numeric_expr(tok).map_err(|err| {
TermBuilderError::invalid_option(format!(
"knots list entry '{tok}' is not a numeric position: {err}"
))
.to_string()
})?;
positions.push(value);
}
Ok(Some(positions))
}
fn parse_tensor_per_axis_usize(
options: &BTreeMap<String, String>,
key: &str,
dim: usize,
) -> Result<Vec<Option<usize>>, String> {
let Some(raw) = options.get(key) else {
return Ok(vec![None; dim]);
};
let values = split_list_option(raw);
let parse_one = |value: &str| -> Result<Option<usize>, String> {
let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
return Ok(None);
}
trimmed.parse::<usize>().map(Some).map_err(|err| {
TermBuilderError::invalid_option(format!(
"tensor smooth `{key}={raw}`: '{trimmed}' is not a non-negative integer ({err})"
))
.to_string()
})
};
if values.len() == 1 {
let shared = parse_one(&values[0])?;
return Ok(vec![shared; dim]);
}
if values.len() != dim {
return Err(TermBuilderError::invalid_option(format!(
"tensor smooth `{key}={raw}` has {} entries but the smooth has {dim} margins; pass one \
value per margin or a single value for all of them",
values.len()
))
.to_string());
}
values.iter().map(|value| parse_one(value)).collect()
}
const CR_MARGIN_DEGREE: usize = 3;
const CR_MARGIN_PENALTY_ORDER: usize = 2;
fn parse_knot_placement(
options: &BTreeMap<String, String>,
) -> Result<crate::basis::BSplineKnotPlacement, String> {
use crate::basis::BSplineKnotPlacement;
match options
.get("knot_placement")
.or_else(|| options.get("knot-placement"))
.or_else(|| options.get("knotplacement"))
{
None => Ok(BSplineKnotPlacement::Uniform),
Some(raw) => match raw
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_ascii_lowercase()
.as_str()
{
"uniform" | "even" | "equal" => Ok(BSplineKnotPlacement::Uniform),
"quantile" | "quantiles" | "data" | "empirical" => Ok(BSplineKnotPlacement::Quantile),
other => Err(TermBuilderError::invalid_option(format!(
"knot_placement={other} is not recognised; expected \"uniform\" or \"quantile\""
))
.to_string()),
},
}
}
fn explicit_knot_placement(
options: &BTreeMap<String, String>,
) -> Result<Option<crate::basis::BSplineKnotPlacement>, String> {
let declared = ["knot_placement", "knot-placement", "knotplacement"]
.iter()
.any(|key| options.contains_key(*key));
if !declared {
return Ok(None);
}
parse_knot_placement(options).map(Some)
}
fn resolve_nonperiodic_bspline_knotspec(
options: &BTreeMap<String, String>,
data: ArrayView1<'_, f64>,
data_range: (f64, f64),
degree: usize,
n_knots: usize,
) -> Result<BSplineKnotSpec, String> {
use crate::basis::{BSplineKnotPlacement, clamped_knot_vector_from_internal_positions};
if let Some(positions) = parse_explicit_internal_knots(options)? {
if option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?.is_some()
{
return Err(TermBuilderError::incompatible_config(
"ps/bspline smooth: specify either explicit knots=[...] positions or \
k=<basis_dim> (not both); the basis size is fixed by the knot vector",
)
.to_string());
}
let knots = clamped_knot_vector_from_internal_positions(data_range, &positions, degree)
.map_err(|e| e.to_string())?;
return Ok(BSplineKnotSpec::Provided(knots));
}
match parse_knot_placement(options)? {
BSplineKnotPlacement::Uniform => Ok(BSplineKnotSpec::Generate {
data_range,
num_internal_knots: n_knots,
}),
BSplineKnotPlacement::Quantile => {
crate::basis::auto_knot_vector_1d_quantile(data, n_knots, degree)
.map_err(|e| e.to_string())?;
Ok(BSplineKnotSpec::Automatic {
num_internal_knots: Some(n_knots),
placement: BSplineKnotPlacement::Quantile,
})
}
}
}
pub(crate) const FACTOR_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"knot_placement",
"knot-placement",
"knotplacement",
"degree",
"penalty_order",
"m",
"double_penalty",
"ordered",
];
pub(crate) const RANDOM_EFFECT_SMOOTH_OPTION_KEYS: &[&str] = &["type", "bs", "ordered"];
const RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS: &[&str] = &[
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"knot_placement",
"knot-placement",
"knotplacement",
"degree",
"penalty_order",
"m",
"double_penalty",
];
fn validate_random_effect_smooth_options(
options: &BTreeMap<String, String>,
) -> Result<(), String> {
if let Some(key) = RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS
.iter()
.find(|key| options.contains_key(**key))
{
return Err(TermBuilderError::incompatible_config(format!(
"bs='re' is a parametric random intercept + slope — the per-level line \
[1, x] under an i.i.d. ridge — not a spline, so it has no basis to shape \
and `{key}=` cannot be honoured. Use bs='fs' for a penalized random \
smooth of x within each level (it accepts {key}=), or drop the option."
))
.to_string());
}
validate_known_options("re", options, RANDOM_EFFECT_SMOOTH_OPTION_KEYS)
}
pub(crate) const CYCLIC_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"degree",
"penalty_order",
"period",
"periods",
"period_start",
"period_end",
"start",
"end",
"origin",
"origins",
"period_origin",
"period-origin",
"domain_origin",
"double_penalty",
"id",
"identifiability",
];
pub(crate) const BSPLINE_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"knot_placement",
"knot-placement",
"knotplacement",
"degree",
"penalty_order",
"boundary",
"bc",
"boundary_conditions",
"bc_left",
"bc_right",
"left_bc",
"right_bc",
"start_bc",
"end_bc",
"side",
"anchor",
"anchor_value",
"value",
"anchor_left",
"left_anchor",
"anchor_right",
"right_anchor",
"periodic",
"period",
"periods",
"period_start",
"period_end",
"origin",
"double_penalty",
"id",
"identifiability",
];
pub(crate) const THINPLATE_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"include_intercept",
"double_penalty",
"id",
"identifiability",
"periodic",
"cyclic",
"period",
"period_start",
"period_end",
"scale_dims",
];
pub(crate) const SPHERE_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"penalty_order",
"m",
"double_penalty",
"id",
"kernel",
"method",
"radians",
"units",
"degree",
"l",
"max_degree",
"max-degree",
"lmax",
"l_max",
"l-max",
];
pub(crate) const CURVATURE_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"kappa",
"length_scale",
"double_penalty",
"id",
];
pub(crate) const MEASURE_JET_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"s",
"alpha",
"tau",
"scales",
"length_scale",
"double_penalty",
"multiscale",
"learn_length_scale",
"id",
];
pub(crate) const MATERN_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"nu",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"include_intercept",
"double_penalty",
"id",
"identifiability",
"periodic",
"cyclic",
"period",
"period_start",
"period_end",
"scale_dims",
];
pub(crate) const DUCHON_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"length_scale",
"centers",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knots",
"rank",
"power",
"p",
"nullspace_order",
"order",
"identifiability",
"periodic",
"cyclic",
"period",
"period_start",
"period_end",
"scale_dims",
"double_penalty",
"id",
];
pub(crate) const TENSOR_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"knot_placement",
"knot-placement",
"knotplacement",
"degree",
"penalty_order",
"double_penalty",
"periodic",
"cyclic",
"period",
"periods",
"period_start",
"period_end",
"origin",
"origins",
"period_origin",
"period-origin",
"domain_origin",
"boundary",
"bc",
"identifiability",
"id",
];
pub(crate) const PCA_SMOOTH_OPTION_KEYS: &[&str] = &[
"type",
"bs",
"by",
"k",
"basis_dim",
"basis-dim",
"basisdim",
"lazy_path",
"path",
"pca_basis_path",
"chunk_size",
"smooth_penalty",
"centered",
"double_penalty",
"id",
];
pub const ENGINE_OPTION_PREFIX: &str = "__";
pub fn is_engine_option(key: &str) -> bool {
key.starts_with(ENGINE_OPTION_PREFIX)
}
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 is_engine_option(key) {
continue;
}
if !known_set.contains(&key.as_str()) {
if term_name == "tensor" && is_tensor_k_axis_option_key(key) {
continue;
}
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(TermBuilderError::invalid_option(format!(
"{term_name}() does not accept option `{key}`{hint}. Valid options: [{}]",
{
let mut sorted = known.to_vec();
sorted.sort_unstable();
sorted.join(", ")
}
))
.to_string());
}
}
Ok(())
}
pub const SECONDARY_CENTER_CAP_OPTION: &str = "__secondary_center_cap";
pub(crate) fn cap_default_spatial_centers(
options: &BTreeMap<String, String>,
default_count: usize,
) -> usize {
match option_usize(options, SECONDARY_CENTER_CAP_OPTION) {
Some(cap) => default_count.min(cap),
None => default_count,
}
}
fn default_matern_center_count(
n: usize,
d: usize,
planned_count: usize,
univariate_floor: usize,
) -> usize {
let low_n_floor = (d + 4).min(n);
planned_count
.max(low_n_floor)
.max(univariate_floor.min(n))
.max(1)
}
fn default_duchon_center_count(
n: usize,
d: usize,
planned_count: usize,
polynomial_cols: usize,
univariate_floor: usize,
) -> usize {
let mgcv_default = 10usize.saturating_mul(3usize.saturating_pow(d.saturating_sub(1) as u32));
let low_n_floor = (polynomial_cols + 1).min(n).max(1);
planned_count
.min(mgcv_default)
.max(low_n_floor)
.max(univariate_floor.min(n))
}
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(TermBuilderError::incompatible_config(format!(
"specify either {}=<count> or k=<basis_dim> (not both)",
primarykey
))
.to_string());
}
Ok(primary.or(basis_dim).unwrap_or(default_count))
}
pub fn parse_penalty_order_alias(
options: &BTreeMap<String, String>,
) -> Result<Option<usize>, String> {
let primary = option_usize_strict(options, "penalty_order")?;
let alias = option_usize_strict(options, "m")?;
match (primary, alias) {
(Some(primary), Some(alias)) if primary != alias => {
Err(TermBuilderError::incompatible_config(format!(
"penalty_order={primary} and m={alias} are two spellings of the same \
option (the order of the penalised derivative), so they cannot disagree; \
specify one of them"
))
.to_string())
}
(Some(primary), _) => Ok(Some(primary)),
(None, alias) => Ok(alias),
}
}
pub 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_cyclic_boundary(
options: &BTreeMap<String, String>,
minv: f64,
maxv: f64,
) -> Result<OneDimensionalBoundary, String> {
let cyclic = option_bool(options, "cyclic")
.or_else(|| option_bool(options, "periodic"))
.unwrap_or(false);
if !cyclic {
return Ok(OneDimensionalBoundary::Open);
}
let start = match option_numeric_expr(options, "period_start")? {
Some(v) => v,
None => option_numeric_expr(options, "start")?.unwrap_or(minv),
};
let end = match option_numeric_expr(options, "period_end")? {
Some(v) => v,
None => option_numeric_expr(options, "end")?.unwrap_or(maxv),
};
if end <= start {
return Err(format!(
"cyclic smooth requires period_end/end ({end}) > period_start/start ({start})"
));
}
Ok(OneDimensionalBoundary::Cyclic { start, end })
}
pub fn parse_periodic_domain_1d(
options: &BTreeMap<String, String>,
minv: f64,
maxv: f64,
) -> Result<(f64, f64), String> {
let start_opt = match option_numeric_expr(options, "period_start")? {
Some(v) => Some(v),
None => option_numeric_expr(options, "start")?,
};
let end_opt = match option_numeric_expr(options, "period_end")? {
Some(v) => Some(v),
None => option_numeric_expr(options, "end")?,
};
if end_opt.is_none() && start_opt.is_none() {
return Err(
"periodic B-spline smooth requires an explicit period: pass period=<value> \
(e.g. period=2*pi) or period_start=/period_end=. Deriving the period from the \
observed data range is sample-dependent and produces an off-by-ε seam, so it is \
not inferred."
.to_string(),
);
}
let start = start_opt.unwrap_or(minv);
let end = end_opt.unwrap_or(maxv);
if !(start.is_finite() && end.is_finite()) {
return Err(format!(
"periodic smooth domain requires finite endpoints, got ({start}, {end})"
));
}
if end <= start {
return Err(format!(
"periodic smooth requires period_end/end ({end}) > period_start/start ({start})"
));
}
Ok((start, end - start))
}
fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
let trimmed = raw.trim();
let lowered = trimmed.to_ascii_lowercase();
let named = match lowered.as_str() {
"1/2" | "0.5" | "half" => Some(MaternNu::Half),
"3/2" | "1.5" => Some(MaternNu::ThreeHalves),
"5/2" | "2.5" => Some(MaternNu::FiveHalves),
"7/2" | "3.5" => Some(MaternNu::SevenHalves),
"9/2" | "4.5" => Some(MaternNu::NineHalves),
_ => None,
};
if let Some(nu) = named {
return Ok(nu);
}
let value = if let Some((num, den)) = trimmed.split_once('/') {
let num = num
.trim()
.parse::<f64>()
.map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
let den = den
.trim()
.parse::<f64>()
.map_err(|err| format!("{}: {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(|err| format!("{}: {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 {
TermBuilderError::unsupported_feature(format!(
"unsupported Matern nu '{raw}'; supported half-integer values are 1/2, 3/2, 5/2, 7/2, and 9/2"
))
.to_string()
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DuchonPowerPolicy {
Explicit(f64),
CubicStructuralDefault,
}
pub fn parse_duchon_power_policy(
options: &BTreeMap<String, String>,
) -> Result<DuchonPowerPolicy, String> {
if let Some(raw_nu) = options.get("nu") {
return Err(TermBuilderError::incompatible_config(format!(
"Duchon smooths use power=<number>, not nu='{}'. Use power=1.5, power=2, etc.",
raw_nu
))
.to_string());
}
match options.get("power").or_else(|| options.get("p")) {
Some(raw) => {
let value = raw.parse::<f64>().map_err(|err| {
TermBuilderError::invalid_option(format!(
"invalid Duchon power '{}'; expected a non-negative number such as power=1.5 or power=2: {}",
raw, err
))
.to_string()
})?;
if !value.is_finite() || value < 0.0 {
return Err(TermBuilderError::invalid_option(format!(
"invalid Duchon power '{}'; expected a finite non-negative number such as power=1.5 or power=2",
raw
))
.to_string());
}
Ok(DuchonPowerPolicy::Explicit(value))
}
None => Ok(DuchonPowerPolicy::CubicStructuralDefault),
}
}
pub fn parse_duchon_order_opt(
options: &BTreeMap<String, String>,
) -> Result<Option<DuchonNullspaceOrder>, String> {
if !options.contains_key("order") && !options.contains_key("nullspace_order") {
return Ok(None);
}
parse_duchon_order(options).map(Some)
}
pub fn parse_duchon_order(
options: &BTreeMap<String, String>,
) -> Result<DuchonNullspaceOrder, String> {
match options.get("order").or_else(|| options.get("nullspace_order")) {
None => Ok(DuchonNullspaceOrder::Linear),
Some(raw) => match raw.parse::<usize>() {
Ok(0) => Ok(DuchonNullspaceOrder::Zero),
Ok(1) => Ok(DuchonNullspaceOrder::Linear),
Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
Err(_) => Err(TermBuilderError::invalid_option(format!(
"invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
raw
))
.to_string()),
},
}
}
fn parse_matern_identifiability(
options: &BTreeMap<String, String>,
) -> Result<MaternIdentifiability, TermBuilderError> {
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(TermBuilderError::unsupported_feature(format!(
"invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
))),
}
}
fn parse_spatial_identifiability(
options: &BTreeMap<String, String>,
) -> Result<SpatialIdentifiability, TermBuilderError> {
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(TermBuilderError::unsupported_feature(
"spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric",
)),
other => Err(TermBuilderError::unsupported_feature(format!(
"invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
))),
}
}
#[cfg(test)]
mod tests;