gam_terms/term_builder.rs
1//! Term construction: bridge from parsed formula terms to `TermCollectionSpec`.
2//!
3//! This module takes the AST produced by `inference::formula_dsl` and a loaded
4//! dataset, resolves column references, infers knot counts and center strategies,
5//! and produces a `TermCollectionSpec` ready for `build_term_collection_design`.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::path::PathBuf;
9
10use ndarray::{Array2, ArrayView1};
11
12use crate::basis::{
13 BSplineBasisSpec, BSplineBoundaryConditions, BSplineEndpointBoundaryCondition,
14 BSplineIdentifiability, BSplineKnotSpec, CenterCountRequest, CenterStrategy,
15 ConstantCurvatureBasisSpec, ConstantCurvatureIdentifiability, DuchonBasisSpec,
16 DuchonNullspaceOrder, DuchonOperatorPenaltySpec, DuchonSpectralBasis, MaternBasisSpec,
17 MaternIdentifiability, MaternLengthScale, MaternNu, MeasureJetBasisSpec,
18 MeasureJetIdentifiability, OneDimensionalBoundary, SpatialIdentifiability, SphereMethod,
19 SphereWahbaKernel, SphericalSplineBasisSpec, SphericalSplineIdentifiability,
20 ThinPlateBasisSpec, auto_spatial_center_strategy, count_unique_coordinate_rows,
21 default_num_centers, default_spatial_center_strategy, default_spherical_harmonic_degree,
22 plan_spatial_basis, select_r_uniform_subsample_centers, thin_plate_penalty_order,
23};
24use crate::inference::formula_dsl::{
25 ParsedTerm, SmoothKind, option_bool, option_f64, option_f64_strict, option_usize,
26 option_usize_any, option_usize_any_strict, option_usize_strict, parsed_term_column_names,
27 strip_quotes,
28};
29use crate::smooth::{
30 BySmoothKind, ByVarKind, ByVariableSpec, FactorSmoothFlavour, FactorSmoothSpec,
31 LinearCoefficientGeometry, LinearTermSpec, RandomEffectTermSpec, ShapeConstraint,
32 SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
33 TensorBSplinePenaltyDecomposition, TensorBSplineSpec, TermCollectionSpec,
34};
35use gam_data::{ColumnKindTag, DataError, EncodedDataset as Dataset};
36use gam_problem::types::ColIdx;
37use gam_runtime::resource::ResourcePolicy;
38
39/// Default B-spline degree when a smooth's `degree=` option is absent. Cubic
40/// (degree 3) is the standard GAM convention: C² continuity with a low knot
41/// count.
42const DEFAULT_BSPLINE_DEGREE: usize = 3;
43
44/// Default difference-penalty order when a smooth's `penalty_order=` (alias
45/// `m=`) option is absent. Second-order (curvature) is the standard P-spline
46/// convention.
47const DEFAULT_PENALTY_ORDER: usize = 2;
48
49/// Admissible `lmax=` for the truncated Wahba sphere kernels, matching the
50/// documented range on [`SphereWahbaKernel::SobolevTruncated`]. The lower end
51/// keeps at least a few degrees of resolution; the upper end is the bound the
52/// device kernel bakes in as a compile-time `#define`.
53const SPHERE_TRUNCATION_LMAX_RANGE: std::ops::RangeInclusive<usize> = 5..=200;
54
55/// Default basis dimension for one-dimensional cyclic cubic P-splines.
56///
57/// Periodic smooths spend no coefficients on free endpoints, so they should not
58/// inherit the larger open B-spline knot ceiling by default. This is still only
59/// a default: callers can request a richer periodic space with `k=`.
60const CYCLIC_DEFAULT_BASIS_DIM: usize = 12;
61
62/// Default shared-marginal basis dimension for `bs="fs"`/`bs="sz"` factor smooths,
63/// matching mgcv's factor-smooth default `k=10`. A factor smooth shares one
64/// marginal across all levels; a modest basis recovers the shared signal without
65/// over-fitting each group's within-group noise (gam#903). Overridden by an
66/// explicit `k`/`basis_dim`.
67const FACTOR_SMOOTH_DEFAULT_BASIS_DIM: usize = 10;
68
69/// Default row-chunk size for the out-of-core PCA-basis smooth when the
70/// `chunk_size=` option is absent. Streams the design in row blocks to bound
71/// peak memory independent of the dataset row count.
72const DEFAULT_PCA_CHUNK_SIZE: usize = 4096;
73
74// ---------------------------------------------------------------------------
75// Typed errors
76// ---------------------------------------------------------------------------
77
78/// Typed errors emitted by term-builder helpers. `Display` reproduces the exact
79/// pre-refactor `format!(...)` text byte-for-byte, so callers that string-match
80/// on the message (tests, log assertions) keep working unchanged. Public-API
81/// functions still return `Result<_, String>` and use `.to_string()` shims at
82/// their boundary to stay compatible with callers in protected modules.
83#[derive(Clone, Debug)]
84pub enum TermBuilderError {
85 /// Column-resolution / column-kind lookup failures whose context is purely
86 /// internal (column-kind table out-of-sync, alias map missing an entry,
87 /// etc.). User-facing "this formula references a column that doesn't
88 /// exist" diagnostics use the dedicated `ColumnNotFound` variant so the
89 /// FFI boundary can lift the structured payload into a Python
90 /// `ColumnNotFoundError` without parsing prose.
91 MissingColumn { reason: String },
92 /// A formula referenced a column that is not present in the input data.
93 /// Mirrors `DataError::ColumnNotFound` field-for-field so the conversion
94 /// across module boundaries is a pure data move (no re-derivation, no
95 /// string re-parsing). Public callers see byte-identical `Display`
96 /// output to the legacy `missing_column_message` text.
97 ColumnNotFound {
98 name: String,
99 role: Option<String>,
100 available: Vec<String>,
101 similar: Vec<String>,
102 tsv_hint: bool,
103 },
104 /// User-specified configuration is internally inconsistent (e.g. too few
105 /// variables for a smooth type, conflicting size options, requested basis
106 /// dimension below the polynomial nullspace).
107 IncompatibleConfig { reason: String },
108 /// Option parsing failure: malformed numeric expression, unknown option
109 /// key, out-of-range integer, list-length mismatch, etc.
110 InvalidOption { reason: String },
111 /// User requested a feature that is intentionally not supported (unknown
112 /// smooth type / method / kernel / identifiability, non-zero anchor,
113 /// internal-only token, etc.).
114 UnsupportedFeature { reason: String },
115 /// Input data is degenerate for the requested term (constant column,
116 /// non-finite categorical entries, ...).
117 DegenerateData { reason: String },
118 /// Term-collection-stage formula error — a node that the caller was
119 /// supposed to resolve upstream reached the builder.
120 MalformedFormula { reason: String },
121}
122
123impl std::fmt::Display for TermBuilderError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 TermBuilderError::MissingColumn { reason }
127 | TermBuilderError::IncompatibleConfig { reason }
128 | TermBuilderError::InvalidOption { reason }
129 | TermBuilderError::UnsupportedFeature { reason }
130 | TermBuilderError::DegenerateData { reason }
131 | TermBuilderError::MalformedFormula { reason } => f.write_str(reason),
132 // Delegate to the canonical `DataError::ColumnNotFound` formatter
133 // so a single source of truth defines the human text. The
134 // intermediate `DataError` constructed here owns its strings only
135 // for the duration of the Display call — no allocation cost
136 // beyond the original payload that this variant already holds.
137 TermBuilderError::ColumnNotFound {
138 name,
139 role,
140 available,
141 similar,
142 tsv_hint,
143 } => {
144 let canonical = DataError::ColumnNotFound {
145 name: name.clone(),
146 role: role.clone(),
147 available: available.clone(),
148 similar: similar.clone(),
149 tsv_hint: *tsv_hint,
150 };
151 std::fmt::Display::fmt(&canonical, f)
152 }
153 }
154 }
155}
156
157impl From<TermBuilderError> for String {
158 fn from(err: TermBuilderError) -> String {
159 err.to_string()
160 }
161}
162
163/// Catchall lift for the term-builder's internal `Result<_, String>` helpers
164/// (numeric expression parsing, option lookup, boundary-condition parsing,
165/// ...) that flow into `build_termspec` via `?`. Maps to
166/// `IncompatibleConfig`, which is the most appropriate generic bucket for
167/// option/config-style failures — leaf sites that emit structured payloads
168/// (`From<DataError>` for column-not-found) bypass this fallback.
169impl From<String> for TermBuilderError {
170 fn from(reason: String) -> Self {
171 Self::IncompatibleConfig { reason }
172 }
173}
174
175/// Typed lift from data-layer errors. `DataError::ColumnNotFound` becomes
176/// `TermBuilderError::ColumnNotFound` field-for-field — no stringification,
177/// no information loss — so the FFI boundary downstream can dispatch on
178/// the typed variant. Other `DataError` variants degrade into
179/// `MissingColumn` since they describe column-resolution-time failures
180/// without a dedicated structured destination.
181impl From<DataError> for TermBuilderError {
182 fn from(err: DataError) -> Self {
183 match err {
184 DataError::ColumnNotFound {
185 name,
186 role,
187 available,
188 similar,
189 tsv_hint,
190 } => Self::ColumnNotFound {
191 name,
192 role,
193 available,
194 similar,
195 tsv_hint,
196 },
197 DataError::SchemaMismatch { reason }
198 | DataError::ParseError { reason }
199 | DataError::EncodingFailure { reason }
200 | DataError::EmptyInput { reason }
201 | DataError::InvalidValue { reason } => Self::MissingColumn { reason },
202 DataError::DegenerateColumn { column, problem } => Self::DegenerateData {
203 reason: format!("column '{column}' {problem}"),
204 },
205 }
206 }
207}
208
209// Constructor helpers — keep error-site code compact and consistent.
210impl TermBuilderError {
211 #[inline]
212 fn missing_column(reason: impl Into<String>) -> Self {
213 TermBuilderError::MissingColumn {
214 reason: reason.into(),
215 }
216 }
217 #[inline]
218 fn incompatible_config(reason: impl Into<String>) -> Self {
219 TermBuilderError::IncompatibleConfig {
220 reason: reason.into(),
221 }
222 }
223 #[inline]
224 fn invalid_option(reason: impl Into<String>) -> Self {
225 TermBuilderError::InvalidOption {
226 reason: reason.into(),
227 }
228 }
229 #[inline]
230 fn unsupported_feature(reason: impl Into<String>) -> Self {
231 TermBuilderError::UnsupportedFeature {
232 reason: reason.into(),
233 }
234 }
235 #[inline]
236 fn degenerate_data(reason: impl Into<String>) -> Self {
237 TermBuilderError::DegenerateData {
238 reason: reason.into(),
239 }
240 }
241 #[inline]
242 fn malformed_formula(reason: impl Into<String>) -> Self {
243 TermBuilderError::MalformedFormula {
244 reason: reason.into(),
245 }
246 }
247}
248
249// ---------------------------------------------------------------------------
250// Column resolution
251// ---------------------------------------------------------------------------
252
253/// Resolve a bare column name to its index, returning a typed
254/// `DataError::ColumnNotFound` on miss so the FFI boundary can surface a
255/// structured `gamfit.ColumnNotFoundError(column=…, available=…)` rather
256/// than rely on string-classification of human prose. Internal callers that
257/// still flow `Result<_, String>` get byte-identical text via
258/// `From<DataError> for String`.
259pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, DataError> {
260 col_map
261 .get(name)
262 .copied()
263 .ok_or_else(|| DataError::column_not_found(col_map, name, None))
264}
265
266/// Like `resolve_col` but tags the missing-column payload with a role label
267/// (`"response"`, `"entry"`, `"exit"`, `"event"`, `"z"`, `"id"`, …) so the
268/// boundary-side Python exception can disambiguate which formula slot held
269/// the bad reference.
270pub fn resolve_role_col(
271 col_map: &HashMap<String, usize>,
272 name: &str,
273 role: &str,
274) -> Result<usize, DataError> {
275 col_map
276 .get(name)
277 .copied()
278 .ok_or_else(|| DataError::column_not_found(col_map, name, Some(role)))
279}
280
281fn encoded_levels_for_column(ds: &Dataset, col: ColIdx) -> Vec<(u64, String)> {
282 let mut seen = BTreeSet::<u64>::new();
283 for value in ds.values.column(col.get()) {
284 if value.is_finite() {
285 seen.insert(gam_data::canonical_level_bits(*value));
286 }
287 }
288 let schema_levels = ds
289 .schema
290 .columns
291 .get(col.get())
292 .map(|column| column.levels.as_slice())
293 .unwrap_or(&[]);
294 seen.into_iter()
295 .enumerate()
296 .map(|(idx, bits)| {
297 let fallback = format!("level{}", idx + 1);
298 let label = schema_levels.get(idx).cloned().unwrap_or(fallback);
299 (bits, label)
300 })
301 .collect()
302}
303
304/// Internal option key carrying the row count that n-scaling BASIS DEFAULTS
305/// (radial center counts, spatial plans) must size from. A factor-by smooth
306/// expands into per-level blocks that each see ONLY their level's rows, so
307/// sizing the default from the pooled row count over-provisions every level —
308/// measured on the #1561 by-group location-scale fixture: `s(x, bs='tp',
309/// by=group)` at n=200 (100/group) got ~50 centers PER LEVEL, an
310/// ill-conditioned 100-column mean block whose truth-recovery floor (0.111)
311/// no λ could beat, while the same smooth sized for the level's own 100 rows
312/// recovers to ~0.036. Explicit user `centers=`/`k=` bypass the default and
313/// are unaffected. Stripped at the top of [`build_smooth_basis`] like
314/// `__by_col`, so per-kind option allow-lists never see it.
315const DEFAULT_SIZING_ROWS_OPTION: &str = "__default_sizing_rows";
316
317/// The smallest per-level row count of a categorical by-column: the effective
318/// sample size each by-level smooth block actually fits. `None` when the
319/// column has no finite rows (callers fall back to the pooled count).
320fn min_categorical_by_level_rows(ds: &Dataset, by_col: usize) -> Option<usize> {
321 let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
322 for value in ds.values.column(by_col) {
323 if value.is_finite() {
324 *counts
325 .entry(gam_data::canonical_level_bits(*value))
326 .or_insert(0) += 1;
327 }
328 }
329 counts.values().copied().min()
330}
331
332/// Insert [`DEFAULT_SIZING_ROWS_OPTION`] into `inner_options` when the by
333/// column is categorical (numeric-by smooths keep one shared block over all
334/// rows, so pooled sizing stays correct there).
335fn inject_by_level_sizing_rows(
336 inner_options: &mut BTreeMap<String, String>,
337 ds: &Dataset,
338 by_col: usize,
339) {
340 if matches!(
341 ds.column_kinds.get(by_col).copied(),
342 Some(ColumnKindTag::Categorical)
343 ) && let Some(min_rows) = min_categorical_by_level_rows(ds, by_col)
344 {
345 inner_options.insert(DEFAULT_SIZING_ROWS_OPTION.to_string(), min_rows.to_string());
346 }
347}
348
349pub fn column_map_with_alias(
350 col_map: &HashMap<String, usize>,
351 alias: &str,
352 target_column: &str,
353) -> HashMap<String, usize> {
354 let mut aliased = col_map.clone();
355 if let Some(idx) = col_map.get(target_column).copied() {
356 aliased.entry(alias.to_string()).or_insert(idx);
357 }
358 aliased
359}
360
361/// The canonical marginal-slope alias: `z` in a formula binds to the column
362/// named by `z_column`.
363pub const MARGINAL_SLOPE_Z_ALIAS: &str = "z";
364
365/// Whether writing `z` in a formula would resolve to `z_column` for this frame.
366///
367/// `column_map_with_alias` inserts with `or_insert`, so a frame that carries its
368/// own real `z` column keeps it and the alias is inert — writing `z` there means
369/// that column, which is legitimate. The alias is live only when `z_column`
370/// exists and `z` does not, and only then does `z` silently denote the score.
371pub fn marginal_slope_z_alias_is_live(col_map: &HashMap<String, usize>, z_column: &str) -> bool {
372 col_map.contains_key(z_column) && !col_map.contains_key(MARGINAL_SLOPE_Z_ALIAS)
373}
374
375// ---------------------------------------------------------------------------
376// ParsedTerm[] + Dataset → TermCollectionSpec
377// ---------------------------------------------------------------------------
378
379pub fn build_termspec(
380 terms: &[ParsedTerm],
381 ds: &Dataset,
382 col_map: &HashMap<String, usize>,
383 inference_notes: &mut Vec<String>,
384 policy: &ResourcePolicy,
385) -> Result<TermCollectionSpec, TermBuilderError> {
386 // Generic ingestion deliberately preserves missing cells because it runs
387 // before a formula exists. This is the first layer that knows the complete
388 // set of columns consumed by term construction (including `by=` and nested
389 // slope surfaces), so completeness is enforced here and nowhere wider.
390 let mut consumed_columns = BTreeSet::new();
391 parsed_term_column_names(terms, &mut consumed_columns);
392 for name in consumed_columns {
393 let column = resolve_col(col_map, &name)?;
394 if let Some(row) = ds
395 .values
396 .column(column)
397 .iter()
398 .position(|value| !value.is_finite())
399 {
400 return Err(TermBuilderError::degenerate_data(format!(
401 "model term column '{name}' contains a non-finite value at row {}",
402 row + 1
403 )));
404 }
405 }
406
407 let mut linear_terms = Vec::<LinearTermSpec>::new();
408 let mut random_terms = Vec::<RandomEffectTermSpec>::new();
409 let mut smooth_terms = Vec::<SmoothTermSpec>::new();
410 let smooth_coordinate_count = terms
411 .iter()
412 .map(|term| match term {
413 ParsedTerm::Smooth { vars, .. } => vars.len(),
414 _ => 0,
415 })
416 .sum::<usize>();
417
418 for t in terms {
419 match t {
420 ParsedTerm::Linear {
421 name,
422 explicit,
423 double_penalty,
424 coefficient_min,
425 coefficient_max,
426 } => {
427 let col = resolve_col(col_map, name)?;
428 let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
429 TermBuilderError::missing_column(format!(
430 "internal column-kind lookup failed for '{name}'"
431 ))
432 .to_string()
433 })?;
434 if *explicit {
435 linear_terms.push(LinearTermSpec {
436 name: name.clone(),
437 feature_col: col,
438 feature_cols: vec![col],
439 categorical_levels: vec![],
440 // Parametric terms are unpenalized/MLE by default.
441 // `double_penalty=true` is an explicit shrinkage choice
442 // carried by the parsed term.
443 double_penalty: *double_penalty,
444 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
445 coefficient_min: *coefficient_min,
446 coefficient_max: *coefficient_max,
447 frozen_function_mass: None,
448 });
449 } else {
450 match auto_kind {
451 ColumnKindTag::Continuous | ColumnKindTag::Binary => {
452 linear_terms.push(LinearTermSpec {
453 name: name.clone(),
454 feature_col: col,
455 feature_cols: vec![col],
456 categorical_levels: vec![],
457 // Preserve the parser's explicit opt-in. Bare
458 // numeric terms arrive as `false`.
459 double_penalty: *double_penalty,
460 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
461 coefficient_min: *coefficient_min,
462 coefficient_max: *coefficient_max,
463 frozen_function_mass: None,
464 });
465 }
466 ColumnKindTag::Categorical => {
467 if coefficient_min.is_some() || coefficient_max.is_some() {
468 return Err(TermBuilderError::incompatible_config(format!(
469 "coefficient constraints are not supported for categorical auto-random-effect term '{name}'; use group({name}) or an unconstrained numeric term"
470 )));
471 }
472 random_terms.push(RandomEffectTermSpec {
473 name: name.clone(),
474 feature_col: col,
475 drop_first_level: false,
476 penalized: true,
477 frozen_levels: None,
478 // A BARE categorical main effect (`+ g`) is a FIXED
479 // parametric factor. Although it is auto-promoted to
480 // a penalized random block above, an *unseen* level
481 // at predict must raise a schema mismatch rather than
482 // be mapped to the factor's centering point (#2102).
483 lenient_unseen: false,
484 });
485 }
486 }
487 }
488 }
489 ParsedTerm::BoundedLinear {
490 name,
491 min,
492 max,
493 prior,
494 double_penalty,
495 } => {
496 let col = resolve_col(col_map, name)?;
497 let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
498 TermBuilderError::missing_column(format!(
499 "internal column-kind lookup failed for '{name}'"
500 ))
501 .to_string()
502 })?;
503 if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
504 return Err(TermBuilderError::incompatible_config(format!(
505 "bounded() currently supports only numeric columns, got categorical '{name}'"
506 )));
507 }
508 linear_terms.push(LinearTermSpec {
509 name: name.clone(),
510 feature_col: col,
511 feature_cols: vec![col],
512 categorical_levels: vec![],
513 double_penalty: *double_penalty,
514 coefficient_geometry: LinearCoefficientGeometry::Bounded {
515 min: *min,
516 max: *max,
517 prior: prior.clone(),
518 },
519 coefficient_min: None,
520 coefficient_max: None,
521 frozen_function_mass: None,
522 });
523 }
524 ParsedTerm::RandomEffect {
525 name,
526 lenient_unseen,
527 } => {
528 let col = resolve_col(col_map, name)?;
529 random_terms.push(RandomEffectTermSpec {
530 name: name.clone(),
531 feature_col: col,
532 drop_first_level: false,
533 penalized: true,
534 frozen_levels: None,
535 // Unseen-level policy is fixed by the wrapper the user wrote
536 // (`formula_dsl`): a genuine random effect
537 // (`group(g)`/`re(g)`/`s(g, bs="re")`) shrinks a held-out
538 // group to the population mean and so tolerates unseen
539 // levels; a fixed `factor(g)`, like a bare `+ g` categorical
540 // main effect, must reject an unseen level rather than
541 // collapse onto the centering point (#2137/#2102).
542 lenient_unseen: *lenient_unseen,
543 });
544 }
545 ParsedTerm::Smooth {
546 label,
547 vars,
548 kind,
549 options,
550 } => {
551 let smooth_vars = vars.clone();
552 let by_name = options.get("by").cloned();
553 // `bs="sz"` (sum-to-zero), like `bs="fs"`/`bs="re"`, is a
554 // factor-smooth family handled natively by `build_smooth_basis`'s
555 // fs/sz/re path: it detects the categorical factor among the
556 // variables and emits a `SmoothBasisSpec::FactorSmooth { Sz }`
557 // with the correct single-penalty marginal and modest default
558 // basis. Route sz straight through `build_smooth_basis` rather
559 // than intercepting it into a legacy `FactorSumToZero` envelope
560 // here (which left `sz(fac, x)` mis-typed as `FactorSumToZero`
561 // instead of the expected `FactorSmooth { Sz }`).
562 let cols = smooth_vars
563 .iter()
564 .map(|v| resolve_col(col_map, v))
565 .collect::<Result<Vec<_>, _>>()?;
566 let mut inner_options = options.clone();
567 inner_options.remove("by");
568 // `ordered=` is consumed here (ByVarKind::Factor routing) and
569 // must not propagate to the inner basis builder, which has no
570 // allow-list entry for it and would reject it as an unknown option.
571 inner_options.remove("ordered");
572 // Pop the shape constraint before `build_smooth_basis` runs so
573 // it never reaches the per-kind `validate_known_options`
574 // allow-lists (the constraint is a property of the smooth term,
575 // not of any one basis kind). Basis-incompatible requests still
576 // fail loudly downstream via `shape_supports_basis`.
577 let shape = match inner_options.remove("shape") {
578 None => ShapeConstraint::None,
579 Some(raw) => crate::smooth::parse_shape_constraint(&raw)
580 .map_err(TermBuilderError::invalid_option)?,
581 };
582 // A categorical by= expands into per-level blocks below; size
583 // the inner basis's n-scaling defaults from the smallest
584 // level's rows, not the pooled count (see
585 // `DEFAULT_SIZING_ROWS_OPTION`).
586 if let Some(by_name) = by_name.as_deref() {
587 let by_col = resolve_col(col_map, by_name)?;
588 inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
589 }
590 let inner_basis = build_smooth_basis(
591 *kind,
592 &smooth_vars,
593 &cols,
594 &inner_options,
595 ds,
596 inference_notes,
597 policy,
598 smooth_coordinate_count,
599 )?;
600 // `bs="sz"` deliberately stays typed as `SmoothBasisSpec::FactorSmooth
601 // { Sz }` (#1403, owner-confirmed in #1887): the `FactorSumToZero`
602 // envelope is the *legacy, mis-typed* representation. `build_factor_smooth`
603 // reuses the sum-to-zero construction internally as its single source of
604 // truth for the zero-sum geometry (term_specs.rs) while keeping the
605 // freeze-consistent `FactorSmooth` metadata shape shared by fs/sz/re, so
606 // there is no reason to re-wrap the spec into the legacy envelope here —
607 // doing so (#1981) mis-typed `sz(fac, x)` back to `FactorSumToZero` and
608 // broke the refit/predict freeze path's `(FactorSmooth, …)` metadata match.
609 if let Some(by_name) = by_name {
610 let by_col = resolve_col(col_map, &by_name)?;
611 match ds.column_kinds.get(by_col).copied().ok_or_else(|| {
612 format!("internal column-kind lookup failed for by variable '{by_name}'")
613 })? {
614 ColumnKindTag::Categorical => {
615 let levels = encoded_levels_for_column(ds, ColIdx::new(by_col));
616 // A penalized random block for this factor already
617 // owns its full level offsets when EITHER an explicit
618 // `group(factor)` appears, OR a *bare* categorical
619 // `+ factor` does — the latter is auto-promoted to a
620 // penalized random-effect block (see the
621 // `ParsedTerm::Linear` / `ColumnKindTag::Categorical`
622 // arm above, `penalized: true`). Both representations
623 // carry the same per-level offsets, so #1457: the
624 // `by=` branch must NOT additionally add its own
625 // unpenalized treatment-coded main effect, which would
626 // double-represent the factor (two `g` design blocks +
627 // a spurious extra smoothing parameter).
628 let penalized_group_owner_present =
629 terms.iter().any(|other| match other {
630 ParsedTerm::RandomEffect { name, .. } => name == &by_name,
631 ParsedTerm::Linear {
632 name,
633 explicit: false,
634 ..
635 } if name == &by_name => col_map
636 .get(name)
637 .and_then(|c| ds.column_kinds.get(*c).copied())
638 .map(|kind| matches!(kind, ColumnKindTag::Categorical))
639 .unwrap_or(false),
640 _ => false,
641 });
642 // Add an unpenalized treatment-coded fixed main
643 // effect for a standalone factor-by smooth, unless
644 // the same factor already has an explicit
645 // `group(factor)` term OR a bare categorical `+
646 // factor` that was auto-promoted to a penalized
647 // random block (#1457). In those mixed-model forms
648 // the penalized random intercept is the coherent
649 // owner of level offsets; adding a no-pooling fixed
650 // factor effect would bypass random-effect
651 // shrinkage and degrade BLUP-style predictions.
652 if !random_terms.iter().any(|rt| rt.name == by_name)
653 && !penalized_group_owner_present
654 {
655 random_terms.push(RandomEffectTermSpec {
656 name: by_name.clone(),
657 feature_col: by_col,
658 drop_first_level: true,
659 penalized: false,
660 frozen_levels: None,
661 // Unpenalized treatment-coded FIXED factor main
662 // effect for a factor-by smooth: an unseen level
663 // is out of contract and must raise, not center
664 // (#2102).
665 lenient_unseen: false,
666 });
667 }
668 // Unordered factor-by smooths are independent
669 // level-specific smooths. Preserve that
670 // term-spec structure explicitly so later
671 // hierarchy/identifiability passes can see the
672 // per-level ownership rather than a generic
673 // BySmooth envelope.
674 for (level_bits, level_label) in levels {
675 smooth_terms.push(SmoothTermSpec {
676 frozen_parametric_residualization: None,
677 name: format!("{label}:by={by_name}[{level_label}]"),
678 basis: SmoothBasisSpec::ByVariable {
679 inner: Box::new(inner_basis.clone()),
680 by_col,
681 kind: BySmoothKind::Level { level_bits },
682 by: ByVariableSpec::Level {
683 value_bits: level_bits,
684 label: level_label,
685 },
686 },
687 shape: shape.clone(),
688 joint_null_rotation: None,
689 });
690 }
691 }
692 ColumnKindTag::Binary | ColumnKindTag::Continuous => {
693 let mut inner_basis = inner_basis;
694 // A continuous by-variable makes a varying
695 // coefficient `f(x)·z` whose constant direction is
696 // `z` itself, not the intercept, so the inner
697 // smooth's default centring would delete the
698 // coefficient's average and force it into a
699 // separate unpenalised `z` term. Keep the constant
700 // in the penalised block; an explicit
701 // `identifiability=` still wins. A binary
702 // by-variable is a factor in disguise and keeps
703 // the factor convention (`s(x, by=g) + g`).
704 if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
705 && !options.contains_key("identifiability")
706 {
707 crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner_basis);
708 }
709 smooth_terms.push(SmoothTermSpec {
710 frozen_parametric_residualization: None,
711 name: label.clone(),
712 basis: SmoothBasisSpec::ByVariable {
713 inner: Box::new(inner_basis),
714 by_col,
715 kind: BySmoothKind::Numeric,
716 by: ByVariableSpec::Numeric,
717 },
718 shape,
719 joint_null_rotation: None,
720 });
721 }
722 }
723 } else {
724 smooth_terms.push(SmoothTermSpec {
725 frozen_parametric_residualization: None,
726 name: label.clone(),
727 basis: inner_basis,
728 shape,
729 joint_null_rotation: None,
730 });
731 }
732 }
733 ParsedTerm::LinkWiggle { .. }
734 | ParsedTerm::TimeWiggle { .. }
735 | ParsedTerm::LinkConfig { .. }
736 | ParsedTerm::SurvivalConfig { .. } => {
737 // Consumed at formula level, not design terms.
738 }
739 ParsedTerm::SlopeSurface { .. } => {
740 return Err(TermBuilderError::malformed_formula(
741 "slope(...) declarations must be resolved by the marginal-slope formula path before building a term spec",
742 ));
743 }
744 ParsedTerm::Interaction {
745 vars,
746 double_penalty,
747 } => {
748 // A linear `:` interaction realizes one design column equal to
749 // the elementwise product of its operands. Numeric (continuous/
750 // binary) operands multiply directly; a categorical operand is
751 // a factor, so the product is expanded factor-aware: one design
752 // column per surviving cell of the factor(s), each an indicator
753 // `1[factor == level]` gating the numeric product.
754 //
755 // Coding is MARGINALITY-AWARE (gam#1158, gam#1159). A categorical
756 // operand `g` is treatment-coded (its lexicographically first
757 // reference level dropped) ONLY when the lower-order term obtained
758 // by removing `g` from this interaction is also present in the
759 // model — that lower-order term is what makes the dropped level
760 // identifiable, exactly mgcv's marginality rule. When that parent
761 // is ABSENT (the interaction-only form), dropping the reference
762 // level instead pins a group to the reference fit (a rank-deficient
763 // design), so we keep ALL levels (full dummy coding) and rely on a
764 // single intercept cell-drop below for identifiability:
765 // * `y ~ x:g` with no `x` main effect → "common intercept,
766 // separate slopes": every group keeps its own x-slope.
767 // * `y ~ g:h` with no `g`/`h` main effects → the saturated
768 // cell-means model: full cross of all levels minus one
769 // reference cell absorbed by the intercept.
770 // When the parents ARE present (`x + x:g`, or `g*h` = `g + h +
771 // g:h`), the historical treatment coding is preserved so those
772 // forms stay correct.
773 //
774 // A main effect for var V is a `Linear`/`BoundedLinear`/
775 // `RandomEffect` ParsedTerm whose referenced name is V (an
776 // auto-detected categorical `Linear` becomes a RandomEffect main
777 // effect; either spelling counts). We only treat such standalone
778 // main-effect terms as parents — not V appearing inside another
779 // interaction.
780 let main_effect_present = |target: &str| -> bool {
781 terms.iter().any(|other| match other {
782 ParsedTerm::Linear { name, .. }
783 | ParsedTerm::BoundedLinear { name, .. }
784 | ParsedTerm::RandomEffect { name, .. } => name == target,
785 _ => false,
786 })
787 };
788 // The lower-order parent of dropping operand `drop_var` from this
789 // interaction is present iff EVERY other operand is a main effect.
790 // For the two cases we care about (`x:g`, `g:h`) the interaction
791 // has two operands, so this reduces to "is the single remaining
792 // operand a main effect"; the general form handles any arity.
793 let parent_present = |drop_var: &str| -> bool {
794 vars.iter()
795 .filter(|v| v.as_str() != drop_var)
796 .all(|v| main_effect_present(v))
797 };
798
799 let mut numeric_cols = Vec::<usize>::new();
800 // Per categorical operand: (var name, col, kept levels, was the
801 // reference level dropped / treatment-coded?).
802 let mut categorical_factors =
803 Vec::<(String, usize, Vec<(u64, String)>, bool)>::new();
804 for var in vars {
805 let col = resolve_col(col_map, var)?;
806 let kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
807 TermBuilderError::missing_column(format!(
808 "internal column-kind lookup failed for '{var}'"
809 ))
810 .to_string()
811 })?;
812 match kind {
813 ColumnKindTag::Continuous | ColumnKindTag::Binary => numeric_cols.push(col),
814 ColumnKindTag::Categorical => {
815 let mut levels = encoded_levels_for_column(ds, ColIdx::new(col));
816 // Treatment-code (drop the reference level) only when
817 // the marginal parent that identifies it is present;
818 // otherwise keep every level (full dummy coding).
819 let treatment_coded = parent_present(var);
820 if treatment_coded && levels.len() > 1 {
821 levels.remove(0);
822 }
823 if levels.is_empty() {
824 return Err(TermBuilderError::incompatible_config(format!(
825 "interaction `{}` references categorical column `{var}` with no usable levels",
826 vars.join(":")
827 )));
828 }
829 categorical_factors.push((var.clone(), col, levels, treatment_coded));
830 }
831 }
832 }
833
834 let label = vars.join(":");
835
836 if categorical_factors.is_empty() {
837 // Pure numeric `:` interaction — single product column,
838 // identical to the historical behaviour.
839 linear_terms.push(LinearTermSpec {
840 name: label,
841 feature_col: numeric_cols[0],
842 feature_cols: numeric_cols,
843 categorical_levels: vec![],
844 // Interactions are recoverable as zero by default.
845 double_penalty: *double_penalty,
846 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
847 coefficient_min: None,
848 coefficient_max: None,
849 frozen_function_mass: None,
850 });
851 inference_notes.push(format!(
852 "wired linear interaction `{}` as product of numeric columns",
853 vars.join(":")
854 ));
855 } else {
856 // Factor-aware expansion: cartesian product over the kept
857 // levels of every categorical operand. Each cell yields one
858 // column gating the numeric product (or, with no numeric
859 // operand, a pure cell indicator).
860 let mut cells: Vec<Vec<(usize, u64, String)>> = vec![Vec::new()];
861 for (_var, col, levels, _treatment_coded) in &categorical_factors {
862 let mut next = Vec::with_capacity(cells.len() * levels.len());
863 for cell in &cells {
864 for (bits, level_label) in levels {
865 let mut extended = cell.clone();
866 extended.push((*col, *bits, level_label.clone()));
867 next.push(extended);
868 }
869 }
870 cells = next;
871 }
872
873 // Intercept-identifiability cell drop. When the cells are PURE
874 // INDICATORS (no numeric operand) and at least one factor was
875 // dummy-coded (kept all its levels), the full set of cell
876 // columns sums to the all-ones intercept and is rank-deficient
877 // against it. Drop exactly ONE reference cell — the cell where
878 // every factor sits at its reference (lexicographically first)
879 // level — so the remaining saturated cells are identifiable
880 // (rank n_g*n_h - 1 cells + intercept). With a numeric operand
881 // the cells gate `x` and sum to `x`, not the intercept, so no
882 // cell is dropped (the collinearity there is with the absent
883 // `x` main effect, which is exactly why full coding is right).
884 let any_dummy_coded = categorical_factors
885 .iter()
886 .any(|(_, _, _, treatment_coded)| !*treatment_coded);
887 if numeric_cols.is_empty() && any_dummy_coded {
888 // The reference cell pairs each factor's column with the
889 // bits of its lexicographically-first (index 0) level.
890 let reference_cell: Vec<(usize, u64)> = categorical_factors
891 .iter()
892 .map(|(_, col, _, _)| {
893 let levels = encoded_levels_for_column(ds, ColIdx::new(*col));
894 (*col, levels[0].0)
895 })
896 .collect();
897 cells.retain(|cell| {
898 !reference_cell.iter().all(|(rcol, rbits)| {
899 cell.iter()
900 .any(|(col, bits, _)| col == rcol && bits == rbits)
901 })
902 });
903 }
904
905 let n_cells = cells.len();
906 for cell in cells {
907 let cell_suffix = cell
908 .iter()
909 .map(|(_, _, level_label)| level_label.as_str())
910 .collect::<Vec<_>>()
911 .join(":");
912 let categorical_levels =
913 cell.iter().map(|(col, bits, _)| (*col, *bits)).collect();
914 // `feature_col` is required to point at a real column;
915 // use the first numeric operand when present, otherwise
916 // the first categorical column (its raw value is never
917 // multiplied — `realized_design_column` starts from ones
918 // and only gates by the level indicators).
919 let feature_col = numeric_cols
920 .first()
921 .copied()
922 .unwrap_or(categorical_factors[0].1);
923 linear_terms.push(LinearTermSpec {
924 name: format!("{label}:{cell_suffix}"),
925 feature_col,
926 feature_cols: numeric_cols.clone(),
927 categorical_levels,
928 double_penalty: *double_penalty,
929 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
930 coefficient_min: None,
931 coefficient_max: None,
932 frozen_function_mass: None,
933 });
934 }
935 let all_treatment_coded = !any_dummy_coded;
936 let coding = if all_treatment_coded {
937 "treatment-coded"
938 } else {
939 "marginality-aware (full dummy / saturated)"
940 };
941 inference_notes.push(format!(
942 "wired factor-aware linear interaction `{}` as {} {} cell column(s)",
943 vars.join(":"),
944 n_cells,
945 coding
946 ));
947 }
948 }
949 }
950 }
951
952 let spec = TermCollectionSpec {
953 linear_terms,
954 random_effect_terms: random_terms,
955 smooth_terms,
956 };
957 // Structural advisories — several 1-D spatial smooths where one surface was
958 // meant, a feature carried by both a smooth and a linear term, nested
959 // smooths under hierarchical ownership — change what a term MEANS, so they
960 // are inference notes: recorded here, once, for every front end and every
961 // model class that lowers a formula (the CLI prints them, Python raises
962 // them as `GamInferenceWarning`s, and the saved model carries them).
963 inference_notes.extend(crate::smooth::collect_smooth_structure_warnings(
964 &spec,
965 &ds.headers,
966 "model",
967 ));
968 Ok(spec)
969}
970
971fn split_list_option(raw: &str) -> Vec<String> {
972 let t = raw.trim();
973 // Accept the Python/JSON list form `[a, b]` AND mgcv's R-vector forms
974 // `c(a, b)` / `(a, b)` as bracketed wrappers around a comma-separated body.
975 // mgcv-style formulas pass per-margin numeric options as `k=c(5,5)` /
976 // `period=c(2*pi, pi)`; without R-vector peeling here those entries were
977 // split into `["c(5", "5)"]` and the downstream numeric parser then
978 // misreported the leading garbage as the invalid digit.
979 let inner = t
980 .strip_prefix('[')
981 .and_then(|u| u.strip_suffix(']'))
982 .or_else(|| {
983 t.strip_prefix("c(")
984 .or_else(|| t.strip_prefix("C("))
985 .or_else(|| t.strip_prefix('('))
986 .and_then(|u| u.strip_suffix(')'))
987 })
988 .unwrap_or(t);
989 inner
990 .split(',')
991 .map(|v| v.trim().to_string())
992 .filter(|v| !v.is_empty())
993 .collect()
994}
995
996fn parse_numeric_expr(raw: &str) -> Result<f64, String> {
997 let mut acc = 1.0f64;
998 let normalized = raw.replace(' ', "");
999 if normalized.eq_ignore_ascii_case("none") {
1000 return Err("None is not numeric".to_string());
1001 }
1002 for factor in normalized.split('*') {
1003 if factor.is_empty() {
1004 return Err(format!("invalid numeric expression '{raw}'"));
1005 }
1006 let value = if factor.eq_ignore_ascii_case("pi") || factor == "π" {
1007 std::f64::consts::PI
1008 } else if factor.eq_ignore_ascii_case("tau") || factor == "τ" {
1009 std::f64::consts::TAU
1010 } else if let Some(prefix) = factor
1011 .strip_suffix("pi")
1012 .or_else(|| factor.strip_suffix("π"))
1013 {
1014 let coefficient = if prefix.is_empty() {
1015 1.0
1016 } else {
1017 prefix
1018 .parse::<f64>()
1019 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1020 };
1021 coefficient * std::f64::consts::PI
1022 } else if let Some(prefix) = factor
1023 .strip_suffix("tau")
1024 .or_else(|| factor.strip_suffix("τ"))
1025 {
1026 let coefficient = if prefix.is_empty() {
1027 1.0
1028 } else {
1029 prefix
1030 .parse::<f64>()
1031 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1032 };
1033 coefficient * std::f64::consts::TAU
1034 } else {
1035 factor
1036 .parse::<f64>()
1037 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1038 };
1039 acc *= value;
1040 }
1041 Ok(acc)
1042}
1043
1044/// Read an endpoint/period option as a numeric *expression* (`2*pi`, `tau`,
1045/// `0.5*tau`, `6.283185307179586`, ...) — the same grammar that `period=` and
1046/// `origin=` already accept via [`parse_numeric_expr`].
1047///
1048/// Returns `Ok(None)` when the key is absent, `Ok(Some(v))` when it parses, and
1049/// a hard `Err` when the key is *present but unparseable*. The crucial contrast
1050/// is with the lenient [`option_f64`], which collapses an unparseable value to
1051/// `None` and lets the caller silently substitute the data range — wrapping a
1052/// cyclic smooth at the wrong period with no diagnostic (the #815 failure mode).
1053fn option_numeric_expr(
1054 options: &BTreeMap<String, String>,
1055 key: &str,
1056) -> Result<Option<f64>, String> {
1057 match options.get(key) {
1058 None => Ok(None),
1059 Some(raw) => parse_numeric_expr(raw)
1060 .map(Some)
1061 .map_err(|err| format!("option `{key}={raw}` is not a valid numeric value: {err}")),
1062 }
1063}
1064
1065fn parse_periods_option(
1066 options: &BTreeMap<String, String>,
1067 dim: usize,
1068) -> Result<Option<Vec<Option<f64>>>, String> {
1069 let Some(raw) = options.get("period") else {
1070 return Ok(None);
1071 };
1072 let values = split_list_option(raw);
1073 let mut periods = vec![None; dim];
1074 if values.len() == 1 && dim == 1 {
1075 periods[0] = Some(parse_numeric_expr(&values[0])?);
1076 } else {
1077 if values.len() != dim {
1078 return Err(format!(
1079 "period list length {} must match smooth dimension {}",
1080 values.len(),
1081 dim
1082 ));
1083 }
1084 for (i, v) in values.iter().enumerate() {
1085 if v.eq_ignore_ascii_case("none") {
1086 continue;
1087 }
1088 periods[i] = Some(parse_numeric_expr(v)?);
1089 }
1090 }
1091 Ok(Some(periods))
1092}
1093
1094fn parse_periodic_axes_option(
1095 options: &BTreeMap<String, String>,
1096 dim: usize,
1097) -> Result<Option<Vec<Option<f64>>>, String> {
1098 // `cyclic=` is whitelisted as the alias of `periodic=` on every radial arm,
1099 // so read it here too; it was previously accepted and dropped (#2781).
1100 let Some(raw_axes) = options.get("periodic").or_else(|| options.get("cyclic")) else {
1101 // No periodicity FLAG — but a declared period is itself the declaration.
1102 // A period is not a property an aperiodic basis has, so an axis that
1103 // carries one is periodic, exactly as on the 1-D B-spline and tensor
1104 // paths (`axes_with_declared_period`). Before #2781 this early return
1105 // dropped `matern(x, z, period=[2*pi, None])` on the floor: the option
1106 // was validated by the arm's whitelist and then never read.
1107 let declared = parse_periods_option(options, dim)?;
1108 return Ok(match declared {
1109 Some(periods) if periods.iter().any(Option::is_some) => Some(periods),
1110 _ => None,
1111 });
1112 };
1113 let mut periods = parse_periods_option(options, dim)?.unwrap_or_else(|| vec![None; dim]);
1114 // Scalar boolean form (`periodic=true` / `false`, `yes` / `no`) applies to
1115 // every axis — the documented per-axis-flag broadcast (see the doc on
1116 // `parse_periodic_axes`, the tensor sibling that already accepts it). A
1117 // 1-D `duchon(x, periodic=true)` lands here: the cyclic *domain* is then
1118 // resolved from the data range by `parse_cyclic_boundary` (the 1-D builder
1119 // consults `boundary` first), so a finite explicit period is NOT required —
1120 // we only need to NOT mis-read "true" as an axis index (#1074). `false`
1121 // means no axis is periodic.
1122 let lowered = raw_axes.trim().to_ascii_lowercase();
1123 if matches!(lowered.as_str(), "true" | "yes" | "y") {
1124 return Ok(Some(periods));
1125 }
1126 // `false` means NO axis is periodic. Return `None` — NOT
1127 // `Some(vec![None; dim])` — because the radial 1-D consumer treats a
1128 // `Some([None])` as "periodicity requested, derive the wrap period from
1129 // the data range" (see the Duchon builder arm below, which back-fills
1130 // `axes[0] = data_span` for a lone `None`) and the 1-D builder routes on
1131 // `spec.periodic.is_some()`. Emitting `Some([None])` here therefore
1132 // silently produced a *periodic* smooth for an explicit `periodic=false`
1133 // — the exact regression this branch now avoids, matching the bracketed
1134 // `[false]` form handled by the per-axis boolean block below.
1135 if matches!(lowered.as_str(), "false" | "no" | "n") {
1136 return Ok(None);
1137 }
1138 let axes = split_list_option(raw_axes);
1139 if axes.is_empty() {
1140 return Ok(Some(periods));
1141 }
1142
1143 // Boolean forms `periodic=true` / `periodic=[true, false, ...]`, mirroring
1144 // `parse_tensor_periodic_axes`. The radial 1-D builders (`duchon`/`tps`/
1145 // `matern`) intentionally DERIVE the wrap period from the closed center
1146 // lattice when none is supplied (`prepare_periodic_duchon_centers_1d_with_period`,
1147 // gam#580: `None => span`), so a boolean-selected periodic axis legitimately
1148 // omits `period`. Without this branch, `duchon(x, periodic=true)`-style
1149 // radial formulas failed with the misleading "invalid periodic axis 'true'".
1150 let is_bool = |t: &str| {
1151 matches!(
1152 t.to_ascii_lowercase().as_str(),
1153 "true" | "yes" | "y" | "false" | "no" | "n"
1154 )
1155 };
1156 let is_truthy = |t: &str| matches!(t.to_ascii_lowercase().as_str(), "true" | "yes" | "y");
1157
1158 // Scalar boolean: `periodic=true` / `periodic=false`.
1159 if axes.len() == 1 && is_bool(&axes[0]) {
1160 if !is_truthy(&axes[0]) {
1161 // Non-periodic: return None so the 1-D builder (which routes on
1162 // `spec.periodic.is_some()`) does NOT take the periodic path.
1163 return Ok(None);
1164 }
1165 // Every axis periodic; honor any explicit per-axis period, else leave
1166 // `None` for the caller (formula arm) / builder to derive the span.
1167 return Ok(Some(periods));
1168 }
1169
1170 // Per-axis boolean list: `periodic=[true, false, ...]` (length must match dim).
1171 if axes.iter().all(|a| is_bool(a)) {
1172 if axes.len() != dim {
1173 return Err(format!(
1174 "periodic flag list length {} must match smooth dimension {dim}",
1175 axes.len()
1176 ));
1177 }
1178 if !axes.iter().any(|a| is_truthy(a)) {
1179 return Ok(None);
1180 }
1181 for (i, a) in axes.iter().enumerate() {
1182 if !is_truthy(a) {
1183 periods[i] = None;
1184 }
1185 }
1186 return Ok(Some(periods));
1187 }
1188
1189 // Index-list form: `periodic=[0, 2]`. Each listed axis must carry an
1190 // explicit finite period — an index gives no per-axis span-derive hint.
1191 for a in &axes {
1192 let axis = a
1193 .parse::<usize>()
1194 .map_err(|err| format!("invalid periodic axis '{a}': {err}"))?;
1195 if axis >= dim {
1196 return Err(format!(
1197 "periodic axis {axis} out of range for {dim}D smooth"
1198 ));
1199 }
1200 if periods[axis].is_none() {
1201 return Err(format!(
1202 "periodic axis {axis} requires period[{axis}] to be finite"
1203 ));
1204 }
1205 }
1206 // Axes not listed are non-periodic even if period list has a finite placeholder.
1207 let listed: std::collections::BTreeSet<usize> = axes
1208 .iter()
1209 .filter_map(|a| a.parse::<usize>().ok())
1210 .collect();
1211 for i in 0..dim {
1212 if !listed.contains(&i) {
1213 periods[i] = None;
1214 }
1215 }
1216 Ok(Some(periods))
1217}
1218
1219// ---------------------------------------------------------------------------
1220// Smooth basis spec construction
1221// ---------------------------------------------------------------------------
1222
1223fn parse_option_list(raw: &str) -> Vec<String> {
1224 let trimmed = raw.trim();
1225 // Accept both the Python/JSON list form `[a, b]` and mgcv's R vector form
1226 // `c(a, b)` (and a bare `(a, b)`) as the bracketed wrapper around a
1227 // comma-separated option list. mgcv writes per-margin options as
1228 // `bs=c('tp','tp')` / `m=c(2,2)`, so the `c(...)` form must round-trip
1229 // through the same splitter the `[...]` form uses.
1230 let inner = trimmed
1231 .strip_prefix('[')
1232 .and_then(|v| v.strip_suffix(']'))
1233 .or_else(|| {
1234 trimmed
1235 .strip_prefix("c(")
1236 .or_else(|| trimmed.strip_prefix("C("))
1237 .or_else(|| trimmed.strip_prefix('('))
1238 .and_then(|v| v.strip_suffix(')'))
1239 })
1240 .unwrap_or(trimmed);
1241 inner
1242 .split(',')
1243 .map(|v| {
1244 v.trim()
1245 .trim_matches('"')
1246 .trim_matches('\'')
1247 .to_ascii_lowercase()
1248 })
1249 .filter(|v| !v.is_empty())
1250 .collect()
1251}
1252
1253/// Axes for which the caller has explicitly declared a period.
1254///
1255/// A period is not a property an aperiodic basis has: declaring one *is* the
1256/// periodicity declaration, and `periodic=` / `bc='periodic'` is a second,
1257/// redundant spelling of the same fact for the axes it names. Before #2781 the
1258/// axis resolvers read only that second spelling, so `s(t, period=24)`,
1259/// `s(t, period_start=0, period_end=24)` and
1260/// `te(th, h, periods=[2*pi, None])` were each validated as a legal option and
1261/// then dropped on the floor — the caller asked for a cyclic smooth, got an
1262/// aperiodic one with a discontinuity at the seam, and was never told.
1263///
1264/// Only *unambiguous* declarations are read here: a per-axis list (which
1265/// includes the scalar form on a 1-D smooth, where the list has length one).
1266/// A bare scalar on a multi-margin tensor does not say which margin it belongs
1267/// to, so it is left to [`parse_periods`], which either broadcasts it onto a
1268/// lone axis already flagged periodic or refuses the length mismatch.
1269fn axes_with_declared_period(
1270 options: &BTreeMap<String, String>,
1271 dim: usize,
1272) -> Result<Vec<bool>, String> {
1273 let mut axes = vec![false; dim];
1274 if let Some(raw) = options.get("period").or_else(|| options.get("periods")) {
1275 let values = split_list_option(raw);
1276 if values.len() == dim {
1277 for (axis, value) in values.iter().enumerate() {
1278 if !value.trim().eq_ignore_ascii_case("none") {
1279 axes[axis] = true;
1280 }
1281 }
1282 }
1283 }
1284 // The half-open endpoint spelling (`period_start=`/`period_end=`, aliases
1285 // `start=`/`end=`) declares the periodic DOMAIN of one axis, so it only has
1286 // a referent on a 1-D smooth. `parse_periodic_domain_1d` is what reads it.
1287 if dim == 1
1288 && PERIOD_ENDPOINT_OPTION_KEYS
1289 .iter()
1290 .any(|key| options.contains_key(*key))
1291 {
1292 axes[0] = true;
1293 }
1294 Ok(axes)
1295}
1296
1297/// Option keys that declare a periodic domain by its endpoints.
1298const PERIOD_ENDPOINT_OPTION_KEYS: [&str; 4] = ["period_start", "period_end", "start", "end"];
1299
1300/// Option keys that declare a period length.
1301const PERIOD_LENGTH_OPTION_KEYS: [&str; 2] = ["period", "periods"];
1302
1303/// Option keys that place the start of a periodic domain.
1304const PERIOD_ORIGIN_OPTION_KEYS: [&str; 5] = [
1305 "origin",
1306 "origins",
1307 "period_origin",
1308 "period-origin",
1309 "domain_origin",
1310];
1311
1312/// Refuse a period declaration that no axis of the smooth can consume.
1313///
1314/// After [`axes_with_declared_period`] has folded every unambiguous declaration
1315/// into the periodic-axis set, the only ways to still hold a period option that
1316/// nothing reads are (a) a bare scalar `period=` on a multi-margin tensor, which
1317/// does not name its margin, (b) an `origin=` with no period to be the origin
1318/// of, and (c) an explicit `periodic=false` that contradicts the declaration.
1319/// Each of those is refused here rather than discarded, which is the contract
1320/// `docs/formulas.md` already states for this family of options: "an unparseable
1321/// endpoint or an unknown option is rejected rather than silently dropped"
1322/// (#2781).
1323fn reject_unconsumable_period_declaration(
1324 term_name: &str,
1325 options: &BTreeMap<String, String>,
1326 periodic_axes: &[bool],
1327) -> Result<(), String> {
1328 if periodic_axes.iter().any(|periodic| *periodic) {
1329 return Ok(());
1330 }
1331 let dim = periodic_axes.len();
1332 if let Some(key) = PERIOD_LENGTH_OPTION_KEYS
1333 .iter()
1334 .find(|key| options.contains_key(**key))
1335 {
1336 let hint = if dim > 1 {
1337 format!(
1338 "a scalar `{key}=` does not say which of the {dim} margins wraps; write one entry \
1339 per margin (e.g. {key}=[<value>, None]) or name the axis with periodic=<axis>"
1340 )
1341 } else {
1342 "declare it on a periodic axis or drop it".to_string()
1343 };
1344 return Err(TermBuilderError::invalid_option(format!(
1345 "{term_name}(): `{key}=` declares a period, but no axis of this smooth is periodic — {hint}"
1346 ))
1347 .to_string());
1348 }
1349 if let Some(key) = PERIOD_ORIGIN_OPTION_KEYS
1350 .iter()
1351 .find(|key| options.contains_key(**key))
1352 {
1353 return Err(TermBuilderError::invalid_option(format!(
1354 "{term_name}(): `{key}=` places the start of a periodic domain, but this smooth \
1355 declares no period; add period=<value> or drop it"
1356 ))
1357 .to_string());
1358 }
1359 if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
1360 .iter()
1361 .find(|key| options.contains_key(**key))
1362 {
1363 return Err(TermBuilderError::invalid_option(format!(
1364 "{term_name}(): `{key}=` declares a periodic domain endpoint, but no axis of this \
1365 smooth is periodic; on a tensor smooth use periods=[...] with origins=[...], which \
1366 name their margin"
1367 ))
1368 .to_string());
1369 }
1370 Ok(())
1371}
1372
1373/// The radial (`thinplate` / `matern` / `duchon`) counterpart of
1374/// [`reject_unconsumable_period_declaration`] (#2781).
1375///
1376/// [`parse_periodic_axes_option`] returns the per-axis period vector these arms
1377/// actually consume, and an axis wraps only when that vector carries a finite
1378/// period for it — with the one exception that a ONE-dimensional radial smooth
1379/// derives its period from the closed center lattice, which tiles a full period
1380/// exactly (gam#580), unlike the sample-dependent data-range derive the B-spline
1381/// path refuses (#1771). Every other spelling used to be validated by the arm's
1382/// whitelist and then dropped: `matern(x, z, period=0.7)` and
1383/// `matern(x, z, periodic=true)` were each bit-identical to the plain aperiodic
1384/// fit, with no error and no warning.
1385///
1386/// Two refusals:
1387///
1388/// * a periodicity or period declaration that leaves no axis periodic — which
1389/// on a multi-dimensional radial smooth is exactly what `periodic=true` alone
1390/// does, since there is no per-axis span to derive from;
1391/// * `period_start=` / `period_end=` on a multi-dimensional radial smooth.
1392/// Those name ONE axis's domain and are read by `parse_cyclic_boundary`,
1393/// which these arms consult only when `d == 1`.
1394fn reject_unconsumable_radial_period_declaration(
1395 term_name: &str,
1396 options: &BTreeMap<String, String>,
1397 dim: usize,
1398 periodic: Option<&[Option<f64>]>,
1399 boundary_is_cyclic: bool,
1400) -> Result<(), String> {
1401 if dim > 1
1402 && let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
1403 .iter()
1404 .find(|key| options.contains_key(**key))
1405 {
1406 return Err(TermBuilderError::invalid_option(format!(
1407 "{term_name}(): `{key}=` names one axis's periodic domain and is only read on a \
1408 one-dimensional radial smooth; this one has {dim} covariates, so give the wrap as \
1409 period=[…] with one entry per axis"
1410 ))
1411 .to_string());
1412 }
1413 let any_axis_wraps = boundary_is_cyclic
1414 || periodic.is_some_and(|axes| {
1415 (dim == 1 && !axes.is_empty()) || axes.iter().any(Option::is_some)
1416 });
1417 if any_axis_wraps {
1418 return Ok(());
1419 }
1420 let declared = ["periodic", "cyclic"]
1421 .iter()
1422 .chain(PERIOD_LENGTH_OPTION_KEYS.iter())
1423 .chain(PERIOD_ENDPOINT_OPTION_KEYS.iter())
1424 .find(|key| options.contains_key(**key));
1425 let Some(key) = declared else {
1426 return Ok(());
1427 };
1428 // `periodic=false` is a denial, not a declaration: it legitimately leaves
1429 // every axis open.
1430 if matches!(*key, "periodic" | "cyclic")
1431 && options
1432 .get(*key)
1433 .map(|raw| raw.trim().to_ascii_lowercase())
1434 .is_some_and(|raw| matches!(raw.as_str(), "false" | "no" | "n"))
1435 {
1436 return Ok(());
1437 }
1438 Err(TermBuilderError::invalid_option(format!(
1439 "{term_name}(): `{key}=` declares periodicity, but no axis of this smooth ends up \
1440 periodic. A radial smooth derives its wrap from the center lattice only in one \
1441 dimension (this one has {dim}), so name the period per axis: \
1442 period=[<value>, None, …]"
1443 ))
1444 .to_string())
1445}
1446
1447fn parse_periodic_axes(
1448 options: &BTreeMap<String, String>,
1449 dim: usize,
1450) -> Result<Vec<bool>, String> {
1451 let mut axes = vec![false; dim];
1452 // `periodic=false` is an explicit denial, not merely the absence of a
1453 // declaration: it suppresses the `boundary=` spelling below, and it
1454 // CONTRADICTS a period declaration rather than silently outranking it.
1455 let mut explicitly_aperiodic = false;
1456 if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1457 let lowered = raw.trim().to_ascii_lowercase();
1458 if matches!(lowered.as_str(), "true" | "yes" | "y") {
1459 axes.fill(true);
1460 } else if matches!(lowered.as_str(), "false" | "no" | "n") {
1461 explicitly_aperiodic = true;
1462 } else {
1463 for axis_raw in parse_option_list(raw) {
1464 let axis = axis_raw
1465 .parse::<usize>()
1466 .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1467 if axis >= dim {
1468 return Err(format!(
1469 "periodic axis {axis} out of range for {dim}D smooth"
1470 ));
1471 }
1472 axes[axis] = true;
1473 }
1474 }
1475 }
1476 if !explicitly_aperiodic
1477 && let Some(raw) = options.get("boundary").or_else(|| options.get("bc"))
1478 {
1479 let boundary = parse_option_list(raw);
1480 if boundary.len() == dim {
1481 for (axis, value) in boundary.iter().enumerate() {
1482 if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1483 axes[axis] = true;
1484 }
1485 }
1486 } else if dim == 1
1487 && matches!(
1488 boundary.first().map(String::as_str),
1489 Some("periodic" | "cyclic" | "cc")
1490 )
1491 {
1492 axes[0] = true;
1493 }
1494 }
1495 fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
1496 Ok(axes)
1497}
1498
1499/// Fold every unambiguous period declaration into `axes` (#2781), refusing a
1500/// declaration that an explicit `periodic=false` contradicts.
1501///
1502/// Shared by the 1-D and tensor axis resolvers so one rule — "a declared period
1503/// makes its axis periodic" — holds on both paths.
1504fn fold_in_declared_periods(
1505 options: &BTreeMap<String, String>,
1506 dim: usize,
1507 axes: &mut [bool],
1508 explicitly_aperiodic: bool,
1509) -> Result<(), String> {
1510 let declared = axes_with_declared_period(options, dim)?;
1511 if explicitly_aperiodic && declared.iter().any(|d| *d) {
1512 return Err(TermBuilderError::incompatible_config(
1513 "periodic=false denies the periodicity that the smooth's own period declaration \
1514 asserts; drop one of the two",
1515 )
1516 .to_string());
1517 }
1518 for (axis, declared_axis) in declared.into_iter().enumerate() {
1519 axes[axis] |= declared_axis;
1520 }
1521 Ok(())
1522}
1523
1524fn parse_optional_numeric_list(
1525 options: &BTreeMap<String, String>,
1526 keys: &[&str],
1527 dim: usize,
1528) -> Result<Vec<Option<f64>>, String> {
1529 let Some(raw) = keys.iter().find_map(|key| options.get(*key)) else {
1530 return Ok(vec![None; dim]);
1531 };
1532 let values = split_list_option(raw);
1533 let mut out = vec![None; dim];
1534 if values.len() == 1 && dim == 1 {
1535 if !values[0].eq_ignore_ascii_case("none") {
1536 out[0] = Some(parse_numeric_expr(&values[0])?);
1537 }
1538 return Ok(out);
1539 }
1540 if values.len() != dim {
1541 return Err(format!(
1542 "numeric option list length {} must match smooth dimension {}",
1543 values.len(),
1544 dim
1545 ));
1546 }
1547 for (i, value) in values.iter().enumerate() {
1548 if !value.eq_ignore_ascii_case("none") {
1549 out[i] = Some(parse_numeric_expr(value)?);
1550 }
1551 }
1552 Ok(out)
1553}
1554
1555fn parse_periods(
1556 options: &BTreeMap<String, String>,
1557 periodic_axes: &[bool],
1558) -> Result<Vec<Option<f64>>, String> {
1559 let dim = periodic_axes.len();
1560 // Broadcast a single-element `period=[v]` onto the lone periodic axis
1561 // of a multi-axis smooth (e.g. `te(th, h, bc=['periodic','natural'],
1562 // period=[2*pi])`): with only one periodic margin, the value can only
1563 // belong there.
1564 let lone_periodic_broadcast = options
1565 .get("period")
1566 .or_else(|| options.get("periods"))
1567 .and_then(|raw| {
1568 let values = split_list_option(raw);
1569 if values.len() != 1 || dim <= 1 {
1570 return None;
1571 }
1572 let mut iter = periodic_axes.iter().enumerate().filter(|(_, p)| **p);
1573 let first = iter.next()?;
1574 if iter.next().is_some() {
1575 return None;
1576 }
1577 Some((first.0, values.into_iter().next()?))
1578 });
1579 let periods = if let Some((axis, value)) = lone_periodic_broadcast {
1580 let mut out = vec![None; dim];
1581 if !value.eq_ignore_ascii_case("none") {
1582 out[axis] = Some(parse_numeric_expr(&value)?);
1583 }
1584 out
1585 } else {
1586 parse_optional_numeric_list(options, &["period", "periods"], dim)?
1587 };
1588 for (axis, (periodic, period)) in periodic_axes.iter().zip(periods.iter()).enumerate() {
1589 if *periodic
1590 && let Some(value) = period
1591 && (!value.is_finite() || *value <= 0.0)
1592 {
1593 return Err(format!(
1594 "period for periodic axis {axis} must be finite and positive, got {value}"
1595 ));
1596 }
1597 }
1598 Ok(periods)
1599}
1600
1601fn parse_period_origins(
1602 options: &BTreeMap<String, String>,
1603 periodic_axes: &[bool],
1604) -> Result<Vec<Option<f64>>, String> {
1605 parse_optional_numeric_list(
1606 options,
1607 &[
1608 "origin",
1609 "origins",
1610 "period_origin",
1611 "period-origin",
1612 "domain_origin",
1613 ],
1614 periodic_axes.len(),
1615 )
1616}
1617
1618/// Parse a per-axis periodic flag list for tensor smooths. Accepts three forms:
1619/// - `periodic=true` / `periodic=false` (scalar applied to every axis),
1620/// - `periodic=[true, false, ...]` (one flag per axis, length `dim`),
1621/// - `periodic=c(1, 1)` / `c(0, 0)` (a length-`dim` 0/1 mask, mgcv's
1622/// per-margin spelling — distinguished from an axis-index list by the
1623/// repeated 0/1 value), and
1624/// - `periodic=[0, 2, ...]` (axis indices that are periodic; others are not).
1625///
1626/// `boundary=[..., "periodic"/"cyclic"/"cc", ...]` may also flip individual
1627/// axes on; non-matching tokens leave the existing flag unchanged.
1628fn parse_tensor_periodic_axes(
1629 options: &BTreeMap<String, String>,
1630 dim: usize,
1631) -> Result<Vec<bool>, String> {
1632 let mut axes = vec![false; dim];
1633 if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1634 let lowered = raw.trim().to_ascii_lowercase();
1635 match lowered.as_str() {
1636 "true" | "yes" | "y" => {
1637 axes.fill(true);
1638 }
1639 "false" | "no" | "n" => {
1640 // Already false; allow `boundary=` below to flip axes if set.
1641 }
1642 _ => {
1643 let entries = parse_option_list(raw);
1644 let all_bool = !entries.is_empty()
1645 && entries.iter().all(|v| {
1646 matches!(
1647 v.as_str(),
1648 "true" | "yes" | "y" | "false" | "no" | "n" | "none"
1649 )
1650 });
1651 // mgcv writes per-margin flag vectors as `periodic=c(1,1)` /
1652 // `periodic=c(0,0)` — a length-`dim` mask where each entry is a
1653 // 0/1 flag for THAT margin, not an axis index. A bare axis-index
1654 // list (`periodic=[0,1]`, `periodic=[0]`) lists DISTINCT margin
1655 // indices to turn on. The two collide only when the list is all
1656 // 0/1 of length `dim`; disambiguate by the repeated-value
1657 // signature `c(1,1)`/`c(0,0)` (a valid axis-index set never
1658 // repeats an index), which is the canonical mask spelling. This
1659 // is what makes the leading tensor margin honor its periodic flag
1660 // (#1751: `periodic=c(1,1)` previously parsed `1,1` as axis
1661 // indices, marking only axis 1 and dropping axis 0).
1662 let all_zero_one =
1663 !entries.is_empty() && entries.iter().all(|v| v == "0" || v == "1");
1664 let has_repeat = {
1665 let mut seen = std::collections::BTreeSet::new();
1666 !entries.iter().all(|v| seen.insert(v.clone()))
1667 };
1668 let numeric_mask = all_zero_one && entries.len() == dim && has_repeat;
1669 if all_bool || numeric_mask {
1670 if entries.len() != dim {
1671 return Err(format!(
1672 "periodic list length {} must match smooth dimension {}",
1673 entries.len(),
1674 dim
1675 ));
1676 }
1677 for (i, v) in entries.iter().enumerate() {
1678 axes[i] = matches!(v.as_str(), "true" | "yes" | "y" | "1");
1679 }
1680 } else {
1681 for axis_raw in entries {
1682 let axis = axis_raw
1683 .parse::<usize>()
1684 .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1685 if axis >= dim {
1686 return Err(format!(
1687 "periodic axis {axis} out of range for {dim}D smooth"
1688 ));
1689 }
1690 axes[axis] = true;
1691 }
1692 }
1693 }
1694 }
1695 }
1696 if let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) {
1697 let boundary = parse_option_list(raw);
1698 // A scalar token applies to every margin; `validate_tensor_boundary_tokens`
1699 // has already refused any other length (#2782).
1700 if boundary.len() == 1 {
1701 if matches!(boundary[0].as_str(), "periodic" | "cyclic" | "cc") {
1702 axes.fill(true);
1703 }
1704 } else if boundary.len() == dim {
1705 for (axis, value) in boundary.iter().enumerate() {
1706 if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1707 axes[axis] = true;
1708 }
1709 }
1710 }
1711 }
1712 // A per-margin basis vector (`bs=c('cc','ps')` / `type=[...]`) declares each
1713 // margin's basis family, and a cyclic family (`cc`/`cp`/`cyclic`) makes THAT
1714 // margin periodic — exactly as the 1-D `s(x, bs='cc')` smooth wraps its lone
1715 // axis. Without this, the per-margin `cc` token was validated but discarded:
1716 // every `bs=c(...)` spelling collapsed to the same open B-spline tensor
1717 // (#1752). Only honor the vector form here; a scalar `bs='cc'` on a tensor is
1718 // ambiguous about which margins wrap, so it does not flip any axis on.
1719 if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
1720 && bs_selector_is_vector(raw)
1721 {
1722 let per_margin = parse_option_list(raw);
1723 if per_margin.len() == dim {
1724 for (axis, margin_bs) in per_margin.iter().enumerate() {
1725 if matches!(canonicalize_smooth_type(margin_bs), "cc" | "cp" | "cyclic") {
1726 axes[axis] = true;
1727 }
1728 }
1729 }
1730 }
1731 // A per-margin period list names its own margins, so it declares
1732 // periodicity just as `periodic=`/`bc=` do (#2781). Without this,
1733 // `te(th, h, periods=[2*pi, None])` was bit-identical to `te(th, h)`.
1734 let explicitly_aperiodic = options
1735 .get("periodic")
1736 .or_else(|| options.get("cyclic"))
1737 .is_some_and(|raw| {
1738 matches!(
1739 raw.trim().to_ascii_lowercase().as_str(),
1740 "false" | "no" | "n"
1741 )
1742 });
1743 fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
1744 Ok(axes)
1745}
1746
1747/// Validate the per-margin `boundary=`/`bc=` tokens on a tensor-product smooth.
1748///
1749/// The tensor `boundary`/`bc` list selects, per margin, whether the margin
1750/// *wraps* (a `periodic`/`cyclic`/`cc` token, consumed by
1751/// [`parse_tensor_periodic_axes`]) or is an ordinary non-periodic margin. In the
1752/// tensor DSL a *non-periodic* margin is spelled `clamped` — in the B-spline
1753/// sense of a **clamped knot vector**, i.e. the standard open spline that is
1754/// free at its two ends and does not wrap (exactly how the callers document it:
1755/// "non-periodic / clamped … free at the two ends, no wrap"). It is therefore an
1756/// inert marker here, not a zero-derivative endpoint reparameterization: a
1757/// cylinder `te(theta, z, boundary=['periodic','clamped'], …)` is a cyclic θ
1758/// margin tensor-producted with an ordinary open z margin, the direct analog of
1759/// mgcv `te(bs=c("cc","ps"))` / `te(bs=c("cc","cr"))`.
1760///
1761/// The periodic selectors and the inert non-periodic markers
1762/// (`clamped`/`open`/`natural`/`free`/`none`/empty) are accepted; anything else
1763/// (e.g. a genuine `anchored` zero-value endpoint constraint, which has no
1764/// ordinary-margin meaning in a tensor) is surfaced as a clean
1765/// unsupported-feature error rather than silently dropped. Previously `clamped`
1766/// itself was rejected, so the cylinder/torus mixed-boundary tensors — the exact
1767/// construction the manifold quality suite builds — could not be fit at all.
1768fn validate_tensor_boundary_tokens(
1769 options: &BTreeMap<String, String>,
1770 dim: usize,
1771) -> Result<(), String> {
1772 let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) else {
1773 return Ok(());
1774 };
1775 let entries = parse_option_list(raw);
1776 // A scalar token applies to every margin (the same broadcast `k=`, `bs=` and
1777 // `degree=` use); any other length names margins that do not exist. Both were
1778 // previously accepted and then dropped by the `len() == dim` guard in
1779 // `parse_tensor_periodic_axes`, so `te(x, z, bc='periodic')` silently built
1780 // an aperiodic tensor (#2782).
1781 if entries.len() != 1 && entries.len() != dim {
1782 return Err(TermBuilderError::invalid_option(format!(
1783 "tensor smooth bc/boundary={raw:?} has {} entries but the smooth has {dim} margins; \
1784 pass one token per margin or a single token for all of them",
1785 entries.len()
1786 ))
1787 .to_string());
1788 }
1789 for (axis, value) in entries.iter().enumerate() {
1790 let inert = matches!(
1791 value.trim().to_ascii_lowercase().as_str(),
1792 "clamped" | "open" | "natural" | "free" | "none" | "" | "periodic" | "cyclic" | "cc"
1793 );
1794 if !inert {
1795 return Err(TermBuilderError::unsupported_feature(format!(
1796 "tensor smooth margin {axis} boundary token '{value}' is not supported \
1797 (got bc/boundary={raw:?} on a {dim}-D tensor); tensor margins accept the periodic \
1798 selectors (periodic/cyclic/cc) or the non-periodic markers (clamped/open/natural/free). \
1799 Apply anchored/zero-value endpoint constraints with a 1-D s(x, bc=...) term instead."
1800 ))
1801 .to_string());
1802 }
1803 }
1804 Ok(())
1805}
1806
1807fn tensor_k_axis_option_axis(
1808 key: &str,
1809 cols: &[usize],
1810 ds: &Dataset,
1811) -> Result<Option<usize>, String> {
1812 let Some(suffix) = key.strip_prefix("k_") else {
1813 return Ok(None);
1814 };
1815 if suffix.is_empty() {
1816 return Err("tensor k axis option must be named k_<axis> or k_<variable>".to_string());
1817 }
1818 if let Ok(axis) = suffix.parse::<usize>() {
1819 return if axis < cols.len() {
1820 Ok(Some(axis))
1821 } else {
1822 Err(format!(
1823 "tensor k axis option `{key}` references axis {axis}, but the smooth has {} margins",
1824 cols.len()
1825 ))
1826 };
1827 }
1828
1829 let mut matches = cols
1830 .iter()
1831 .enumerate()
1832 .filter(|(_, col)| ds.headers.get(**col).is_some_and(|name| name == suffix))
1833 .map(|(axis, _)| axis);
1834 let first = matches.next();
1835 if matches.next().is_some() {
1836 return Err(format!(
1837 "tensor k axis option `{key}` matches more than one margin named `{suffix}`"
1838 ));
1839 }
1840 first.map(Some).ok_or_else(|| {
1841 let margin_names = cols
1842 .iter()
1843 .enumerate()
1844 .map(|(axis, col)| {
1845 let name = ds
1846 .headers
1847 .get(*col)
1848 .map(String::as_str)
1849 .unwrap_or("<unnamed>");
1850 format!("{axis}:{name}")
1851 })
1852 .collect::<Vec<_>>()
1853 .join(", ");
1854 format!(
1855 "tensor k axis option `{key}` does not match a margin index or name; tensor margins are [{margin_names}]"
1856 )
1857 })
1858}
1859
1860fn is_tensor_k_axis_option_key(key: &str) -> bool {
1861 key.strip_prefix("k_")
1862 .is_some_and(|suffix| !suffix.is_empty())
1863}
1864
1865/// Parse a per-margin basis dimension list (`k=<scalar>`, `k=[k0, k1, ...]`,
1866/// or axis aliases like `k_x=...` / `k_0=...`). A scalar is broadcast across
1867/// all axes; `None` returns the heuristic from the data column.
1868fn parse_tensor_k_list(
1869 options: &BTreeMap<String, String>,
1870 cols: &[usize],
1871 ds: &Dataset,
1872) -> Result<(Vec<usize>, bool), String> {
1873 let mut axis_values = vec![None; cols.len()];
1874 let mut saw_axis_alias = false;
1875 for (key, value) in options {
1876 let Some(axis) = tensor_k_axis_option_axis(key, cols, ds)? else {
1877 continue;
1878 };
1879 saw_axis_alias = true;
1880 if axis_values[axis].is_some() {
1881 return Err(format!("tensor k axis {axis} is specified more than once"));
1882 }
1883 let k: usize = value
1884 .parse()
1885 .map_err(|err| format!("invalid tensor k option `{key}={value}`: {err}"))?;
1886 axis_values[axis] = Some(k);
1887 }
1888
1889 let raw = options
1890 .get("k")
1891 .or_else(|| options.get("basis_dim"))
1892 .or_else(|| options.get("basis-dim"))
1893 .or_else(|| options.get("basisdim"));
1894 if saw_axis_alias {
1895 if raw.is_some() {
1896 return Err(
1897 "tensor k axis aliases cannot be combined with k= or basis_dim=".to_string(),
1898 );
1899 }
1900 if let Some(missing_axis) = axis_values.iter().position(Option::is_none) {
1901 let margin_name = cols
1902 .get(missing_axis)
1903 .and_then(|col| ds.headers.get(*col))
1904 .map(String::as_str)
1905 .unwrap_or("<unnamed>");
1906 return Err(format!(
1907 "tensor k axis aliases must specify every margin; missing axis {missing_axis} ({margin_name})"
1908 ));
1909 }
1910 return Ok((
1911 axis_values
1912 .into_iter()
1913 .map(|k| k.expect("missing axis values rejected above"))
1914 .collect(),
1915 false,
1916 ));
1917 }
1918 let Some(raw) = raw else {
1919 let inferred = heuristic_tensor_margin_knots(cols, ds);
1920 return Ok((inferred, true));
1921 };
1922 let entries = split_list_option(raw);
1923 if entries.len() == 1 {
1924 let k: usize = entries[0]
1925 .parse()
1926 .map_err(|err| format!("invalid tensor k '{}': {err}", entries[0]))?;
1927 return Ok((vec![k; cols.len()], false));
1928 }
1929 if entries.len() != cols.len() {
1930 return Err(format!(
1931 "tensor k list length {} must match smooth dimension {}",
1932 entries.len(),
1933 cols.len()
1934 ));
1935 }
1936 let mut out = Vec::with_capacity(entries.len());
1937 for entry in entries {
1938 let k: usize = entry
1939 .parse()
1940 .map_err(|err| format!("invalid tensor k '{entry}': {err}"))?;
1941 out.push(k);
1942 }
1943 Ok((out, false))
1944}
1945
1946/// Parse the `identifiability=` option for tensor-product smooths. Mirrors the
1947/// vocabulary of the Matern/Duchon parsers so the formula DSL is consistent.
1948///
1949/// `kind` selects the default identifiability when no explicit
1950/// `identifiability=` option is supplied: `te(...)` ([`SmoothKind::Te`]) keeps
1951/// the full-tensor sum-to-zero default, while `ti(...)` ([`SmoothKind::Ti`])
1952/// defaults to per-margin sum-to-zero so the marginal main effects are excluded
1953/// (the mgcv tensor-interaction semantics). An explicit option always wins.
1954fn parse_tensor_identifiability(
1955 options: &BTreeMap<String, String>,
1956 kind: SmoothKind,
1957) -> Result<TensorBSplineIdentifiability, String> {
1958 let Some(raw) = options.get("identifiability").map(String::as_str) else {
1959 return Ok(match kind {
1960 SmoothKind::Ti => TensorBSplineIdentifiability::MarginalSumToZero,
1961 _ => TensorBSplineIdentifiability::default(),
1962 });
1963 };
1964 match raw.trim().to_ascii_lowercase().as_str() {
1965 "none" => Ok(TensorBSplineIdentifiability::None),
1966 "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
1967 | "sumtozero" => Ok(TensorBSplineIdentifiability::SumToZero),
1968 "marginal_sum_tozero" | "marginal-sum-to-zero" | "marginal_sumtozero"
1969 | "marginalsumtozero" | "interaction" => {
1970 Ok(TensorBSplineIdentifiability::MarginalSumToZero)
1971 }
1972 other => Err(TermBuilderError::unsupported_feature(format!(
1973 "invalid tensor identifiability '{other}'; expected one of: none, sum_tozero, marginal_sum_tozero"
1974 ))
1975 .to_string()),
1976 }
1977}
1978
1979/// Parse the `identifiability=` option for every 1-D B-spline family arm —
1980/// `s()` / `bs='ps'|'bspline'|'cr'|'cs'` and the cyclic `cc`/`cp`/`periodic`
1981/// selector.
1982///
1983/// Returns `Ok(None)` when the option is absent so each arm can keep applying
1984/// its own *structural* default: an anchored endpoint is already the model's
1985/// level gauge and therefore defaults to [`BSplineIdentifiability::None`],
1986/// while every other 1-D smooth defaults to sum-to-zero centering. An explicit
1987/// token always wins over the default, and an unrecognised one is refused
1988/// rather than silently discarded (#2783).
1989///
1990/// The vocabulary deliberately mirrors [`parse_tensor_identifiability`],
1991/// [`parse_matern_identifiability`] and [`parse_spatial_identifiability`] so a
1992/// token means the same thing on every smooth kind: `none` keeps the
1993/// unconstrained basis columns, the `sum_tozero` family centers, and `linear`
1994/// removes the constant *and* linear directions (the 1-D Greville-geometry
1995/// analogue of the Matérn `CenterLinearOrthogonal` policy).
1996///
1997/// [`BSplineIdentifiability::OrthogonalToDesignColumns`] and
1998/// [`BSplineIdentifiability::FrozenTransform`] are engine-internal: the first
1999/// needs a design-column block no formula can name, the second is minted by
2000/// design freezing at fit time. Both are refused with a message that says so,
2001/// exactly as `parse_spatial_identifiability` refuses `frozen`.
2002fn parse_bspline_identifiability(
2003 options: &BTreeMap<String, String>,
2004) -> Result<Option<BSplineIdentifiability>, String> {
2005 let Some(raw) = options.get("identifiability").map(String::as_str) else {
2006 return Ok(None);
2007 };
2008 match raw.trim().to_ascii_lowercase().as_str() {
2009 "none" => Ok(Some(BSplineIdentifiability::None)),
2010 "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
2011 | "sumtozero" => Ok(Some(BSplineIdentifiability::WeightedSumToZero {
2012 weights: None,
2013 })),
2014 "linear" | "remove_linear_trend" | "remove-linear-trend" | "removelineartrend"
2015 | "center_linear_orthogonal" | "center-linear-orthogonal" => {
2016 Ok(Some(BSplineIdentifiability::RemoveLinearTrend))
2017 }
2018 "frozen" | "frozen_transform" | "orthogonal" | "orthogonal_to_design_columns" => {
2019 Err(TermBuilderError::unsupported_feature(format!(
2020 "B-spline identifiability '{}' is internal-only (it is minted by design freezing \
2021 or needs an explicit design-column block); use one of: none, sum_tozero, linear",
2022 raw.trim()
2023 ))
2024 .to_string())
2025 }
2026 other => Err(TermBuilderError::unsupported_feature(format!(
2027 "invalid B-spline identifiability '{other}'; expected one of: none, sum_tozero, linear"
2028 ))
2029 .to_string()),
2030 }
2031}
2032
2033/// The structural facts about a 1-D B-spline arm that decide which
2034/// identifiability policies are simultaneously satisfiable with the basis the
2035/// arm is about to build.
2036#[derive(Debug, Clone, Copy, Default)]
2037struct BSplineIdentifiabilityContext {
2038 /// An endpoint is pinned to a value, so the smooth already carries its own
2039 /// level gauge and the global intercept is suppressed.
2040 has_anchor: bool,
2041 /// The basis wraps, so no aperiodic (constant + linear) chart applies.
2042 periodic: bool,
2043 /// The basis is a natural cubic regression spline indexed by value-at-knot
2044 /// (`bs="cr"`/`"cs"`), which carries no B-spline knot/degree geometry for a
2045 /// Greville-abscissae chart to be built from.
2046 natural_cubic_regression: bool,
2047}
2048
2049/// Resolve the 1-D B-spline identifiability policy from the caller's
2050/// `identifiability=` token (if any) and the structural default the arm would
2051/// otherwise apply, refusing the combinations that are not simultaneously
2052/// satisfiable.
2053///
2054/// Three refusals, each of which would otherwise be a silent mis-fit rather
2055/// than a mere style violation:
2056///
2057/// * **anchored endpoint + a centering policy.** An anchored endpoint pins the
2058/// function's absolute level and suppresses the global intercept, so the
2059/// *fitted function* — not a centered deviation — obeys the pin. Layering
2060/// sum-to-zero on top demands the same function additionally have sample mean
2061/// zero, which excludes every non-zero-mean anchored curve from the model
2062/// space before REML is even evaluated (#1867, #2297). The implicit default
2063/// already resolves this by choosing `None`; an explicit request for the
2064/// incompatible policy is refused rather than quietly overridden.
2065/// * **periodic basis + `linear`.** [`BSplineIdentifiability::RemoveLinearTrend`]
2066/// builds its transform from the Greville abscissae of an *open* knot vector
2067/// and removes the constant and linear directions. A linear trend is not a
2068/// periodic function, so it is not in the span of a cyclic basis at all: the
2069/// constraint is ill-posed there, and the transform would in any case be
2070/// derived from the wrong knot geometry.
2071/// * **`bs="cr"`/`"cs"` + `linear`.** The natural cubic regression basis is
2072/// parameterized by function values at its knots, so a Greville-based linear
2073/// removal would be applied to the wrong coordinates.
2074/// [`crate::basis::build_cubic_regression_basis_1d`] refuses this too; doing
2075/// it here turns a fit-time basis error into a formula-time configuration
2076/// error naming the option the user actually wrote.
2077fn resolve_bspline_identifiability(
2078 options: &BTreeMap<String, String>,
2079 structural_default: BSplineIdentifiability,
2080 context: BSplineIdentifiabilityContext,
2081) -> Result<BSplineIdentifiability, String> {
2082 let Some(explicit) = parse_bspline_identifiability(options)? else {
2083 return Ok(structural_default);
2084 };
2085 if context.has_anchor && !matches!(explicit, BSplineIdentifiability::None) {
2086 return Err(TermBuilderError::incompatible_config(
2087 "an anchored endpoint already fixes the smooth's level (the global intercept is \
2088 suppressed), so it cannot also carry a centering identifiability constraint; \
2089 drop the anchor or use identifiability='none'",
2090 )
2091 .to_string());
2092 }
2093 if matches!(explicit, BSplineIdentifiability::RemoveLinearTrend) {
2094 if context.periodic {
2095 return Err(TermBuilderError::incompatible_config(
2096 "identifiability='linear' removes the constant and linear directions using \
2097 open-knot Greville geometry, which a periodic basis does not span; use 'none' \
2098 or 'sum_tozero' on a periodic smooth",
2099 )
2100 .to_string());
2101 }
2102 if context.natural_cubic_regression {
2103 return Err(TermBuilderError::incompatible_config(
2104 "identifiability='linear' needs B-spline knot/degree geometry, which the natural \
2105 cubic regression basis (bs='cr'/'cs') does not carry; use 'none' or 'sum_tozero', \
2106 or switch to bs='ps'",
2107 )
2108 .to_string());
2109 }
2110 }
2111 Ok(explicit)
2112}
2113
2114fn bspline_boundary_declares_periodic_axis(options: &BTreeMap<String, String>) -> bool {
2115 options
2116 .get("boundary")
2117 .or_else(|| options.get("bc"))
2118 .map(|raw| {
2119 parse_option_list(raw)
2120 .into_iter()
2121 .any(|value| matches!(value.as_str(), "periodic" | "cyclic" | "cc"))
2122 })
2123 .unwrap_or(false)
2124}
2125
2126/// Canonical-name lookup for the `bs=`/`type=` smooth selector.
2127///
2128/// User-facing names — including mgcv-compatible spellings whose semantics
2129/// match an existing gamfit smooth exactly — collapse to the engine-internal
2130/// canonical names used by the dispatch in [`build_smooth_basis`]. Adding a
2131/// new exactly-equivalent alias is a one-line entry here; the match arms
2132/// below remain the single dispatch site.
2133///
2134/// Aliases listed here MUST be true semantic equivalents of the canonical
2135/// target, not approximations. mgcv names whose semantics differ from any
2136/// gamfit smooth (e.g. `bs="ts"` shrinkage thin-plate, `bs="ad"` adaptive)
2137/// are intentionally NOT mapped here — they should reach the unsupported-type
2138/// path so users get a real diagnostic instead of a silent semantic
2139/// substitution. mgcv's `bs="cr"`/`"cs"` (cubic regression and its shrinkage
2140/// twin) are handled directly in the [`build_smooth_basis`] dispatch — they
2141/// are not aliased here because the `cr`/`cs` distinction controls a default
2142/// (`double_penalty`) that the canonical-name layer cannot see.
2143///
2144/// Unrecognised inputs pass through unchanged so the dispatch can produce its
2145/// usual "unsupported smooth type" error, preserving the existing diagnostic
2146/// surface for genuine typos.
2147pub(crate) fn canonicalize_smooth_type(raw: &str) -> &str {
2148 match raw {
2149 // Thin-plate spline. mgcv `bs="tp"` is the default thin-plate
2150 // regression spline — exact semantic equivalent of gamfit's `"tps"`.
2151 "tp" => "tps",
2152 // Gaussian process / Matérn. mgcv `bs="gp"` defaults to a Matérn
2153 // covariance kernel with REML smoothing parameter selection, which
2154 // matches gamfit's `"matern"` exactly (same kernel-Gram identity,
2155 // same REML route).
2156 "gp" => "matern",
2157 // Constant-curvature (M_κ) geodesic-kernel smooth (#944). All aliases
2158 // collapse to one canonical type so `bs="curv"`/`bs="mkappa"` cannot
2159 // diverge from `curv(...)`.
2160 "curv" | "constant_curvature" | "mkappa" => "curvature",
2161 // Measure-jet spline: multiscale local-jet-residual energy of the
2162 // empirical measure. No mgcv equivalent (mgcv has no measure-learned
2163 // geometry smooth), so no mgcv alias is mapped.
2164 "mjs" | "measure_jet" | "web" => "measurejet",
2165 other => other,
2166 }
2167}
2168
2169/// Is `margin_bs` a per-margin basis name that the tensor builder realizes as a
2170/// penalized 1-D B-spline margin?
2171///
2172/// gam's tensor product is built from penalized B-spline marginals. mgcv's
2173/// thin-plate (`tp`/`tps`), P-spline (`ps`), B-spline (`bs`), cubic-regression
2174/// (`cr`/`cs`), and cyclic (`cc`/`cp`/`cyclic`) marginals are all penalized
2175/// splines spanning the same per-axis smoothing space, so a B-spline margin
2176/// reproduces the same tensor smoothing class. Margin kinds with fundamentally
2177/// different structure (adaptive, random-effect, sphere) are NOT accepted as
2178/// tensor margins.
2179pub(crate) fn tensor_margin_bs_is_supported(margin_bs: &str) -> bool {
2180 matches!(
2181 canonicalize_smooth_type(margin_bs),
2182 "tps" | "ps" | "bs" | "bspline" | "cr" | "cs" | "cc" | "cp" | "cyclic"
2183 )
2184}
2185
2186/// Does the smooth request a periodic/cyclic axis via its options?
2187///
2188/// Mirrors the boundary-condition reading used by the periodic-aware dispatch
2189/// branches. Factored out so the type resolver and `build_smooth_basis` agree
2190/// on a single notion of "periodic requested".
2191pub(crate) fn smooth_options_declare_periodic(options: &BTreeMap<String, String>) -> bool {
2192 options.contains_key("periodic")
2193 || options.contains_key("cyclic")
2194 || options
2195 .get("boundary")
2196 .or_else(|| options.get("bc"))
2197 .map(|boundary| {
2198 boundary.to_ascii_lowercase().contains("periodic")
2199 || boundary.to_ascii_lowercase().contains("cyclic")
2200 })
2201 .unwrap_or(false)
2202}
2203
2204/// Resolve the canonical engine-internal smooth-type name for a term.
2205///
2206/// Reads the user-facing `type=`/`bs=` selector and collapses mgcv-compatible
2207/// aliases (`tp`→`tps`, `gp`→`matern`) via [`canonicalize_smooth_type`], or
2208/// derives the default from the smooth kind/arity when no selector is given.
2209/// This is the single source of truth for the dispatch in
2210/// [`build_smooth_basis`]; other call sites (e.g. predictor-specific basis
2211/// policy) use it so the classification never drifts from the dispatch.
2212/// Is the raw `bs=`/`type=` selector a vector literal (`c('tp','tp')`,
2213/// `['tp','tp']`, `(tp, tp)`) rather than a scalar smooth-type name?
2214///
2215/// mgcv's tensor smooths take a *per-margin* basis vector
2216/// (`te(x1, x2, bs=c('tp','tp'))`). Such a value is not a scalar canonical
2217/// type and must not be fed through [`canonicalize_smooth_type`] — it has to be
2218/// recognized as a tensor request and split into per-margin types. A scalar
2219/// selector (`bs="tp"`) is left untouched.
2220pub(crate) fn bs_selector_is_vector(raw: &str) -> bool {
2221 let trimmed = raw.trim();
2222 let bracketed = (trimmed.starts_with('[') && trimmed.ends_with(']'))
2223 || (trimmed.starts_with("c(") || trimmed.starts_with("C(")) && trimmed.ends_with(')')
2224 || (trimmed.starts_with('(') && trimmed.ends_with(')'));
2225 bracketed && !parse_option_list(trimmed).is_empty()
2226}
2227
2228pub fn resolve_smooth_type_name(
2229 kind: SmoothKind,
2230 n_cols: usize,
2231 options: &BTreeMap<String, String>,
2232) -> String {
2233 let selector = options.get("type").or_else(|| options.get("bs"));
2234 // A per-margin basis vector is a tensor request, never a scalar type. Route
2235 // it to the tensor builder, which reads the per-margin types out of the
2236 // same `bs=` option. (A vector on a non-tensor smooth is ill-formed and
2237 // falls through to the scalar path below so the existing diagnostic fires.)
2238 if let Some(raw) = selector
2239 && bs_selector_is_vector(raw)
2240 && matches!(kind, SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2)
2241 {
2242 return "tensor".to_string();
2243 }
2244 selector
2245 .map(|s| canonicalize_smooth_type(&s.to_ascii_lowercase()).to_string())
2246 .unwrap_or_else(|| match kind {
2247 SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2 => "tensor".to_string(),
2248 SmoothKind::S if n_cols == 1 => "bspline".to_string(),
2249 // Mixed periodic Euclidean radial kernels are not separable on the
2250 // cylinder. Use a tensor product with a cyclic margin so s(theta,h)
2251 // honors seam continuity while preserving the formula-level s(...).
2252 SmoothKind::S if smooth_options_declare_periodic(options) => "tensor".to_string(),
2253 SmoothKind::S => "tps".to_string(),
2254 })
2255}
2256
2257/// Does this canonical smooth type size its basis through the generous spatial
2258/// center heuristic ([`crate::basis::default_num_centers`])?
2259///
2260/// Only the radial spatial bases (thin-plate, Matérn/GP, Duchon) route their
2261/// default basis dimension through `plan_spatial_basis(.., Default, ..)`. The
2262/// B-spline, cyclic, tensor, and factor-smooth bases use their own modest
2263/// knot-based defaults, so they are unaffected by — and must not be perturbed
2264/// by — secondary-predictor basis-parsimony adjustments (#501).
2265pub fn smooth_type_uses_spatial_center_heuristic(canonical_type: &str) -> bool {
2266 matches!(canonical_type, "tps" | "matern" | "duchon")
2267}
2268
2269pub fn build_smooth_basis(
2270 kind: SmoothKind,
2271 vars: &[String],
2272 cols: &[usize],
2273 options: &BTreeMap<String, String>,
2274 ds: &Dataset,
2275 inference_notes: &mut Vec<String>,
2276 policy: &ResourcePolicy,
2277 smooth_coordinate_count: usize,
2278) -> Result<SmoothBasisSpec, String> {
2279 // Strip the internal by-level sizing carrier before any per-kind option
2280 // allow-list runs (the `__by_col` pattern): `sizing_rows` feeds every
2281 // n-scaling BASIS DEFAULT below; explicit user counts are untouched.
2282 let stripped_sizing_options;
2283 let (options, sizing_rows) = match options.get(DEFAULT_SIZING_ROWS_OPTION) {
2284 Some(raw) => {
2285 let rows = raw.parse::<usize>().map_err(|_| {
2286 format!("internal by-level sizing rows carrier is not a count: '{raw}'")
2287 })?;
2288 let mut cleaned = options.clone();
2289 cleaned.remove(DEFAULT_SIZING_ROWS_OPTION);
2290 stripped_sizing_options = cleaned;
2291 (&stripped_sizing_options, rows)
2292 }
2293 None => (options, ds.values.nrows()),
2294 };
2295 // Fail fast on degenerate input: a smooth whose (non-categorical) coordinate
2296 // columns collapse to a SINGLE distinct point can only ever fit the response
2297 // mean — its design matrix is rank-1. For a UNIVARIATE smooth this is exactly
2298 // "the one column is constant": `smooth(x)`/`matern(x)` on constant `x` would
2299 // otherwise silently fit the mean of `y` with no visible cue (Duchon already
2300 // errors loudly via the basis layer; this makes the diagnosis explicit and
2301 // uniform). For a general MULTIVARIATE Euclidean smooth (tensor, tps,
2302 // matern, ...) a single constant coordinate is NOT degenerate — the basis
2303 // still varies along the other coordinate(s) and the penalty absorbs the
2304 // rank-deficient direction (a constant-`x2` slice of `tps(x1, x2)` is a
2305 // well-posed 1-D function of `x1`). Such a term is degenerate only when
2306 // EVERY coordinate is constant at once, i.e. the joint input is a single
2307 // point. Test the JOINT cardinality, not each column independently, so the
2308 // loud diagnosis still fires for the genuinely rank-1 case without rejecting
2309 // well-posed lower-dimensional slices.
2310 //
2311 // The SPHERE/SOS term is the exception (handled separately just below): its
2312 // spherical-harmonic / Wahba basis is intrinsically a function of BOTH
2313 // angular coordinates, so a constant latitude or longitude is not an honest
2314 // lower-D slice but an unidentifiable axis (every point on a single meridian
2315 // or parallel) — that case is rejected per-coordinate.
2316 let coord_cols: Vec<(&String, usize)> = vars
2317 .iter()
2318 .zip(cols.iter().copied())
2319 .filter(|(_, col)| !matches!(ds.column_kinds.get(*col), Some(ColumnKindTag::Categorical)))
2320 .collect();
2321 if !coord_cols.is_empty() {
2322 let views: Vec<ArrayView1<'_, f64>> = coord_cols
2323 .iter()
2324 .map(|(_, col)| ds.values.column(*col))
2325 .collect();
2326 let n_rows = views[0].len();
2327 let mut distinct_points = std::collections::HashSet::<Vec<u64>>::new();
2328 for r in 0..n_rows {
2329 let key: Vec<u64> = views
2330 .iter()
2331 .map(|v| gam_data::canonical_level_bits(v[r]))
2332 .collect();
2333 distinct_points.insert(key);
2334 if distinct_points.len() > 1 {
2335 break;
2336 }
2337 }
2338 if distinct_points.len() <= 1 {
2339 return Err(TermBuilderError::degenerate_data(if coord_cols.len() == 1 {
2340 let var = coord_cols[0].0;
2341 format!(
2342 "smooth term over '{var}' has only one unique value in the training data \
2343 — a smooth on a constant column is degenerate and would only fit the response mean. \
2344 Remove `{var}` from the smooth, drop the term, or check the data."
2345 )
2346 } else {
2347 let names = coord_cols
2348 .iter()
2349 .map(|(v, _)| v.as_str())
2350 .collect::<Vec<_>>()
2351 .join(", ");
2352 format!(
2353 "smooth term over ({names}) has only one unique joint coordinate in the training \
2354 data — every coordinate is constant, so the smooth is degenerate and would only \
2355 fit the response mean. Drop the term or check the data."
2356 )
2357 })
2358 .to_string());
2359 }
2360
2361 // Sphere/SOS exception: the S² smooth is intrinsically a function of
2362 // BOTH angular coordinates, so a single constant axis is unidentifiable
2363 // (every point on one meridian or one parallel), not an honest 1-D
2364 // slice. Reject it per-coordinate at fit-time with a coordinate-named
2365 // error. This runs ONLY during term construction (build_smooth_basis);
2366 // predict rebuilds the design from the frozen resolvedspec and never
2367 // re-enters this path, so a constant predict grid (e.g. a single query
2368 // point on a fixed meridian) is never re-validated (#frozen-mass).
2369 if matches!(
2370 resolve_smooth_type_name(kind, cols.len(), options).as_str(),
2371 "sphere" | "s2" | "sos"
2372 ) {
2373 for (axis, (var, col)) in coord_cols.iter().enumerate() {
2374 let column = ds.values.column(*col);
2375 let mut distinct = std::collections::HashSet::<u64>::new();
2376 for &value in column.iter() {
2377 distinct.insert(gam_data::canonical_level_bits(value));
2378 if distinct.len() > 1 {
2379 break;
2380 }
2381 }
2382 if distinct.len() <= 1 {
2383 // Axis 0 is latitude, axis 1 longitude (formula order
2384 // `sphere(lat, lon)`); name the collapsed slice accordingly.
2385 let slice = if axis == 0 {
2386 "a single parallel (constant latitude)"
2387 } else {
2388 "a single meridian (constant longitude)"
2389 };
2390 return Err(TermBuilderError::degenerate_data(format!(
2391 "sphere smooth has a constant '{var}' column — every point lies on \
2392 {slice}, so the 2-sphere term is degenerate and unidentifiable along \
2393 that axis. A spherical smooth needs genuine variation in BOTH latitude \
2394 and longitude; vary '{var}', drop the term, or fit a 1-D smooth on the \
2395 varying coordinate."
2396 ))
2397 .to_string());
2398 }
2399 }
2400 }
2401 }
2402 if let Some(by_name) = options.get("by").cloned() {
2403 let by_col = options
2404 .get("__by_col")
2405 .and_then(|raw| raw.parse::<usize>().ok())
2406 .or_else(|| vars.iter().position(|v| v == &by_name).map(|idx| cols[idx]))
2407 .ok_or_else(|| format!("unknown by= column '{by_name}'"))?;
2408 let mut inner_options = options.clone();
2409 inner_options.remove("by");
2410 inner_options.remove("__by_col");
2411 inner_options.remove("id");
2412 // Size the inner basis's n-scaling defaults from the smallest
2413 // by-level's rows (see `DEFAULT_SIZING_ROWS_OPTION`); numeric-by
2414 // smooths keep pooled sizing.
2415 inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
2416 let mut inner = build_smooth_basis(
2417 kind,
2418 vars,
2419 cols,
2420 &inner_options,
2421 ds,
2422 inference_notes,
2423 policy,
2424 smooth_coordinate_count,
2425 )?;
2426 // Same rule as the formula path: a continuous by-variable's constant
2427 // direction is the by-variable itself, so it stays in the penalised
2428 // block unless an explicit `identifiability=` says otherwise.
2429 if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
2430 && !options.contains_key("identifiability")
2431 {
2432 crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner);
2433 }
2434 let by_kind = match ds.column_kinds.get(by_col).copied() {
2435 Some(ColumnKindTag::Categorical) => ByVarKind::Factor {
2436 feature_col: by_col,
2437 ordered: option_bool(options, "ordered").unwrap_or(false),
2438 frozen_levels: None,
2439 },
2440 Some(ColumnKindTag::Continuous | ColumnKindTag::Binary) => ByVarKind::Numeric {
2441 feature_col: by_col,
2442 },
2443 None => {
2444 return Err(format!(
2445 "internal column-kind lookup failed for by='{by_name}'"
2446 ));
2447 }
2448 };
2449 return Ok(SmoothBasisSpec::BySmooth {
2450 smooth: Box::new(inner),
2451 by_kind,
2452 });
2453 }
2454
2455 let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
2456 let type_opt = resolve_smooth_type_name(kind, cols.len(), options);
2457
2458 if matches!(type_opt.as_str(), "fs" | "sz" | "re") {
2459 if type_opt == "re" {
2460 validate_random_effect_smooth_options(options)?;
2461 } else {
2462 validate_known_options(type_opt.as_str(), options, FACTOR_SMOOTH_OPTION_KEYS)?;
2463 }
2464 if cols.len() != 2 {
2465 return Err(format!(
2466 "{} factor-smooth currently expects exactly two variables (one numeric, one categorical)",
2467 type_opt
2468 ));
2469 }
2470 let kinds = cols
2471 .iter()
2472 .map(|&c| ds.column_kinds.get(c).copied())
2473 .collect::<Vec<_>>();
2474 let (cont_idx, group_idx) = if type_opt == "re" {
2475 // mgcv random-slope examples are often s(g, x, bs="re").
2476 match (kinds[0], kinds[1]) {
2477 (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
2478 (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
2479 _ => (1usize, 0usize),
2480 }
2481 } else {
2482 match (kinds[0], kinds[1]) {
2483 (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
2484 (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
2485 _ => {
2486 return Err(format!(
2487 "{} factor-smooth requires one categorical factor variable",
2488 type_opt
2489 ));
2490 }
2491 }
2492 };
2493 let c = cols[cont_idx];
2494 let (minv, maxv) = col_minmax(ds.values.column(c))?;
2495 let degree = if type_opt == "re" {
2496 1
2497 } else {
2498 option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE)
2499 };
2500 // For a factor smooth every group's curve is fit from THAT group's rows
2501 // alone, so the marginal's flexibility must respect the least-resolved
2502 // group, not the pooled column. The pooled heuristic can hand the marginal
2503 // a basis that saturates (or exceeds) a small group's sample — e.g. the
2504 // sleepstudy panel has 8 training days per subject, and a default cubic
2505 // basis of 8 functions interpolates each subject's 8 points, leaving no
2506 // room for the wiggliness penalty to collapse the curve toward the
2507 // per-subject line. The factor smooth then fits within-group noise and
2508 // extrapolates badly (held-out forecast worse than the population mean).
2509 //
2510 // Cap the marginal basis below the minimum per-group covariate resolution
2511 // so the penalty always retains residual degrees of freedom to shrink each
2512 // group's curvature toward its linear null space (the random-slope
2513 // estimand). This small-group cap composes with a separate upper bound at
2514 // mgcv's factor-smooth default k=10 (FACTOR_SMOOTH_DEFAULT_BASIS_DIM,
2515 // applied below), so even ample-data groups get the modest SHARED marginal
2516 // a factor smooth wants rather than the full pooled basis. The explicit
2517 // `re` random-effect form takes neither cap: it is a raw linear `[1, x]`
2518 // random effect (0 internal knots), handled in the branch above.
2519 let pooled_internal = heuristic_knots_for_column(ds.values.column(c));
2520 let default_internal = if type_opt == "re" {
2521 // `bs="re"` is a PARAMETRIC random effect, not a smooth of the
2522 // covariate: `s(x, g, bs="re")` is the mgcv random intercept+slope
2523 // `(1 + x | g)`, i.e. a per-group line `[1, x]`, penalized by an iid
2524 // ridge. A degree-1 marginal with ZERO internal knots spans exactly
2525 // that linear space (2 coefficients per group). Using the pooled
2526 // knot heuristic here instead turned the marginal into a
2527 // piecewise-linear B-spline (e.g. 6 functions/group on sleepstudy),
2528 // i.e. a *smooth* with kinks rather than a random slope — many extra
2529 // collinear-across-levels coefficients that ill-condition the joint
2530 // Newton/REML solve (minutes-long fits, and a singular block when
2531 // combined with a separate random intercept `s(g, bs="re")`). The
2532 // raw linear basis is both the correct `re` semantics and fast.
2533 0
2534 } else {
2535 let min_group_resolution =
2536 min_per_group_unique_count(ds.values.column(c), ds.values.column(cols[group_idx]));
2537 // Per-group basis dim = degree + 1 + internal. Hold it well below the
2538 // smallest group's resolution (leave at least two residual points per
2539 // group) so the smooth cannot interpolate that group and the
2540 // wiggliness penalty retains the room to collapse each curve toward
2541 // its linear null space. Never drop below `degree + 2`, which keeps
2542 // exactly the linear span plus a single curvature direction — the
2543 // minimal smoother that can still bend if the data demand it.
2544 let basis_cap = min_group_resolution.saturating_sub(2).max(degree + 2);
2545 let internal_cap = basis_cap.saturating_sub(degree + 1);
2546 let capped = pooled_internal.min(internal_cap.max(1));
2547 // A factor smooth (`fs` AND `sz`) shares ONE marginal across ALL
2548 // levels, each level's curve fit from that group's rows alone. The
2549 // pooled knot heuristic (driven by the full column's sample) hands it
2550 // a much richer basis than the shared signal needs — ~24
2551 // functions/group on the gam#903 factor-smooth-recovery fixtures — so
2552 // REML has the capacity to fit within-group noise and over-fits the
2553 // shared shape (fs: edf 58 vs mgcv's k=10/edf 39; sz: gam 0.068 vs
2554 // mgcv 0.046 truth RMSE), losing the truth-recovery head-to-head with
2555 // the mature tool. mgcv's factor-smooth default `k=10` embodies the
2556 // right convention: a modest shared marginal. Cap the marginal there
2557 // (basis ≈ degree+1+internal ≈ 10) for both flavours when the
2558 // small-group cap above is not already tighter, so REML is not handed
2559 // noise-fitting capacity it does not need. An explicit `k`/`basis_dim`
2560 // overrides this (parse_ps_internal_knots); `re` is the raw linear
2561 // effect handled above.
2562 let fs_default_internal = FACTOR_SMOOTH_DEFAULT_BASIS_DIM
2563 .saturating_sub(degree + 1)
2564 .max(1);
2565 capped.min(fs_default_internal)
2566 };
2567 let (n_knots, _, effective_degree) =
2568 parse_ps_internal_knots(options, degree, default_internal)?;
2569 // `m=` is mgcv's spelling of `penalty_order=` and is resolved as its
2570 // alias here (#2791). It used to be read further down as a boolean gate
2571 // on the `Fs` null-penalty path instead, which made every value >= 1 the
2572 // same model and dropped the key entirely on `sz`.
2573 let penalty_order = parse_penalty_order_alias(options)?
2574 .unwrap_or(if effective_degree > 1 { 2 } else { 1 })
2575 .min(effective_degree);
2576 // All factor-smooth flavours (`fs`, `sz`, `re`) place their per-level
2577 // marginal on the SAME penalized B-spline (P-spline) basis. The flavours
2578 // differ ONLY in their penalty/constraint structure (handled below) —
2579 // sz: zero-sum deviation blocks with the per-level null space left
2580 // unpenalized; fs: random-effect double penalty; re: identity ridge.
2581 //
2582 // `sz` USED to route its default-degree marginal to a NATURAL cubic
2583 // regression spline (`cr`), on the belief that mgcv's `bs="sz"` does the
2584 // same and that cr recovers smooth signals more efficiently than the
2585 // (then uncapped) B-spline margin (#1074). That introduced a consistency
2586 // failure (#1605): the `cr` basis enforces the natural boundary
2587 // conditions f''(x_1)=f''(x_k)=0 and extrapolates linearly past the end
2588 // knots, so it CANNOT represent a per-group deviation curve with non-zero
2589 // curvature at the data boundary. Phase-shifted deviation shapes
2590 // (f''(0) = -(2π)² sin(φ) ≠ 0) are then biased toward "free linear +
2591 // anchored wiggle", under-shooting the amplitude — a bias that does NOT
2592 // vanish as n→∞ (n-independent: a genuine consistency failure, not
2593 // finite-sample shrinkage). The earlier #700/#1074 sz fixtures used
2594 // d_g ∝ sin(2πx), whose f'' happens to vanish at x=0 and x=1, so they
2595 // accidentally satisfied the natural BC and never exposed the gap; the
2596 // `fs` sibling, on this very B-spline marginal, recovers the SAME
2597 // phase-shifted data to the noise floor.
2598 //
2599 // The penalized B-spline marginal makes no boundary assumption, so it
2600 // represents arbitrary deviation shapes, and — with the
2601 // FACTOR_SMOOTH_DEFAULT_BASIS_DIM cap above already removing the
2602 // noise-fitting capacity that originally motivated leaving B-splines —
2603 // it recovers the BC-satisfying #700/#1074 signals just as well. Sharing
2604 // one marginal basis across all flavours also lets the B-spline degree/
2605 // knot degradation handle low-cardinality covariates uniformly (what
2606 // `fs` already does), so the `sz`-only cr data-support cap (#1541/#1542)
2607 // — and the asymmetry where only the cr-marginal `sz` spelling hard-
2608 // failed a 3-level ordinal — is no longer needed.
2609 let marginal_knotspec = resolve_nonperiodic_bspline_knotspec(
2610 options,
2611 ds.values.column(c),
2612 (minv, maxv),
2613 effective_degree,
2614 n_knots,
2615 )?;
2616 let marginal = BSplineBasisSpec {
2617 degree: effective_degree,
2618 penalty_order,
2619 knotspec: marginal_knotspec,
2620 // mgcv's `bs="fs"` is a random-effect-style smooth: EVERY per-level
2621 // coefficient, including the marginal null space, is penalized so
2622 // unobserved groups can be predicted — so `fs` keeps the null-space
2623 // (double) penalty. mgcv's `bs="sz"` is a pure across-level
2624 // *deviation* smooth that, under the default `select=FALSE`, leaves
2625 // the per-level null space UNPENALIZED; carrying the double penalty
2626 // there shrinks the genuine deviation signal and over-smooths the
2627 // recovered curves relative to mgcv (gam#700). `re` carries its own
2628 // identity ridge below and ignores this flag. Honour an explicit
2629 // user `double_penalty=` either way.
2630 double_penalty: option_bool(options, "double_penalty")
2631 .unwrap_or(type_opt.as_str() != "sz"),
2632 identifiability: BSplineIdentifiability::None,
2633 boundary_conditions: Default::default(),
2634 boundary: OneDimensionalBoundary::Open,
2635 };
2636 let flavour = match type_opt.as_str() {
2637 "fs" => FactorSmoothFlavour::Fs {},
2638 "sz" => FactorSmoothFlavour::Sz,
2639 "re" => FactorSmoothFlavour::Re,
2640 // Outer `matches!` already restricts to fs/sz/re.
2641 other => {
2642 return Err(format!(
2643 "internal: factor-smooth flavour dispatch reached unexpected type `{}`",
2644 other
2645 ));
2646 }
2647 };
2648 return Ok(SmoothBasisSpec::FactorSmooth {
2649 spec: FactorSmoothSpec {
2650 continuous_cols: vec![c],
2651 group_col: cols[group_idx],
2652 marginal,
2653 flavour,
2654 group_frozen_levels: None,
2655 frozen_global_orthogonality: None,
2656 },
2657 });
2658 }
2659
2660 match type_opt.as_str() {
2661 // `periodic` is the generic spelling for a periodic (wrap-continuous)
2662 // B-spline; it names the SAME `SmoothBasisSpec::BSpline1D {
2663 // PeriodicUniform }` the mgcv-style cyclic selectors (`cc`/`cp`/`cyclic`)
2664 // build, and is already recognized as that basis kind by the JSON /
2665 // override path (`smooth_overrides`) and accepted by the formula parser.
2666 // Route it through the cyclic arm so the formula path agrees with the
2667 // rest of the codebase instead of rejecting it as an unsupported type.
2668 "cyclic" | "cc" | "cp" | "cyclic-ps" | "periodic" => {
2669 validate_known_options("cyclic", options, CYCLIC_SMOOTH_OPTION_KEYS)?;
2670 if cols.len() != 1 {
2671 return Err(format!(
2672 "periodic smooth expects one variable, got {}",
2673 cols.len()
2674 ));
2675 }
2676 let c = cols[0];
2677 let (minv, maxv) = col_minmax(ds.values.column(c))?;
2678 let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2679 let mut default_internal = heuristic_knots_for_column(ds.values.column(c));
2680 if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2681 default_internal = default_internal.min(1);
2682 }
2683 // A periodic cubic spline has no free endpoint behaviour to spend
2684 // degrees of freedom on: the wrap constraint removes the ordinary
2685 // boundary wiggle, and the cyclic second-difference penalty leaves
2686 // only the constant direction (handled by the smooth
2687 // identifiability constraint). An over-rich default would give
2688 // small binomial/continuation-ratio fits a large penalized nuisance
2689 // space whose REML/LAML optimum is driven by finite-sample Bernoulli
2690 // noise rather than the low-frequency periodic signal. Cap the
2691 // cyclic default in the mgcv `bs="cc"` spirit: a modest basis unless
2692 // the caller explicitly requests `k=...`; high-frequency periodic
2693 // structure remains available through that explicit contract. Since
2694 // gam#1680 lowered the open-spline univariate default to ≈12
2695 // functions this cap and the open-spline default coincide, so it now
2696 // acts as an explicit floor/guard that keeps the cyclic default lean
2697 // even if the open-spline heuristic is later widened.
2698 let cyclic_default_basis_cap = CYCLIC_DEFAULT_BASIS_DIM.max(degree + 1);
2699 let default_basis = (default_internal + degree + 1).min(cyclic_default_basis_cap);
2700 let num_basis = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
2701 .unwrap_or(default_basis);
2702 if num_basis < degree + 1 {
2703 return Err(format!(
2704 "periodic smooth: k={} too small for degree {}; expected k >= {}",
2705 num_basis,
2706 degree,
2707 degree + 1
2708 ));
2709 }
2710 // The cyclic arm is periodic on its single axis by construction, so
2711 // resolve the period exactly the way the `s()`/`ps` arm does: honour
2712 // `period=`/`periods=` first (with `origin=` setting the domain
2713 // start), and fall back to the `period_start`/`period_end` endpoint
2714 // form only when `period=` is absent. Previously this arm jumped
2715 // straight to `parse_periodic_domain_1d`, so a `period=<v>`
2716 // declaration was silently dropped and the smooth wrapped at the
2717 // data range (#816). All three helpers route through
2718 // `parse_numeric_expr`, so `period=2*pi` and `period_end=2*pi` parse
2719 // identically (#815).
2720 let periodic_axes = [true];
2721 let periods = parse_periods(options, &periodic_axes)?;
2722 let origins = parse_period_origins(options, &periodic_axes)?;
2723 // Distinguish a *cyclic basis selector* (`bs='cc'`/`cp'`/`cyclic`,
2724 // this whole arm) from a generic B-spline forced periodic by a
2725 // `periodic=`/`boundary=` flag (the `ps`/`bspline` arm). Only the
2726 // latter carries the sample-dependent off-by-ε seam that #1771's
2727 // guard in `parse_periodic_domain_1d` requires an explicit period
2728 // to avoid. A bare `s(x, bs='cc')` opts INTO mgcv's `bs="cc"`
2729 // semantics — the wrap IS the observed data range — exactly like
2730 // the tensor cc-margin fallback (`te(x, z, bs=c('cc','cc'))`). The
2731 // cyclic arm was left routing through the now-strict helper when
2732 // #1771 tightened it, so a bare cyclic smooth hard-errored with
2733 // "periodic B-spline smooth requires an explicit period" even
2734 // though its period is well-defined. Honor `period=`/`periods=`
2735 // first, then the half-open `period_start`/`period_end` endpoint
2736 // form, and only otherwise wrap at the observed `[min, max]` span.
2737 let has_endpoint_decl = ["period_start", "start", "period_end", "end"]
2738 .iter()
2739 .any(|key| options.contains_key(*key));
2740 let (domain_start, period) = if let Some(p) = periods[0] {
2741 (origins[0].unwrap_or(minv), p)
2742 } else if has_endpoint_decl {
2743 parse_periodic_domain_1d(options, minv, maxv)?
2744 } else {
2745 let span = maxv - minv;
2746 if !(span.is_finite() && span > 0.0) {
2747 return Err(format!(
2748 "cyclic smooth requires a positive observed data range to derive \
2749 its period, got [{minv}, {maxv}]"
2750 ));
2751 }
2752 (origins[0].unwrap_or(minv), span)
2753 };
2754 // This arm is periodic by construction, so its structural default is
2755 // the ordinary sum-to-zero centering (the cyclic penalty leaves the
2756 // constant direction unpenalized). An explicit `identifiability=`
2757 // token overrides it; before #2783 the option was whitelisted here
2758 // and then hardcoded away, so `cyclic(x, identifiability='none')`
2759 // was bit-identical to the centered default.
2760 let identifiability = resolve_bspline_identifiability(
2761 options,
2762 BSplineIdentifiability::default(),
2763 BSplineIdentifiabilityContext {
2764 periodic: true,
2765 ..Default::default()
2766 },
2767 )?;
2768 Ok(SmoothBasisSpec::BSpline1D {
2769 feature_col: c,
2770 spec: BSplineBasisSpec {
2771 degree,
2772 penalty_order: option_usize(options, "penalty_order")
2773 .unwrap_or(DEFAULT_PENALTY_ORDER),
2774 knotspec: BSplineKnotSpec::PeriodicUniform {
2775 data_range: (domain_start, domain_start + period),
2776 num_basis,
2777 },
2778 double_penalty: smooth_double_penalty,
2779 identifiability,
2780 boundary_conditions: Default::default(),
2781 boundary: OneDimensionalBoundary::Cyclic {
2782 start: domain_start,
2783 end: domain_start + period,
2784 },
2785 },
2786 })
2787 }
2788 "bspline" | "ps" | "p-spline" | "cr" | "cs" => {
2789 // mgcv's `bs="cr"` (cubic regression spline) and `bs="cs"` (its
2790 // shrinkage twin) are penalized cubic-regression smooths that span
2791 // the same per-axis function space as gamfit's `bspline` (cubic
2792 // B-spline, second-derivative penalty). Route both through the
2793 // 1-D B-spline arm. Both recover unsupported null-space effects by
2794 // default; `double_penalty=false` is the explicit unpenalized
2795 // opt-out. Without this route, a stand-alone
2796 // `s(x, bs='cr')` (which is otherwise a routine 1-D smooth in
2797 // mgcv-compatible formulae) reached the dispatch's default arm
2798 // and aborted the whole fit with `unsupported smooth type 'cr'`,
2799 // even though the same name was already recognized as a tensor
2800 // margin (`tensor_margin_bs_is_supported`).
2801 let validation_name = match type_opt.as_str() {
2802 "cr" => "cr",
2803 "cs" => "cs",
2804 _ => "bspline",
2805 };
2806 validate_known_options(validation_name, options, BSPLINE_SMOOTH_OPTION_KEYS)?;
2807 if cols.len() != 1 {
2808 return Err(TermBuilderError::incompatible_config(format!(
2809 "bspline smooth expects one variable, got {}",
2810 cols.len()
2811 ))
2812 .to_string());
2813 }
2814 let c = cols[0];
2815 let (minv, maxv) = col_minmax(ds.values.column(c))?;
2816 let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2817 let default_internal = heuristic_knots_for_column(ds.values.column(c));
2818 let (mut n_knots, inferred, effective_degree) =
2819 parse_ps_internal_knots(options, degree, default_internal)?;
2820 let periodic_axes = parse_periodic_axes(options, 1).map_err(|e| e.to_string())?;
2821 // Every period/origin declaration this arm accepts is read only
2822 // inside the `periodic_axes[0]` branch below, so one that leaves the
2823 // axis aperiodic would be silently discarded (#2781).
2824 reject_unconsumable_period_declaration(validation_name, options, &periodic_axes)?;
2825 // Periodic margins still need enough basis functions to wrap, so
2826 // surface the per-axis degree reduction as a config error when the
2827 // user explicitly asked for a periodic-but-too-small basis. The
2828 // non-periodic path silently degrades degree to match mgcv.
2829 if periodic_axes[0] && effective_degree != degree {
2830 return Err(TermBuilderError::invalid_option(format!(
2831 "periodic smooth: k={} too small for degree {}; expected k >= {}",
2832 effective_degree + 1,
2833 degree,
2834 degree + 1
2835 ))
2836 .to_string());
2837 }
2838 let heuristic_knots = n_knots;
2839 if inferred && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2840 n_knots = n_knots.min(1);
2841 }
2842 if inferred {
2843 let unique = unique_count_column(ds.values.column(c));
2844 // State the rule the engine actually applied
2845 // (`heuristic_knots_for_column`: `clamp(unique/4, 4..8)`), and
2846 // the small-data reduction when it fired. The note used to
2847 // announce a `max(20, cbrt(unique))` ceiling that no code path
2848 // computed, so for every column with 36 or more unique values
2849 // it printed a rule whose own arithmetic disagreed with the
2850 // count beside it.
2851 let mut note = format!(
2852 "Automatically set {} internal knots for smooth '{}' from {} unique values (rule: clamp(unique/4, 4..{}) = {}; basis dimension = internal knots + degree + 1).",
2853 n_knots,
2854 vars.join(","),
2855 unique,
2856 MAX_DEFAULT_INTERNAL_KNOTS,
2857 heuristic_knots,
2858 );
2859 if n_knots != heuristic_knots {
2860 note.push_str(&format!(
2861 " Reduced to {} because the fit has only {} rows and {} smooth coordinates.",
2862 n_knots,
2863 ds.values.nrows(),
2864 smooth_coordinate_count,
2865 ));
2866 }
2867 note.push_str(" Override with knots=... or k=....");
2868 inference_notes.push(note);
2869 }
2870 let boundary_conditions =
2871 if periodic_axes[0] && bspline_boundary_declares_periodic_axis(options) {
2872 BSplineBoundaryConditions::default()
2873 } else {
2874 parse_bspline_boundary_conditions(options).map_err(|e| e.to_string())?
2875 };
2876 // An anchored endpoint (one *or* both sides) is already the model's
2877 // level-setting gauge: term-design construction suppresses the
2878 // global intercept so the fitted function itself, rather than only a
2879 // centered deviation, obeys the endpoint pin. Applying the ordinary
2880 // sum-to-zero chart as well would force the entire anchored function
2881 // to have sample mean zero. In #1867 that made a positive one-sided
2882 // anchored bump mathematically unrecoverable before REML was even
2883 // evaluated; for a two-sided anchor it additionally strips the
2884 // interior level the two pins bracket (#2297).
2885 let structural_identifiability = if boundary_conditions.has_anchor() {
2886 BSplineIdentifiability::None
2887 } else {
2888 BSplineIdentifiability::default()
2889 };
2890 // An explicit `identifiability=` token overrides that structural
2891 // default, and an unrecognised one is refused. Before #2783 the
2892 // option was whitelisted by `validate_known_options` above and then
2893 // never read, so every value — including nonsense — was accepted
2894 // and inert on this arm alone.
2895 let identifiability = resolve_bspline_identifiability(
2896 options,
2897 structural_identifiability,
2898 BSplineIdentifiabilityContext {
2899 has_anchor: boundary_conditions.has_anchor(),
2900 periodic: periodic_axes[0],
2901 natural_cubic_regression: !periodic_axes[0]
2902 && (type_opt == "cr" || type_opt == "cs"),
2903 },
2904 )?;
2905 let periods = parse_periods(options, &periodic_axes).map_err(|e| e.to_string())?;
2906 let origins =
2907 parse_period_origins(options, &periodic_axes).map_err(|e| e.to_string())?;
2908 let (knotspec, boundary) = if periodic_axes[0] {
2909 if !boundary_conditions.is_free() {
2910 return Err(TermBuilderError::incompatible_config(
2911 "periodic B-splines cannot also declare endpoint boundary conditions",
2912 )
2913 .to_string());
2914 }
2915 {
2916 let (domain_start, p_value) = if let Some(period) = periods[0] {
2917 (origins[0].unwrap_or(minv), period)
2918 } else {
2919 parse_periodic_domain_1d(options, minv, maxv).map_err(|e| e.to_string())?
2920 };
2921 let domain_end = domain_start + p_value;
2922 (
2923 BSplineKnotSpec::PeriodicUniform {
2924 data_range: (domain_start, domain_end),
2925 num_basis: n_knots + effective_degree + 1,
2926 },
2927 OneDimensionalBoundary::Cyclic {
2928 start: domain_start,
2929 end: domain_end,
2930 },
2931 )
2932 }
2933 } else if type_opt == "cr" || type_opt == "cs" {
2934 // mgcv `bs="cr"`/`"cs"`: a natural cubic regression spline whose
2935 // basis is indexed by `k` values at quantile-placed knots (#1074),
2936 // NOT a B-spline knot vector. Match gam's `k=` convention by
2937 // requesting the same total basis size the B-spline arm would
2938 // produce (`n_knots` internal + degree + 1), floored at the cr
2939 // minimum of 3 knots. `cr` vs `cs` (shrinkage) is carried by the
2940 // `double_penalty` flag resolved below, which the cr builder reads.
2941 //
2942 // Cap that request to the covariate's data support (#1541): a cr
2943 // basis cannot place more value-knots than there are distinct
2944 // covariate values, so an unclamped `k` on a low-cardinality
2945 // predictor (binary indicator, 3-level ordinal, small count) used
2946 // to hard-fail in `select_cr_knots` instead of reducing like mgcv
2947 // and gam's tensor path. Below the cr minimum (a binary covariate)
2948 // degrade to the B-spline marginal the default `s(x, k=..)` basis
2949 // already fits on the same data — never a hard error.
2950 let k_cr = (n_knots + effective_degree + 1).max(CR_MIN_KNOTS);
2951 let knotspec = match capped_cr_marginal_knotspec(
2952 ds.values.column(c),
2953 k_cr,
2954 &vars.join(","),
2955 inference_notes,
2956 )? {
2957 Some(cr_knotspec) => cr_knotspec,
2958 None => resolve_nonperiodic_bspline_knotspec(
2959 options,
2960 ds.values.column(c),
2961 (minv, maxv),
2962 effective_degree,
2963 n_knots,
2964 )?,
2965 };
2966 (knotspec, parse_cyclic_boundary(options, minv, maxv)?)
2967 } else {
2968 (
2969 resolve_nonperiodic_bspline_knotspec(
2970 options,
2971 ds.values.column(c),
2972 (minv, maxv),
2973 effective_degree,
2974 n_knots,
2975 )?,
2976 parse_cyclic_boundary(options, minv, maxv)?,
2977 )
2978 };
2979 // Both cubic-regression spellings recover unsupported null-space
2980 // effects by default. An explicit `double_penalty=false` is the
2981 // MLE-style opt-out.
2982 let double_penalty = smooth_double_penalty;
2983 // Clamp the marginal difference penalty to `<= effective_degree`
2984 // so it stays well-defined when the per-axis degree was reduced
2985 // (mirrors the tensor margin path: `create_difference_penalty_matrix`
2986 // requires order < num_basis_functions).
2987 let penalty_order = option_usize(options, "penalty_order")
2988 .unwrap_or(DEFAULT_PENALTY_ORDER)
2989 .min(effective_degree);
2990 Ok(SmoothBasisSpec::BSpline1D {
2991 feature_col: c,
2992 spec: BSplineBasisSpec {
2993 degree: effective_degree,
2994 penalty_order,
2995 knotspec,
2996 double_penalty,
2997 identifiability,
2998 boundary,
2999 boundary_conditions,
3000 },
3001 })
3002 }
3003 "tps" | "thinplate" | "thin-plate" => {
3004 validate_known_options("thinplate", options, THINPLATE_SMOOTH_OPTION_KEYS)?;
3005 let plan = plan_spatial_basis(
3006 sizing_rows,
3007 cols.len(),
3008 CenterCountRequest::Default,
3009 DuchonNullspaceOrder::Linear,
3010 option_bool(options, "scale_dims").unwrap_or(false),
3011 policy,
3012 )
3013 .map_err(|e| e.to_string())?;
3014 // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) that used to live
3015 // here was DELETED. It masked the real defect — the n-scaling default
3016 // over-sizes a thin-plate field, producing a weakly-identified
3017 // two-penalty ρ-surface the outer optimizer stalls on (row-order
3018 // dependent, #1378), and surplus columns REML can't penalize away on
3019 // weak-signal fits. Capping the basis hid that stall instead of fixing
3020 // it. The default now uses the generic spatial center heuristic; the
3021 // root fix (a well-identified ρ-surface / optimizer that doesn't stall)
3022 // is tracked separately. Explicit `k`/`centers` still take full effect.
3023 let default_centers = plan.centers;
3024 let centers = parse_countwith_basis_alias(
3025 options,
3026 "centers",
3027 cap_default_spatial_centers(options, default_centers),
3028 )?;
3029 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3030 spatial_center_strategy_for_dimension(centers, cols.len())
3031 } else {
3032 auto_spatial_center_strategy(centers, cols.len())
3033 };
3034 // `include_intercept` appends a constant column to a KERNEL basis
3035 // that has no polynomial null space of its own — which is exactly
3036 // what the Matérn basis is, and exactly what the thin-plate basis is
3037 // not: a TPS ships its polynomial null space (constant and linear)
3038 // by construction, sized by
3039 // `thin_plate_polynomial_basis_dimension`. An appended constant
3040 // would therefore be exactly collinear with a column already in the
3041 // span. The option was whitelisted here anyway and read by nothing,
3042 // so it was accepted and silently discarded (#2781's family).
3043 if options.contains_key("include_intercept") {
3044 return Err(TermBuilderError::unsupported_feature(
3045 "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.",
3046 )
3047 .to_string());
3048 }
3049 let periodic = parse_periodic_axes_option(options, cols.len())?;
3050 reject_unconsumable_radial_period_declaration(
3051 "thinplate",
3052 options,
3053 cols.len(),
3054 periodic.as_deref(),
3055 false,
3056 )?;
3057 Ok(SmoothBasisSpec::ThinPlate {
3058 feature_cols: cols.to_vec(),
3059 spec: ThinPlateBasisSpec {
3060 center_strategy,
3061 periodic,
3062 // Sentinel: leave at 0.0 when the user didn't pass an
3063 // explicit length_scale so `auto_init_length_scale_in_place`
3064 // can replace it with a data-derived initialization. The
3065 // old hard-coded 1.0 was the documented basin (see
3066 // smooth.rs `auto_init_length_scale_in_place`) that the
3067 // spatial optimizer could not escape, leaving TPS terms
3068 // initialized off the data scale.
3069 length_scale: option_f64(options, "length_scale").unwrap_or(0.0),
3070 double_penalty: smooth_double_penalty,
3071 identifiability: parse_spatial_identifiability(options)
3072 .map_err(|e| e.to_string())?,
3073 radial_reparam: None,
3074 },
3075 input_scale: None,
3076 })
3077 }
3078 "sphere" | "s2" | "sos" => {
3079 validate_known_options("sphere", options, SPHERE_SMOOTH_OPTION_KEYS)?;
3080 if cols.len() != 2 {
3081 return Err(format!(
3082 "sphere smooth expects exactly two variables (lat, lon), got {}",
3083 cols.len()
3084 ));
3085 }
3086 let radians = option_bool(options, "radians").unwrap_or_else(|| {
3087 options
3088 .get("units")
3089 .map(|u| u.eq_ignore_ascii_case("radian") || u.eq_ignore_ascii_case("radians"))
3090 .unwrap_or(false)
3091 });
3092 // An explicit `degree`/`l`/`max_degree` names a spherical-harmonic
3093 // truncation, so with no explicit kernel/method it selects the
3094 // Harmonic construction (the Wahba kernel ignores `degree` and would
3095 // silently emit a 1-column kernel design). An explicit kernel/method
3096 // still wins.
3097 let degree_requested = options.contains_key("degree")
3098 || options.contains_key("l")
3099 || options.contains_key("max_degree")
3100 || options.contains_key("max-degree");
3101 let kernel = options
3102 .get("kernel")
3103 .or_else(|| options.get("method"))
3104 .map(|raw| strip_quotes(raw).trim().to_ascii_lowercase())
3105 .unwrap_or_else(|| {
3106 if degree_requested {
3107 "harmonic".to_string()
3108 } else {
3109 "sobolev".to_string()
3110 }
3111 });
3112 let (method, wahba_kernel) = match kernel.as_str() {
3113 "sobolev" | "wahba" | "wahba_sobolev" | "wahba-sobolev" => {
3114 (SphereMethod::Wahba, SphereWahbaKernel::Sobolev)
3115 }
3116 "pseudo" | "mgcv" | "sos" | "wahba_pseudo" | "wahba-pseudo" => {
3117 (SphereMethod::Wahba, SphereWahbaKernel::Pseudo)
3118 }
3119 "harmonic" | "spherical_harmonic" | "spherical-harmonic" => {
3120 (SphereMethod::Harmonic, SphereWahbaKernel::Sobolev)
3121 }
3122 other => {
3123 return Err(format!(
3124 "unsupported sphere kernel '{other}'; expected sobolev, pseudo, or harmonic"
3125 ));
3126 }
3127 };
3128 // `lmax=` states a finite spectral resolution for a Wahba kernel,
3129 // selecting the truncated variant `Σ_{ℓ=1..lmax} c_ℓ P_ℓ(cos γ)`
3130 // instead of the closed form. This is the only route from the
3131 // formula surface to `SobolevTruncated`/`PseudoTruncated`, and it
3132 // is what makes `m=1` expressible at all: the untruncated Sobolev
3133 // `m = 1` kernel is log-singular at coincidence, so it has no Gram
3134 // diagonal and the basis builder refuses it (#2475). Before this
3135 // option the refusal named a remedy no formula could reach.
3136 let wahba_kernel = match option_usize_any(options, &["lmax", "l_max", "l-max"]) {
3137 None => wahba_kernel,
3138 Some(_) if matches!(method, SphereMethod::Harmonic) => {
3139 return Err(
3140 "sphere smooth: lmax= states the truncation of a Wahba reproducing kernel \
3141 and does not apply to kernel=harmonic; use degree=/max_degree= to set the \
3142 harmonic degree"
3143 .to_string(),
3144 );
3145 }
3146 Some(lmax) => {
3147 if !(SPHERE_TRUNCATION_LMAX_RANGE).contains(&lmax) {
3148 return Err(format!(
3149 "sphere smooth: lmax={lmax} is out of range; the truncated Wahba \
3150 kernels support lmax in {}..={} (the device kernel bakes it in as a \
3151 compile-time bound)",
3152 SPHERE_TRUNCATION_LMAX_RANGE.start(),
3153 SPHERE_TRUNCATION_LMAX_RANGE.end()
3154 ));
3155 }
3156 let lmax = lmax as u16;
3157 match wahba_kernel {
3158 SphereWahbaKernel::Sobolev | SphereWahbaKernel::SobolevTruncated { .. } => {
3159 SphereWahbaKernel::SobolevTruncated { lmax }
3160 }
3161 SphereWahbaKernel::Pseudo | SphereWahbaKernel::PseudoTruncated { .. } => {
3162 SphereWahbaKernel::PseudoTruncated { lmax }
3163 }
3164 }
3165 }
3166 };
3167 let max_degree = if matches!(method, SphereMethod::Harmonic) {
3168 let degree =
3169 option_usize_any(options, &["degree", "l", "max_degree", "max-degree"])
3170 .or_else(|| option_usize(options, "centers"))
3171 .or_else(|| {
3172 option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
3173 .and_then(|k| (1..=128).find(|&l| l * (l + 2) >= k))
3174 })
3175 .unwrap_or_else(|| default_spherical_harmonic_degree(sizing_rows));
3176 if degree == 0 {
3177 return Err("sphere smooth requires degree/max_degree >= 1".to_string());
3178 }
3179 if degree > 32 {
3180 return Err(format!(
3181 "sphere smooth max_degree={} is too large for the dense harmonic engine (limit 32)",
3182 degree
3183 ));
3184 }
3185 Some(degree)
3186 } else {
3187 None
3188 };
3189 let penalty_order =
3190 parse_penalty_order_alias(options)?.unwrap_or(DEFAULT_PENALTY_ORDER);
3191 let center_strategy = if matches!(method, SphereMethod::Wahba) {
3192 let mut centers = parse_countwith_basis_alias(
3193 options,
3194 "centers",
3195 default_num_centers(sizing_rows, cols.len()),
3196 )?;
3197 if penalty_order >= 4 {
3198 centers = centers.max(30);
3199 }
3200 CenterStrategy::FarthestPoint {
3201 num_centers: centers,
3202 }
3203 } else {
3204 CenterStrategy::FarthestPoint { num_centers: 0 }
3205 };
3206 Ok(SmoothBasisSpec::Sphere {
3207 feature_cols: cols.to_vec(),
3208 spec: SphericalSplineBasisSpec {
3209 center_strategy,
3210 penalty_order,
3211 double_penalty: smooth_double_penalty,
3212 radians,
3213 method,
3214 max_degree,
3215 wahba_kernel,
3216 identifiability: SphericalSplineIdentifiability::CenterSumToZero,
3217 },
3218 })
3219 }
3220 "curvature" => {
3221 // Constant-curvature (M_κ) geodesic-kernel smooth (#944): the
3222 // κ-generic sibling of the intrinsic S² smooth above. The feature
3223 // columns are κ-stereographic chart coordinates and the geometry
3224 // comes from `geometry::constant_curvature::ConstantCurvature`.
3225 // `kappa=` follows the mgcv-`sp=` convention (gam#2152): an EXPLICIT
3226 // value is a FIXED sectional curvature that selects the geometry
3227 // (`Sᵈ` for κ>0, `ℝᵈ` for κ=0, `Hᵈ` for κ<0) and is honoured verbatim
3228 // by the fit; OMITTING `kappa=` leaves κ free for the #944/#1464
3229 // outer ψ-coordinate estimation, seeded at the flat default 0.
3230 validate_known_options("curvature", options, CURVATURE_SMOOTH_OPTION_KEYS)?;
3231 // `kappa=` follows the mgcv-`sp=` convention: an EXPLICIT value pins
3232 // the sectional curvature (fixed geometry, honoured verbatim by the
3233 // fit — gam#2152); an OMITTED `kappa=` leaves κ free for the
3234 // #944/#1464 outer estimation, seeded at the flat default 0.
3235 let kappa_opt = option_f64(options, "kappa");
3236 let kappa_fixed = kappa_opt.is_some();
3237 let kappa = kappa_opt.unwrap_or(0.0);
3238 if !kappa.is_finite() {
3239 return Err("curvature smooth requires a finite kappa".to_string());
3240 }
3241 // `length_scale=` follows the SAME mgcv-`sp=` convention as `kappa=`
3242 // (gam#2747): an EXPLICIT value pins the kernel resolution and the fit
3243 // honours it verbatim; an OMITTED one leaves η = ln ℓ free for the
3244 // outer estimation, seeded by the auto rule. The range must be fitted
3245 // by default because it is confounded with κ — pinning it makes κ
3246 // absorb the range error rather than measure curvature.
3247 let length_scale_opt = option_f64(options, "length_scale");
3248 let length_scale_fixed = length_scale_opt.is_some();
3249 let length_scale = length_scale_opt.unwrap_or(0.0);
3250 if !length_scale.is_finite() || length_scale < 0.0 {
3251 return Err(format!(
3252 "curvature smooth length_scale must be positive (or omitted for auto); got {length_scale}"
3253 ));
3254 }
3255 let centers = parse_countwith_basis_alias(
3256 options,
3257 "centers",
3258 default_num_centers(sizing_rows, cols.len()),
3259 )?;
3260 if centers < 2 {
3261 return Err("curvature smooth requires at least 2 centers".to_string());
3262 }
3263 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3264 spatial_center_strategy_for_dimension(centers, cols.len())
3265 } else {
3266 auto_spatial_center_strategy(centers, cols.len())
3267 };
3268 Ok(SmoothBasisSpec::ConstantCurvature {
3269 feature_cols: cols.to_vec(),
3270 spec: ConstantCurvatureBasisSpec {
3271 center_strategy,
3272 kappa,
3273 kappa_fixed,
3274 // 0.0 sentinel = κ-independent auto initialization in the
3275 // basis builder (median chart center spacing, doubled).
3276 length_scale,
3277 length_scale_fixed,
3278 // Curvature smooth defaults to NO double-penalty ridge
3279 // (#1464): the curvature-blind ridge `I` absorbs the data fit
3280 // independently of κ and rails the fitted curvature to the
3281 // +chart bound (hyperbolic truth recovered as spherical). The
3282 // RKHS Gram penalty is already full-rank PD, so the ridge adds
3283 // no stability. Honour an EXPLICIT `double_penalty=` only.
3284 double_penalty: option_bool(options, "double_penalty").unwrap_or(false),
3285 identifiability: ConstantCurvatureIdentifiability::CenterSumToZero,
3286 },
3287 })
3288 }
3289 "measurejet" => {
3290 // Measure-jet spline: multiscale local-jet-residual energy of the
3291 // empirical measure. The feature columns are ambient coordinates
3292 // of data concentrated near an unknown low-dimensional set; the
3293 // geometry (centers, masses, scale band) is read off the measure
3294 // at build time — magic by default, every option optional.
3295 validate_known_options("measurejet", options, MEASURE_JET_SMOOTH_OPTION_KEYS)?;
3296 let order_s = option_f64(options, "s").unwrap_or(0.0);
3297 // 0.0 = auto sentinel; explicit values must sit inside the
3298 // admissible order interval of the affine-jet (r = 2) energy.
3299 if !(order_s.is_finite() && (order_s == 0.0 || (order_s > 0.0 && order_s < 2.0))) {
3300 return Err(format!(
3301 "measurejet smooth s must lie in (0, 2) (or be omitted for auto); got {order_s}"
3302 ));
3303 }
3304 // Default to the spec Default (α = 1, density-WEIGHTED Hessian
3305 // energy — the module-header default). The density-free α = 3/2
3306 // (q^{−2}) over-smooths low-intrinsic-dimension manifolds where the
3307 // local mass q is tiny and varies along the stratum (#1116:
3308 // 13×-worse-than-matérn on a 1-D curve in 3-D); α = 1's q^{−1} is
3309 // gentler and robust across intrinsic dimensions. An explicit
3310 // `alpha=` still overrides for full-dimensional density-free use.
3311 let alpha =
3312 option_f64(options, "alpha").unwrap_or(MeasureJetBasisSpec::default().alpha);
3313 if !alpha.is_finite() {
3314 return Err("measurejet smooth requires a finite alpha".to_string());
3315 }
3316 let tau0 = option_f64(options, "tau").unwrap_or(1e-3);
3317 if !(tau0.is_finite() && tau0 >= 0.0) {
3318 return Err(format!(
3319 "measurejet smooth tau must be finite and nonnegative; got {tau0}"
3320 ));
3321 }
3322 let num_scales = option_usize(options, "scales").unwrap_or(0);
3323 let length_scale = option_f64(options, "length_scale").unwrap_or(0.0);
3324 if !length_scale.is_finite() || length_scale < 0.0 {
3325 return Err(format!(
3326 "measurejet smooth length_scale must be positive (or omitted for auto); got {length_scale}"
3327 ));
3328 }
3329 let centers = parse_countwith_basis_alias(
3330 options,
3331 "centers",
3332 default_num_centers(sizing_rows, cols.len()),
3333 )?;
3334 if centers < 3 {
3335 return Err("measurejet smooth requires at least 3 centers".to_string());
3336 }
3337 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3338 spatial_center_strategy_for_dimension(centers, cols.len())
3339 } else {
3340 auto_spatial_center_strategy(centers, cols.len())
3341 };
3342 // Multiscale (per-scale spectral split + (α, lnτ) ψ dials + the
3343 // affine-preserving ridge) is an explicit opt-in (#1116): default
3344 // single-scale at any center count, the Duchon/Matérn footprint.
3345 let multiscale = option_bool(options, "multiscale").unwrap_or(false);
3346 // The representer range ℓ is a design-moving basis coordinate of the
3347 // same kind as the Matérn κ: λ shrinks inside a span and cannot move
3348 // one, so a frozen ℓ is an error no smoothing parameter can repair
3349 // (#2761 measured it at 13.4x held-out RMSE on a 1-D curve in 3-D,
3350 // with the design's own span floor sitting AT the fitted value).
3351 // REML therefore selects it by default.
3352 //
3353 // An explicit `length_scale=` is a request, not a seed, so it pins ℓ
3354 // — the same short-circuit `all_spatial_terms_kappa_fixed` gives an
3355 // explicitly-scaled Matérn. `learn_length_scale=` overrides either
3356 // way.
3357 let learn_length_scale =
3358 option_bool(options, "learn_length_scale").unwrap_or(length_scale == 0.0);
3359 Ok(SmoothBasisSpec::MeasureJet {
3360 feature_cols: cols.to_vec(),
3361 spec: MeasureJetBasisSpec {
3362 center_strategy,
3363 order_s,
3364 alpha,
3365 tau0,
3366 num_scales,
3367 // 0.0 sentinel = auto initialization in the basis builder
3368 // (median nearest-center spacing).
3369 length_scale,
3370 double_penalty: smooth_double_penalty,
3371 learn_length_scale,
3372 multiscale,
3373 identifiability: MeasureJetIdentifiability::CenterSumToZero,
3374 frozen_quadrature: None,
3375 },
3376 input_scale: None,
3377 })
3378 }
3379 "matern" => {
3380 // Catch typos like `lengt_scale=` / `nyu=` / `centerz=` before
3381 // they get silently ignored and the user wonders why their
3382 // option had no effect. The matern() term accepts exactly
3383 // these options.
3384 validate_known_options("matern", options, MATERN_SMOOTH_OPTION_KEYS)?;
3385 let plan = plan_spatial_basis(
3386 sizing_rows,
3387 cols.len(),
3388 CenterCountRequest::Default,
3389 DuchonNullspaceOrder::Zero,
3390 option_bool(options, "scale_dims").unwrap_or(false),
3391 policy,
3392 )
3393 .map_err(|e| e.to_string())?;
3394 // #1867: spline-equivalent floor so a 1-D radial basis is not
3395 // dimensioned coarser than the competing `s(x)` on identical data.
3396 let univariate_floor = if cols.len() == 1 {
3397 heuristic_knots_for_column(ds.values.column(cols[0]))
3398 .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
3399 } else {
3400 0
3401 };
3402 let centers = parse_countwith_basis_alias(
3403 options,
3404 "centers",
3405 cap_default_spatial_centers(
3406 options,
3407 default_matern_center_count(
3408 sizing_rows,
3409 cols.len(),
3410 plan.centers,
3411 univariate_floor,
3412 ),
3413 ),
3414 )?;
3415 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3416 spatial_center_strategy_for_dimension(centers, cols.len())
3417 } else {
3418 auto_spatial_center_strategy(centers, cols.len())
3419 };
3420 let nu = parse_matern_nu(options.get("nu").map(String::as_str).unwrap_or("5/2"))?;
3421 // The exponential (ν = 1/2) Matérn kernel has a singular Laplacian
3422 // at zero in d ≥ 2, so the operator-collocation penalty machinery
3423 // hits a non-invertible matrix during fit. Surface the cause
3424 // up-front instead of letting the user see the generic
3425 // "Matrix conditioning issue detected" wrapper from PIRLS.
3426 if matches!(nu, MaternNu::Half) && cols.len() >= 2 {
3427 return Err(TermBuilderError::unsupported_feature(format!(
3428 "matern() with nu=1/2 is not supported for d>=2 (got {} covariates): \
3429 the exponential kernel's Laplacian is singular at center collisions, \
3430 which makes the operator-collocation penalty non-invertible. \
3431 Choose nu>=3/2 (e.g. nu=3/2 or the default nu=5/2) for multi-dimensional smooths.",
3432 cols.len()
3433 ))
3434 .to_string());
3435 }
3436 let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
3437 Some(vec![0.0; cols.len()])
3438 } else {
3439 None
3440 };
3441 let periodic = parse_periodic_axes_option(options, cols.len())?;
3442 reject_unconsumable_radial_period_declaration(
3443 "matern",
3444 options,
3445 cols.len(),
3446 periodic.as_deref(),
3447 false,
3448 )?;
3449 Ok(SmoothBasisSpec::Matern {
3450 feature_cols: cols.to_vec(),
3451 spec: MaternBasisSpec {
3452 center_strategy,
3453 periodic,
3454 // Preserve whether the user supplied `length_scale` as typed
3455 // provenance. The planner resolves `Auto` to the same
3456 // data-derived wiggly-side initialization the thin-plate path
3457 // uses (`max_range / sqrt(n)`), then lets the κ-optimizer refine
3458 // it without ever turning it into a user-fixed scale.
3459 //
3460 // gam#1629: the previous `default_matern_length_scale` seeded
3461 // the FULL data diameter — the maximally over-smoothed corner.
3462 // Because that value looked explicit, the old auto-init was a
3463 // no-op for Matérn, so the κ-optimizer started in the flat
3464 // over-smoothed basin and parked there, leaving high-frequency
3465 // 2-D surfaces unresolved (truth-RMSE ~6× worse than
3466 // thin-plate/tensor on identical data, and insensitive to `k`).
3467 // Typed Auto starts REML in the resolving regime it can escape
3468 // from and cannot be confused with explicit zero.
3469 length_scale: option_f64(options, "length_scale")
3470 .map(MaternLengthScale::fixed)
3471 .unwrap_or_else(MaternLengthScale::auto),
3472 nu,
3473 include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
3474 double_penalty: smooth_double_penalty,
3475 identifiability: parse_matern_identifiability(options)
3476 .map_err(|e| e.to_string())?,
3477 aniso_log_scales,
3478 // Cold build: let the bootstrap-κ spectral test decide whether
3479 // the double-penalty nullspace shrinkage survives; the freeze
3480 // step then pins that decision into the FrozenTransform so the
3481 // κ-optimizer's rebuilds keep the count invariant (gam#787/#860).
3482 },
3483 input_scale: None,
3484 })
3485 }
3486 "duchon" | "ds" => {
3487 validate_known_options("duchon", options, DUCHON_SMOOTH_OPTION_KEYS)?;
3488 if options.contains_key("double_penalty") {
3489 return Err(TermBuilderError::incompatible_config(format!(
3490 "Duchon smooth '{}' does not support double_penalty; the Duchon smoother already ships its native reproducing-norm penalty plus a null-space shrinkage ridge.",
3491 vars.join(", ")
3492 ))
3493 .to_string());
3494 }
3495 let requested_nullspace_order = parse_duchon_order_opt(options)?;
3496 let length_scale = option_f64_strict(options, "length_scale")?;
3497 // Resolve `(nullspace_order, power)`. The default (magic) path is a
3498 // structural amplitude/slope/curvature smoother: an affine (`Linear`)
3499 // polynomial nullspace and spectral power `s = (d - 1)/2`, giving the
3500 // cubic kernel `r^3` in 1D. There is no nullspace-order escalation —
3501 // the structural cubic smoother is well-defined for every dimension.
3502 //
3503 // Explicit `power=...` honors the user's value verbatim against their
3504 // requested nullspace order; the kernel validator emits a precise
3505 // diagnostic for any inadmissible combination. In the scale-free
3506 // (non-hybrid) regime fractional powers are admitted and threaded as
3507 // `f64`. The hybrid Duchon-Matérn kernel (`length_scale=Some`) is
3508 // restricted to integer powers.
3509 let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
3510 DuchonPowerPolicy::Explicit(req_power) => {
3511 if length_scale.is_some() && req_power.fract() != 0.0 {
3512 return Err(TermBuilderError::incompatible_config(format!(
3513 "hybrid Duchon-Matern smooth '{}' (length_scale=...) requires an integer power, got power={}; \
3514 drop length_scale to use the scale-free structural kernel with a fractional power.",
3515 vars.join(", "),
3516 req_power,
3517 ))
3518 .to_string());
3519 }
3520 (
3521 requested_nullspace_order.unwrap_or(DuchonNullspaceOrder::Linear),
3522 req_power,
3523 )
3524 }
3525 DuchonPowerPolicy::CubicStructuralDefault => {
3526 // Magic cubic rule (REQUEST-LAYER default): no explicit power ⇒
3527 // affine null space + fractional spectral power s = (d-1)/2, i.e.
3528 // the Duchon kernel φ(r)=r³ in every dimension. An EXPLICIT
3529 // `power=0` is handled above and is honored as the s=0 Duchon
3530 // kernel (r²·log r ≡ the thin-plate kernel in even d) — the magic
3531 // default lives here, not in the basis builder.
3532 // An explicit `order=` names the polynomial null space; the
3533 // structural default then supplies only the spectral power.
3534 // Taking the whole PAIR from the default discarded a
3535 // caller's `order=` whenever no `power=` accompanied it, so
3536 // `duchon(x, z, order=0)` and `order=2` were parsed,
3537 // validated, and thrown away (#2781's family). `order=1` is
3538 // the default null space, so every shipped
3539 // `duchon(..., order=1)` formula is unaffected.
3540 match length_scale {
3541 None => {
3542 let (default_order, s) =
3543 crate::basis::duchon_cubic_default(cols.len());
3544 (requested_nullspace_order.unwrap_or(default_order), s)
3545 }
3546 Some(_) => {
3547 // The hybrid Matérn-blended kernel (`length_scale=Some`)
3548 // requires an INTEGER spectral power `s` (the partial-
3549 // fraction split `1/(ρ^{2p}(κ²+ρ²)^s)` is only defined for
3550 // integer `s`). The fractional cubic default `s=(d-1)/2` is
3551 // a half-integer for even `d`, and the basis builder's
3552 // `power_as_usize` maps a NON-integer to `0` (not its
3553 // floor) — so for even `d ≥ 4` the realized kernel has
3554 // `2(p+s) = 2p = 4 ≤ d`, which is non-finite at the origin
3555 // and crashes the fit (historically a non-finite
3556 // eigendecomposition; now a fit-time validation error).
3557 //
3558 // Resolve to the same structural cubic default the
3559 // scale-free path uses (affine `Linear` null space, `r³`
3560 // kernel, fractional power `s = (d-1)/2`) but take the
3561 // largest admissible INTEGER at or below it — `⌊(d-1)/2⌋`.
3562 // For odd `d` this is exactly the cubic power (the hybrid
3563 // default then agrees with the scale-free cubic default);
3564 // for even `d` it is the nearest integer below. Either way
3565 // `p = 2` (affine) gives spectral order
3566 // `2(p+s) = d+3` (odd `d`) or `d+2` (even `d`), which
3567 // clears both kernel existence `2(p+s) > d` and the D1
3568 // collocation floor `2(p+s) > d+1` for every `d ≥ 1`.
3569 // Flooring here at the request layer avoids the
3570 // `power_as_usize` truncation-to-zero on the fractional
3571 // half-integer.
3572 let (default_order, s_frac) =
3573 crate::basis::duchon_cubic_default(cols.len());
3574 (
3575 requested_nullspace_order.unwrap_or(default_order),
3576 s_frac.floor(),
3577 )
3578 }
3579 }
3580 }
3581 };
3582 let plan = plan_spatial_basis(
3583 sizing_rows,
3584 cols.len(),
3585 CenterCountRequest::Default,
3586 nullspace_order,
3587 option_bool(options, "scale_dims").unwrap_or(false),
3588 policy,
3589 )
3590 .map_err(|e| e.to_string())?;
3591 let centers_explicit = has_explicit_countwith_basis_alias(options, "centers");
3592 let polynomial_cols = match nullspace_order {
3593 DuchonNullspaceOrder::Zero => 1,
3594 DuchonNullspaceOrder::Linear => cols.len() + 1,
3595 DuchonNullspaceOrder::Degree(degree) => {
3596 crate::basis::duchon_nullspace_dimension(cols.len(), degree)
3597 }
3598 };
3599 // #1867: spline-equivalent floor so a 1-D radial basis is not
3600 // dimensioned coarser than the competing `s(x)` on identical data.
3601 let univariate_floor = if cols.len() == 1 {
3602 heuristic_knots_for_column(ds.values.column(cols[0]))
3603 .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
3604 } else {
3605 0
3606 };
3607 let default_centers = default_duchon_center_count(
3608 sizing_rows,
3609 cols.len(),
3610 plan.centers,
3611 polynomial_cols,
3612 univariate_floor,
3613 );
3614 let spectral_rank = option_usize(options, "rank");
3615 let center_default = if spectral_rank.is_some() {
3616 // mgcv's Duchon constructor runs `uniquecombs` FIRST and caps
3617 // at `max.knots` afterwards, so its knot budget is
3618 // `min(n_unique, 2000)`. This took the RAW row count and let
3619 // `select_r_uniform_subsample_centers` deduplicate later — so
3620 // on any data carrying a repeated coordinate row with fewer
3621 // than 2000 rows, the budget exceeded what the sampler could
3622 // supply and the fit hard-refused rather than degrading
3623 // (#2623: `prostate_gamair`, 523 requested vs 522 unique).
3624 // Counting distinct rows here makes the budget satisfiable by
3625 // construction, and leaves the retained spectral `rank` — a
3626 // separate option — untouched.
3627 count_unique_coordinate_rows(ds.values.view(), &cols).min(2000)
3628 } else {
3629 cap_default_spatial_centers(options, default_centers)
3630 };
3631 let requested_centers =
3632 parse_countwith_basis_alias(options, "centers", center_default)?;
3633 if requested_centers > ds.values.nrows() {
3634 return Err(TermBuilderError::incompatible_config(format!(
3635 "Duchon smooth '{}' requested {requested_centers} centers but only {} rows are available",
3636 vars.join(", "),
3637 ds.values.nrows(),
3638 ))
3639 .to_string());
3640 }
3641 if requested_centers <= polynomial_cols {
3642 return Err(TermBuilderError::incompatible_config(format!(
3643 "Duchon smooth '{}' requested basis dimension {} but order={:?} in {}D needs {} polynomial null-space columns; choose centers/k > {}",
3644 vars.join(", "),
3645 requested_centers,
3646 nullspace_order,
3647 cols.len(),
3648 polynomial_cols,
3649 polynomial_cols,
3650 ))
3651 .to_string());
3652 }
3653 if let Some(rank) = spectral_rank
3654 && (rank <= polynomial_cols || rank > requested_centers)
3655 {
3656 return Err(TermBuilderError::incompatible_config(format!(
3657 "Duchon smooth '{}' spectral rank must satisfy {} < rank <= centers (got rank={rank}, centers={requested_centers})",
3658 vars.join(", "),
3659 polynomial_cols,
3660 ))
3661 .to_string());
3662 }
3663 let mut centers = requested_centers;
3664 if !centers_explicit && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3665 centers = centers.max(polynomial_cols + 4);
3666 }
3667 let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
3668 Some(vec![0.0; cols.len()])
3669 } else {
3670 None
3671 };
3672 // Formula-level `duchon(...)` is the native Duchon reproducing-norm
3673 // smoother: the always-on Primary Gram plus the polynomial trend
3674 // ridge. Do not silently add collocated mass/tension penalties here.
3675 // They add extra REML hyperparameters and an O(k)-support quadrature
3676 // build to the default 2-D path, making `duchon(x, z)` materially
3677 // slower than the equivalent thin-plate fit without a principled
3678 // accuracy gain (gam#1718). Lower-order Hilbert-scale penalties remain
3679 // available to callers that construct an explicit DuchonBasisSpec.
3680 let operator_penalties = DuchonOperatorPenaltySpec::all_disabled();
3681 // For a 1-D periodic Duchon with no EXPLICIT period, anchor the wrap
3682 // to the covariate DATA range rather than letting the basis builder
3683 // derive it from the (k-subsampled) center span. The center span is a
3684 // strict subset of the data and undershoots the true period, seaming
3685 // the curve (f(0) ≠ f(2π)); the data range is the caller's actual
3686 // domain. Honors any explicit `period=` (parse_periodic_axes_option
3687 // already threaded it) and leaves multi-D / non-periodic untouched.
3688 let mut periodic = parse_periodic_axes_option(options, cols.len())?;
3689 if cols.len() == 1
3690 && let Some(axes) = periodic.as_mut()
3691 && axes.len() == 1
3692 && axes[0].is_none()
3693 {
3694 let (minv, maxv) = col_minmax(ds.values.column(cols[0]))?;
3695 if maxv > minv {
3696 axes[0] = Some(maxv - minv);
3697 }
3698 }
3699 let boundary = if cols.len() == 1 {
3700 let c = cols[0];
3701 let (minv, maxv) = col_minmax(ds.values.column(c))?;
3702 parse_cyclic_boundary(options, minv, maxv)?
3703 } else {
3704 OneDimensionalBoundary::Open
3705 };
3706 let is_periodic = periodic
3707 .as_ref()
3708 .is_some_and(|axes| axes.iter().any(Option::is_some))
3709 || matches!(boundary, OneDimensionalBoundary::Cyclic { .. });
3710 reject_unconsumable_radial_period_declaration(
3711 "duchon",
3712 options,
3713 cols.len(),
3714 periodic.as_deref(),
3715 matches!(boundary, OneDimensionalBoundary::Cyclic { .. }),
3716 )?;
3717 if spectral_rank.is_some() && is_periodic {
3718 return Err(TermBuilderError::incompatible_config(
3719 "Duchon spectral rank is defined for the scale-free open-domain kernel, \
3720 not a periodic image expansion"
3721 .to_string(),
3722 )
3723 .to_string());
3724 }
3725 let center_strategy = if spectral_rank.is_some() {
3726 // Freeze the exact fixed-seed uniform landmark experiment used
3727 // by mgcv's Duchon constructor. Spectral rank parity requires
3728 // the same kernel matrix, not merely the same retained column
3729 // count: maximin/equal-mass landmarks define a different
3730 // finite-sample eigenspace and confound accuracy comparisons.
3731 // Materializing 2,000×d coordinates here is cheap, avoids an
3732 // O(nk) maximin pass, and makes prediction replay explicit.
3733 let mut coordinates = Array2::<f64>::zeros((ds.values.nrows(), cols.len()));
3734 for (axis, &column) in cols.iter().enumerate() {
3735 coordinates
3736 .column_mut(axis)
3737 .assign(&ds.values.column(column));
3738 }
3739 let sampled = select_r_uniform_subsample_centers(coordinates.view(), centers, 1)
3740 .map_err(|error| error.to_string())?;
3741 CenterStrategy::UserProvided(sampled)
3742 } else if is_periodic {
3743 if centers_explicit {
3744 spatial_center_strategy_for_dimension(centers, cols.len())
3745 } else {
3746 auto_spatial_center_strategy(centers, cols.len())
3747 }
3748 } else {
3749 duchon_center_strategy(centers, cols.len(), !centers_explicit)
3750 };
3751 let center_strategy = match spectral_rank {
3752 Some(rank) => CenterStrategy::DuchonSpectral {
3753 knots: Box::new(center_strategy),
3754 basis: DuchonSpectralBasis::Fresh { rank },
3755 },
3756 None => center_strategy,
3757 };
3758 Ok(SmoothBasisSpec::Duchon {
3759 feature_cols: cols.to_vec(),
3760 spec: DuchonBasisSpec {
3761 center_strategy,
3762 periodic,
3763 length_scale,
3764 power,
3765 nullspace_order,
3766 identifiability: parse_spatial_identifiability(options)
3767 .map_err(|e| e.to_string())?,
3768 aniso_log_scales,
3769 operator_penalties,
3770 boundary,
3771 radial_reparam: None,
3772 },
3773 input_scale: None,
3774 })
3775 }
3776 "tensor" | "te" | "ti" | "t2" => {
3777 validate_known_options("tensor", options, TENSOR_SMOOTH_OPTION_KEYS)?;
3778 if cols.len() < 2 {
3779 return Err(TermBuilderError::incompatible_config(format!(
3780 "tensor smooth expects at least 2 variables, got {}",
3781 cols.len()
3782 ))
3783 .to_string());
3784 }
3785 let dim = cols.len();
3786
3787 // Tensor-product contract (#1082). `te(x1, x2, ...)` ALWAYS builds a
3788 // genuine anisotropic tensor product of per-margin bases (the arm
3789 // below), exactly as mgcv's `te()` does — one smoothing parameter per
3790 // margin, a marginal-Kronecker-sum penalty, and a separate default
3791 // function-space ridge on the joint polynomial null space. A margin
3792 // vector `bs=c('tp','tp')` requests a thin-plate FUNCTION SPACE per
3793 // axis; the tensor realizes each axis as a 1-D penalized B-spline
3794 // margin spanning that same per-axis space (tp/ps/cr/bs/cc all share
3795 // it). We deliberately do NOT silently swap the requested tensor for a
3796 // single multi-D ISOTROPIC thin-plate radial smooth (`s(x,y,bs='tp')`):
3797 // that is a different model — one isotropic smoothing parameter, no
3798 // per-margin anisotropy — and substituting it while the user wrote a
3799 // tensor formula is dishonest. A user who genuinely wants the isotropic
3800 // radial smooth asks for it directly with `s(x1, x2, bs='tp')`.
3801 // Per-margin basis vector (`bs=c('tp','tp')` / `bs=['ps','cr']`):
3802 // validate each requested margin is a penalized-spline basis that
3803 // the tensor product realizes as a 1-D B-spline margin. mgcv's
3804 // `tp`/`ps`/`cr`/`bs`/`cc` margins are all penalized splines over
3805 // the same per-axis function space, so a B-spline margin recovers
3806 // the same tensor smoothing space; genuinely different margin kinds
3807 // (e.g. adaptive `ad`, random `re`) are rejected loudly rather than
3808 // silently substituted.
3809 if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
3810 && bs_selector_is_vector(raw)
3811 {
3812 let per_margin = parse_option_list(raw);
3813 if per_margin.len() != dim {
3814 return Err(TermBuilderError::invalid_option(format!(
3815 "tensor smooth per-margin bs vector has {} entries but the smooth has {} margins",
3816 per_margin.len(),
3817 dim
3818 ))
3819 .to_string());
3820 }
3821 for (axis, margin_bs) in per_margin.iter().enumerate() {
3822 if !tensor_margin_bs_is_supported(margin_bs) {
3823 return Err(TermBuilderError::unsupported_feature(format!(
3824 "tensor smooth margin {axis} basis '{margin_bs}' is not a supported penalized-spline margin; \
3825 tensor margins accept tp/tps/ps/bs/cr/cc"
3826 ))
3827 .to_string());
3828 }
3829 }
3830 }
3831 // Validate the boundary tokens BEFORE the axis resolver reads them,
3832 // so a malformed list is refused by name rather than silently
3833 // failing the resolver's length guard.
3834 validate_tensor_boundary_tokens(options, dim)?;
3835 let periodic_axes = parse_tensor_periodic_axes(options, dim)?;
3836 reject_unconsumable_period_declaration("tensor", options, &periodic_axes)?;
3837 // The half-open endpoint spelling names a single axis's domain and
3838 // has no per-margin form, so the tensor arm never reads it — it went
3839 // in through `validate_known_options` and straight out again (#2781).
3840 if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
3841 .iter()
3842 .find(|key| options.contains_key(**key))
3843 {
3844 return Err(TermBuilderError::invalid_option(format!(
3845 "tensor(): `{key}=` declares one axis's periodic domain and has no per-margin \
3846 form; on a tensor smooth give periods=[...] (with origins=[...] for the \
3847 domain start), which name their margin"
3848 ))
3849 .to_string());
3850 }
3851 let periods_opt = parse_periods(options, &periodic_axes)?;
3852 let origins_opt = parse_period_origins(options, &periodic_axes)?;
3853 // Per-margin `degree=` / `penalty_order=`. Both keep the caller's
3854 // request as `Option` rather than collapsing it onto the default
3855 // immediately: the cr-margin routing below has to know whether the
3856 // default was ASKED FOR or merely not overridden (#2782).
3857 let requested_degrees = parse_tensor_per_axis_usize(options, "degree", dim)?;
3858 let requested_penalty_orders =
3859 parse_tensor_per_axis_usize(options, "penalty_order", dim)?;
3860 let axis_degree = |axis: usize| -> usize {
3861 requested_degrees[axis].unwrap_or(DEFAULT_BSPLINE_DEGREE)
3862 };
3863 let axis_penalty_order = |axis: usize| -> usize {
3864 requested_penalty_orders[axis]
3865 .unwrap_or(if axis_degree(axis) > 1 { 2 } else { 1 })
3866 };
3867 let (mut k_list, k_inferred) = parse_tensor_k_list(options, cols, ds)?;
3868 if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3869 for (axis, k) in k_list.iter_mut().enumerate() {
3870 *k = (*k).min(axis_degree(axis) + 2);
3871 }
3872 }
3873 if k_inferred {
3874 inference_notes.push(format!(
3875 "Automatically set per-margin basis sizes {:?} for tensor smooth '{}' \
3876 (dimension-aware tensor budget: total ∏k kept near the mgcv-te default \
3877 and within the data support, distributed geometrically across margins and \
3878 capped per margin by each column's resolution). \
3879 Override with k=<int> or k=[k0,k1,...].",
3880 k_list,
3881 vars.join(",")
3882 ));
3883 }
3884 // Per-axis requested marginal basis family. mgcv's `te()`/`ti()`
3885 // default marginal basis is the cubic regression spline (`cr`), and
3886 // the te_3d quality gap (#1074) is precisely the marginal-basis
3887 // resolution at small `k`: a `cr` margin places k value-knots at
3888 // data quantiles (finer interior resolution under natural boundary
3889 // constraints) where the cubic B-spline margin has only
3890 // `k-degree-1` interior knots. Resolve each axis to either an
3891 // explicit per-margin `bs` (vector `bs=c('cr','ps')`), a single
3892 // scalar `bs`, or the unset default — and route
3893 // `cr`/`cs`/unset/`tp`/`tps` margins through the natural cubic
3894 // regression builder (`NaturalCubicRegression` knotspec), keeping
3895 // explicit `ps`/`bs`/`bspline` on the B-spline margin.
3896 let per_axis_bs: Vec<Option<String>> =
3897 match options.get("bs").or_else(|| options.get("type")) {
3898 Some(raw) if bs_selector_is_vector(raw) => {
3899 let list = parse_option_list(raw);
3900 (0..dim).map(|a| list.get(a).cloned()).collect()
3901 }
3902 Some(raw) => {
3903 let scalar = raw
3904 .trim()
3905 .trim_matches('"')
3906 .trim_matches('\'')
3907 .to_ascii_lowercase();
3908 vec![Some(scalar); dim]
3909 }
3910 None => vec![None; dim],
3911 };
3912 // A margin is realized as a natural cubic regression spline when it
3913 // is the (unset) mgcv default, an explicit `cr`/`cs`, or a
3914 // `tp`/`tps` (same per-axis penalized-spline space). Explicit
3915 // B-spline-family margins (`ps`/`bs`/`bspline`/`p-spline`) keep the
3916 // open B-spline margin.
3917 let margin_wants_cr = |bs: &Option<String>| -> bool {
3918 matches!(
3919 bs.as_deref(),
3920 None | Some("cr") | Some("cs") | Some("tp") | Some("tps")
3921 )
3922 };
3923 let requested_knot_placement = explicit_knot_placement(options)?;
3924 let mut margins: Vec<BSplineBasisSpec> = Vec::with_capacity(dim);
3925 let mut emitted_periods: Vec<Option<f64>> = Vec::with_capacity(dim);
3926 for axis in 0..dim {
3927 let c = cols[axis];
3928 let (data_min, data_max) = col_minmax(ds.values.column(c))?;
3929 // mgcv reduces a tensor margin's basis dimension to what its data
3930 // can support: a cr or B-spline margin cannot place more value
3931 // knots / basis functions than there are DISTINCT covariate
3932 // values on that axis. Without this cap an explicit `k` on a
3933 // low-cardinality margin — e.g. the binary `badh ∈ {0,1}` in
3934 // `te(age, badh, k=5)` — hard-failed in `select_cr_knots` ("cubic
3935 // regression spline with k=5 requires at least 5 distinct values,
3936 // got 2") instead of degrading to the 2-function (linear) margin
3937 // mgcv builds there. The auto-`k` path already caps per margin via
3938 // `heuristic_tensor_margin_knots`; mirror that for explicit `k`.
3939 // The cap propagates correctly: every per-axis quantity below
3940 // (effective degree, knot set, penalty order) is derived from
3941 // `k_axis`, and the marginal basis size is read from the resulting
3942 // knot spec — never from `k_list`. Floor at 2 so a margin still
3943 // carries at least a linear basis (tensor margins require k >= 2).
3944 let k_requested = k_list[axis];
3945 let n_distinct_axis = unique_count_column(ds.values.column(c));
3946 let k_axis = k_requested.min(n_distinct_axis).max(2);
3947 if k_axis < k_requested {
3948 log::info!(
3949 "tensor smooth: margin axis {axis} requested k={k_requested}, but the \
3950 covariate has only {n_distinct_axis} distinct value(s); reducing this \
3951 margin to k={k_axis} (mgcv-style data-support cap on the per-axis basis)."
3952 );
3953 }
3954 // Per-axis effective spline degree. The B-spline basis with `k`
3955 // functions is well-defined for any `degree <= k - 1`; mgcv's
3956 // `te(...)` exploits this so a binary tensor margin
3957 // (`k=2` → linear basis) or a ternary margin (`k=3` → quadratic)
3958 // can coexist with a smoother continuous margin under one
3959 // shared `degree=` request. We mirror that: if the caller
3960 // explicitly asks for `k < degree + 1`, drop the degree on
3961 // THAT axis only to the largest feasible spline, and track the
3962 // penalty order so the marginal difference penalty stays
3963 // well-defined (`order < num_basis_functions` is required by
3964 // `create_difference_penalty_matrix`). Apply the same
3965 // per-margin degree shrinkage to periodic tensor margins too:
3966 // a cyclic marginal basis with k=3 cannot be cubic, but it is
3967 // still a valid lower-degree cyclic margin with dimension k,
3968 // matching mgcv's small-k tensor-margin behavior.
3969 if k_axis < 2 {
3970 return Err(TermBuilderError::invalid_option(format!(
3971 "tensor smooth: k[{axis}]={k_axis} too small; tensor margins require k >= 2"
3972 ))
3973 .to_string());
3974 }
3975 let degree = axis_degree(axis);
3976 let penalty_order = axis_penalty_order(axis);
3977 let effective_degree = degree.min(k_axis - 1).max(1);
3978 let effective_penalty_order = penalty_order.min(effective_degree);
3979 // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3980 // without necessarily supplying a `period=`: mgcv's `bs="cc"`
3981 // wraps at the covariate's observed data range. Mirror the 1-D
3982 // cyclic fallback (`parse_periodic_domain_1d`) here so a bare
3983 // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3984 // [min, max] span instead of hard-erroring (#1752).
3985 let margin_is_cc = matches!(
3986 canonicalize_smooth_type(per_axis_bs[axis].as_deref().unwrap_or("")),
3987 "cc" | "cp" | "cyclic"
3988 );
3989 let (knotspec, boundary, axis_period) = if periodic_axes[axis] {
3990 // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3991 // without necessarily supplying a `period=`; in that case wrap
3992 // at the covariate's observed [min, max] span, mirroring the
3993 // 1-D cyclic fallback (`parse_periodic_domain_1d`) so a bare
3994 // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3995 // range instead of hard-erroring (#1752). An axis made
3996 // periodic by an explicit `periodic=`/`boundary=` selector
3997 // (not a cyclic margin basis) still requires an explicit
3998 // `period=`: a data-derived period there is a sample-dependent
3999 // off-by-ε seam and is not inferred.
4000 let (domain_start, period_value) = match periods_opt[axis] {
4001 Some(period_value) => {
4002 if !period_value.is_finite() || period_value <= 0.0 {
4003 return Err(format!(
4004 "tensor smooth axis {axis}: period must be a positive finite value, got {period_value}"
4005 ));
4006 }
4007 (origins_opt[axis].unwrap_or(data_min), period_value)
4008 }
4009 None if margin_is_cc => {
4010 let span = data_max - data_min;
4011 if !span.is_finite() || span <= 0.0 {
4012 return Err(format!(
4013 "tensor smooth axis {axis}: cyclic margin requires a positive \
4014 observed data range to derive its period, got [{data_min}, {data_max}]"
4015 ));
4016 }
4017 (origins_opt[axis].unwrap_or(data_min), span)
4018 }
4019 None => {
4020 return Err(format!(
4021 "tensor smooth axis {axis} is periodic but requires an explicit \
4022 period: pass period=<value> (scalar) or period=[..., <value>, ...]. \
4023 Deriving the period from the observed data range is sample-dependent \
4024 (off-by-ε seam), so it is not inferred."
4025 ));
4026 }
4027 };
4028 let domain_end = domain_start + period_value;
4029 (
4030 BSplineKnotSpec::PeriodicUniform {
4031 data_range: (domain_start, domain_end),
4032 num_basis: k_axis,
4033 },
4034 OneDimensionalBoundary::Cyclic {
4035 start: domain_start,
4036 end: domain_end,
4037 },
4038 Some(period_value),
4039 )
4040 } else if margin_wants_cr(&per_axis_bs[axis])
4041 && requested_knot_placement.is_none()
4042 && requested_degrees[axis].is_none_or(|d| d == CR_MARGIN_DEGREE)
4043 && requested_penalty_orders[axis]
4044 .is_none_or(|m| m == CR_MARGIN_PENALTY_ORDER)
4045 && k_axis >= 3
4046 {
4047 // mgcv `te()`/`ti()` default cr margin: place exactly
4048 // `k_axis` Lancaster–Salkauskas value-knots at data
4049 // quantiles. The cr basis dimension equals the knot count,
4050 // so this reproduces the requested per-margin `k` directly.
4051 // A natural cubic regression spline needs at least 3 knots
4052 // (one interior); a `k_axis < 3` margin (e.g. a binary
4053 // tensor axis requesting a linear margin) falls through to
4054 // the B-spline branch below, exactly as before #1074 — mgcv
4055 // likewise does not build a `cr` margin below k=3. An
4056 // explicit `knot_placement=quantile` also falls through:
4057 // that option selects the generated B-spline knot strategy
4058 // represented by `Automatic { Quantile }`, whereas the cr
4059 // margin has already materialized its quantile value-knots.
4060 let cr_knots = crate::basis::select_cr_knots(ds.values.column(c), k_axis)
4061 .map_err(|e| e.to_string())?;
4062 (
4063 BSplineKnotSpec::NaturalCubicRegression { knots: cr_knots },
4064 OneDimensionalBoundary::Open,
4065 None,
4066 )
4067 } else {
4068 // `num_internal_knots = k - effective_degree - 1` is the
4069 // only count that realises the requested per-margin basis
4070 // size: a clamped degree-`d` B-spline with `m` internal
4071 // knots has exactly `m + d + 1` functions, and zero
4072 // internal knots (`k = degree + 1`, one polynomial piece)
4073 // is a valid margin. A legacy `.max(1)` floor on the
4074 // un-reduced path used to turn `k=4` into a five-function
4075 // cubic margin, so `k=4` and `k=5` built the same tensor.
4076 // `k_axis >= 2` and `effective_degree <= k_axis - 1`, so
4077 // this cannot underflow.
4078 let num_internal_knots = k_axis - effective_degree - 1;
4079 let knotspec = match requested_knot_placement
4080 .unwrap_or(crate::basis::BSplineKnotPlacement::Uniform)
4081 {
4082 crate::basis::BSplineKnotPlacement::Uniform => BSplineKnotSpec::Generate {
4083 data_range: (data_min, data_max),
4084 num_internal_knots,
4085 },
4086 crate::basis::BSplineKnotPlacement::Quantile => {
4087 crate::basis::auto_knot_vector_1d_quantile(
4088 ds.values.column(c),
4089 num_internal_knots,
4090 effective_degree,
4091 )
4092 .map_err(|e| e.to_string())?;
4093 BSplineKnotSpec::Automatic {
4094 num_internal_knots: Some(num_internal_knots),
4095 placement: crate::basis::BSplineKnotPlacement::Quantile,
4096 }
4097 }
4098 };
4099 (knotspec, OneDimensionalBoundary::Open, None)
4100 };
4101 // Margins contribute only their roughness operators. The tensor
4102 // builder constructs exactly one joint function-space null
4103 // penalty, avoiding unused per-margin ridge candidates and
4104 // duplicate λ coordinates.
4105 margins.push(BSplineBasisSpec {
4106 degree: effective_degree,
4107 penalty_order: effective_penalty_order,
4108 knotspec,
4109 double_penalty: false,
4110 identifiability: BSplineIdentifiability::None,
4111 boundary,
4112 boundary_conditions: BSplineBoundaryConditions::default(),
4113 });
4114 emitted_periods.push(axis_period);
4115 }
4116 // #1593: canonicalize the margin order so a tensor smooth is invariant
4117 // to the typed order of its covariates. `te(x, z)` and `te(z, x)` span
4118 // the IDENTICAL tensor-product space under the identical per-margin
4119 // penalty family, but the design is the Khatri–Rao product
4120 // `B_first ⊙ B_second`, so the typed order permutes the design columns
4121 // (and the per-margin penalty blocks `S_first⊗I`, `I⊗S_second`). That
4122 // permutation is a pure relabelling in exact arithmetic — REML is
4123 // invariant to it — yet it reorders the penalized normal-equation / REML
4124 // eigen/Cholesky linear algebra, and the resulting sub-ULP differences
4125 // route the outer λ optimizer to a different terminal point in te's flat
4126 // REML valley (the over-smoothed margin rails to the ρ bound while the
4127 // other lands on a materially different λ̂). So the shipped surface
4128 // drifted ~2–6 % of range with a cosmetic swap of the covariate order
4129 // (the #1378 row-permutation / #1456 rotation flat-valley gauge family).
4130 // Sorting the margins by their source feature-column index makes the same
4131 // physical model build the identical problem regardless of typed order,
4132 // so the fit — and every prediction rebuilt from the resolved spec — is
4133 // genuinely order-invariant. `ti`/`t2` share this arm and become exactly
4134 // invariant too (they were already ~1e-5 by centring each margin
4135 // separately; canonicalization makes the swap bit-identical).
4136 let canon_cols: Vec<usize> = {
4137 let mut perm: Vec<usize> = (0..dim).collect();
4138 perm.sort_by_key(|&a| cols[a]);
4139 if perm.iter().enumerate().any(|(i, &a)| i != a) {
4140 margins = perm.iter().map(|&a| margins[a].clone()).collect();
4141 emitted_periods = perm.iter().map(|&a| emitted_periods[a]).collect();
4142 }
4143 perm.iter().map(|&a| cols[a]).collect()
4144 };
4145 let any_periodic = emitted_periods.iter().any(|p| p.is_some());
4146 let periods_vec = if any_periodic {
4147 emitted_periods
4148 } else {
4149 Vec::new()
4150 };
4151 // The tensor's joint polynomial null space is independently
4152 // shrinkable by default, so REML can recover an unsupported surface
4153 // as zero. Explicit `double_penalty=false` remains the MLE opt-out.
4154 let tensor_double_penalty = smooth_double_penalty;
4155 Ok(SmoothBasisSpec::TensorBSpline {
4156 feature_cols: canon_cols,
4157 spec: TensorBSplineSpec {
4158 marginalspecs: margins,
4159 periods: periods_vec,
4160 double_penalty: tensor_double_penalty,
4161 identifiability: parse_tensor_identifiability(options, kind)?,
4162 // `t2` selects mgcv's separable (Wood, Scheipl & Faraway
4163 // 2013) decomposition. It can arrive either as the `t2(...)`
4164 // function form (`SmoothKind::T2`) or as a `type="t2"` /
4165 // `bs="t2"` option on an `s(...)`/`te(...)` term, in which
4166 // case `kind` is *not* `T2` but the resolved type string is
4167 // "t2". Keying only off `kind` silently aliased the option
4168 // form to `te`'s Kronecker-sum penalty (gam#1185); key off
4169 // the resolved type string as well so both routes build the
4170 // separable penalty.
4171 penalty_decomposition: if matches!(kind, SmoothKind::T2)
4172 || type_opt.as_str() == "t2"
4173 {
4174 TensorBSplinePenaltyDecomposition::Separable
4175 } else {
4176 TensorBSplinePenaltyDecomposition::MarginalKroneckerSum
4177 },
4178 },
4179 })
4180 }
4181 "pca" => {
4182 validate_known_options("pca", options, PCA_SMOOTH_OPTION_KEYS)?;
4183 let path = options
4184 .get("lazy_path")
4185 .or_else(|| options.get("pca_basis_path"))
4186 .or_else(|| options.get("path"))
4187 .map(|raw| PathBuf::from(strip_quotes(raw)));
4188 let Some(path) = path else {
4189 return Err(TermBuilderError::incompatible_config(
4190 "pca smooth requires lazy_path=... on the formula path",
4191 )
4192 .to_string());
4193 };
4194 let k = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
4195 .unwrap_or(0);
4196 let chunk_size = option_usize(options, "chunk_size").unwrap_or(DEFAULT_PCA_CHUNK_SIZE);
4197 Ok(SmoothBasisSpec::Pca {
4198 feature_cols: cols.to_vec(),
4199 basis_matrix: Array2::<f64>::zeros((cols.len(), k)),
4200 centered: option_bool(options, "centered").unwrap_or(true),
4201 smooth_penalty: option_f64(options, "smooth_penalty").unwrap_or(1.0),
4202 center_mean: None,
4203 pca_basis_path: Some(path),
4204 chunk_size,
4205 })
4206 }
4207 other => Err(TermBuilderError::unsupported_feature(format!(
4208 "unsupported smooth type '{other}'"
4209 ))
4210 .to_string()),
4211 }
4212}
4213
4214/// Initialise per-axis anisotropic log-scales on eligible spatial smooth specs.
4215pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
4216 for smooth in spec.smooth_terms.iter_mut() {
4217 // A multi-axis thin-plate term cannot carry per-axis anisotropy on its
4218 // single curvature penalty, so `scale_dimensions` was historically a
4219 // silent no-op for `bs="tp"` (gam#1676). Rewrite it to the
4220 // mathematically-equivalent anisotropic s=0 Duchon spline first; the
4221 // Duchon arm below then sees an already-seeded `aniso_log_scales` and
4222 // leaves it untouched.
4223 promote_thin_plate_for_scale_dimensions(&mut smooth.basis);
4224 match &mut smooth.basis {
4225 SmoothBasisSpec::Matern {
4226 feature_cols,
4227 spec: matern,
4228 ..
4229 } => {
4230 if matern.aniso_log_scales.is_none() {
4231 let d = feature_cols.len();
4232 matern.aniso_log_scales = Some(vec![0.0; d]);
4233 }
4234 }
4235 SmoothBasisSpec::Duchon {
4236 feature_cols,
4237 spec: duchon,
4238 ..
4239 } => {
4240 if duchon.aniso_log_scales.is_none() {
4241 let d = feature_cols.len();
4242 duchon.aniso_log_scales = Some(vec![0.0; d]);
4243 }
4244 }
4245 // Bases with no per-axis length-scale vector to seed: either
4246 // single-axis, factor-indexed, or (tensor) already anisotropic by
4247 // construction through their marginals. Enumerated rather than
4248 // wildcarded so a new basis kind has to answer this question.
4249 SmoothBasisSpec::ByVariable { .. }
4250 | SmoothBasisSpec::FactorSumToZero { .. }
4251 | SmoothBasisSpec::BSpline1D { .. }
4252 | SmoothBasisSpec::BySmooth { .. }
4253 | SmoothBasisSpec::FactorSmooth { .. }
4254 | SmoothBasisSpec::ThinPlate { .. }
4255 | SmoothBasisSpec::Sphere { .. }
4256 | SmoothBasisSpec::ConstantCurvature { .. }
4257 | SmoothBasisSpec::MeasureJet { .. }
4258 | SmoothBasisSpec::Pca { .. }
4259 | SmoothBasisSpec::TensorBSpline { .. } => {}
4260 }
4261 }
4262}
4263
4264/// Rewrite a multi-axis thin-plate term into the mathematically-equivalent
4265/// anisotropic s=0 Duchon spline so that `scale_dimensions` genuinely engages
4266/// (gam#1676).
4267///
4268/// ## Why a rewrite rather than a new field on the TPS builder
4269///
4270/// A canonical thin-plate regression spline carries a *single* curvature
4271/// penalty — the exact `∫|Dᵐ f|²` reproducing-kernel Gram. That penalty has no
4272/// per-axis structure to make one direction more or less relevant than another,
4273/// so per-axis anisotropy (`scale_dimensions`) cannot be expressed on it. The
4274/// flag was therefore a silent no-op for `bs="tp"` while it engaged for
4275/// `duchon()`/`matern()`.
4276///
4277/// The thin-plate kernel `r^{2m−d}` (the `r²·log r` log-case in even `d`) is
4278/// *exactly* the s=0 Duchon kernel (`DuchonBasisSpec::power = 0`,
4279/// `length_scale = None`) at the matching polynomial null-space order
4280/// `m = thin_plate_penalty_order(d)`. The Duchon polyharmonic family already
4281/// carries the per-axis tension ARD that `scale_dimensions` requests: its
4282/// isotropic first-order roughness penalty `Σ‖∇f‖²` splits into `d` directional
4283/// penalties `Σ(∂f/∂x_a)²`, each with its own REML `λ_a`
4284/// (`duchon_operator_penalty_candidates`). So the well-posed *anisotropic
4285/// thin-plate spline is the anisotropic s=0 Duchon spline*. Rewriting to that
4286/// representation reuses the battle-tested Duchon anisotropy / ψ-derivative /
4287/// freeze / predict machinery instead of duplicating it onto the TPS metadata
4288/// path, and keeps the polyharmonic family internally consistent. The codebase
4289/// already promotes infeasible-`k` TPS to Duchon for the same reason (the
4290/// canonical TPS single curvature penalty cannot deliver a requested
4291/// capability); per-axis anisotropy is another such capability.
4292///
4293/// This fires *only* when the user opts into `scale_dimensions`; the default
4294/// thin-plate path (`scale_dimensions` off) is left bit-for-bit unchanged.
4295/// A 1-D thin-plate term is left untouched — anisotropy is meaningless on a
4296/// single axis (its `Σ η = 0` contrast vector is empty), exactly as for a 1-D
4297/// Matérn/Duchon term.
4298fn promote_thin_plate_for_scale_dimensions(basis: &mut SmoothBasisSpec) {
4299 let SmoothBasisSpec::ThinPlate {
4300 feature_cols,
4301 spec,
4302 input_scale,
4303 } = &*basis
4304 else {
4305 return;
4306 };
4307 let d = feature_cols.len();
4308 if d <= 1 {
4309 return;
4310 }
4311 // m = thin_plate_penalty_order(d) is the TPS penalty order; the Duchon
4312 // null-space order naming is `Zero → m=1`, `Linear → m=2`,
4313 // `Degree(g) → m=g+1`, so the s=0 Duchon kernel exponent
4314 // `2(p+s) − d = 2m − d` reproduces the TPS kernel exactly.
4315 let m = thin_plate_penalty_order(d);
4316 let nullspace_order = match m {
4317 0 | 1 => DuchonNullspaceOrder::Zero,
4318 2 => DuchonNullspaceOrder::Linear,
4319 _ => DuchonNullspaceOrder::Degree(m - 1),
4320 };
4321 let duchon_spec = DuchonBasisSpec {
4322 center_strategy: spec.center_strategy.clone(),
4323 periodic: spec.periodic.clone(),
4324 // Pure, scale-free Duchon — the thin-plate kernel has no length scale
4325 // (a global TPS kernel scale is non-identifiable once REML learns the
4326 // smoothing penalty: gam#718/#721/#731/#732). The per-axis relevance
4327 // the user asked for is carried by the tension-ARD `λ_a`, not a κ axis.
4328 length_scale: None,
4329 // s = 0 ⇒ thin-plate kernel `r^{2m−d}`.
4330 power: 0.0,
4331 nullspace_order,
4332 identifiability: spec.identifiability.clone(),
4333 // All-zero geometry seed sentinel: `auto_seed_aniso_contrasts` resolves
4334 // it from the (standardized) knot cloud, and the per-axis tension split
4335 // engages on `aniso.is_some()`.
4336 aniso_log_scales: Some(vec![0.0; d]),
4337 operator_penalties: DuchonOperatorPenaltySpec::default(),
4338 boundary: OneDimensionalBoundary::Open,
4339 radial_reparam: None,
4340 };
4341 let feature_cols = feature_cols.clone();
4342 let input_scale = *input_scale;
4343 // All borrows of `*basis` (the `&*basis` destructure above) end with the
4344 // clones on the two preceding lines, so the reassignment is sound.
4345 *basis = SmoothBasisSpec::Duchon {
4346 feature_cols,
4347 spec: duchon_spec,
4348 input_scale,
4349 };
4350}
4351
4352// ---------------------------------------------------------------------------
4353// Data-aware helpers
4354// ---------------------------------------------------------------------------
4355
4356pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
4357 if d <= 3 {
4358 // In low-dimensional spatial smooths, an explicit `k` is a resolution
4359 // request rather than a request for marginal quantile-midpoint centers.
4360 // Use deterministic maximin geometry so Matérn/GP and Duchon REML see a
4361 // well-resolved native kernel block with small fill distance instead of
4362 // compensating for holes or endpoint under-resolution by over-smoothing
4363 // low-noise signals (#504).
4364 CenterStrategy::FarthestPoint { num_centers }
4365 } else {
4366 default_spatial_center_strategy(num_centers, d)
4367 }
4368}
4369
4370/// Center geometry for a non-periodic Duchon smooth.
4371///
4372/// In one dimension the represented domain is the interval between the observed
4373/// extrema. Equally spaced centers are the exact minimax design for that
4374/// interval: among all `k`-point center sets they minimize the largest uncovered
4375/// gap. Greedy farthest-point sampling instead produces a dyadic mesh whose
4376/// partially filled final level clusters centers and leaves wider holes whenever
4377/// `k` is not a power-of-two refinement. Those holes reduce the effective
4378/// resolution of an explicit `k` and caused the low-noise k=20 Duchon fit to miss
4379/// the mature-smoother accuracy bar despite having the same basis dimension.
4380///
4381/// Multidimensional Duchon terms keep the rotation-equivariant farthest-point /
4382/// equal-mass strategies, where there is no canonical coordinate-aligned grid.
4383/// The `Auto` wrapper is retained for inferred 1-D counts so adaptive resolution
4384/// can still resize the interval grid before freezing its realized centers.
4385fn duchon_center_strategy(num_centers: usize, d: usize, automatic: bool) -> CenterStrategy {
4386 let realized = if d == 1 {
4387 CenterStrategy::UniformGrid {
4388 points_per_dim: num_centers,
4389 }
4390 } else {
4391 spatial_center_strategy_for_dimension(num_centers, d)
4392 };
4393 if automatic {
4394 CenterStrategy::Auto(Box::new(realized))
4395 } else {
4396 realized
4397 }
4398}
4399
4400pub fn col_minmax(col: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
4401 let min = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
4402 let max = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
4403 if !min.is_finite() || !max.is_finite() {
4404 return Err(TermBuilderError::degenerate_data(
4405 "non-finite data encountered while inferring knot range",
4406 )
4407 .to_string());
4408 }
4409 if (max - min).abs() < 1e-12 {
4410 Ok((min, min + 1e-6))
4411 } else {
4412 Ok((min, max))
4413 }
4414}
4415
4416pub fn unique_count_column(col: ArrayView1<'_, f64>) -> usize {
4417 use std::collections::HashSet;
4418 let mut set = HashSet::<u64>::with_capacity(col.len());
4419 for &v in col {
4420 set.insert(gam_data::canonical_level_bits(v));
4421 }
4422 set.len().max(1)
4423}
4424
4425/// Minimum knot count for a natural cubic regression spline: `select_cr_knots`
4426/// places one value-knot per basis function and needs at least an interior knot,
4427/// so the sparsest representable cr basis is `{const, linear, curvature}` at
4428/// three knots. Below this a cr spline is not constructible and the caller must
4429/// degrade to the linear B-spline marginal.
4430pub(crate) const CR_MIN_KNOTS: usize = 3;
4431
4432/// Build a cubic-regression marginal knot spec capped to the covariate's data
4433/// support, mgcv-style.
4434///
4435/// A `cr`/`cs`/`sz` marginal places exactly one basis function per value-knot,
4436/// so `select_cr_knots` cannot place more knots than the covariate has DISTINCT
4437/// values — it `bail`s with "cubic regression spline with k=N requires at least
4438/// N distinct values" otherwise. An unclamped `k` on an ordinary low-cardinality
4439/// covariate (a binary indicator, a 3-level ordinal/Likert score, a small count)
4440/// therefore hard-failed the whole fit instead of reducing the basis the way
4441/// mgcv — and gam's own tensor-margin path (996f829d7, `term_builder.rs:2986` /
4442/// the `k_axis >= 3` cr gate at `:3047`) — do. This is the univariate / factor-
4443/// smooth sibling of that tensor cap (#1541, #1542).
4444///
4445/// Returns:
4446/// - `Some(NaturalCubicRegression { .. })` with `k = min(k_requested, n_distinct)`
4447/// value-knots when the data supports a cr spline (`n_distinct >= CR_MIN_KNOTS`).
4448/// A cr basis of exactly `n_distinct` knots is full-rank for the data — it can
4449/// represent any per-distinct-value structure (e.g. 3 arbitrary group means on
4450/// a ternary covariate) — so the cap never costs recoverable signal.
4451/// - `None` when `n_distinct < CR_MIN_KNOTS` (a binary covariate): too few
4452/// distinct values for ANY cr spline, so the caller degrades to the linear
4453/// B-spline marginal — exactly what the default `s(x, k=..)` basis already
4454/// builds on the same data, and what the tensor path's `< 3` branch builds.
4455///
4456/// `inference_notes` records any reduction so the user sees that `k` was capped
4457/// (mgcv emits a warning in the same situation).
4458fn capped_cr_marginal_knotspec(
4459 col: ArrayView1<'_, f64>,
4460 k_cr_requested: usize,
4461 label: &str,
4462 inference_notes: &mut Vec<String>,
4463) -> Result<Option<BSplineKnotSpec>, String> {
4464 let n_distinct = unique_count_column(col);
4465 let k_cr = k_cr_requested.min(n_distinct);
4466 if k_cr < CR_MIN_KNOTS {
4467 inference_notes.push(format!(
4468 "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis requested k={k_cr_requested}, \
4469 but the covariate has only {n_distinct} distinct value(s) — too few to support a cubic \
4470 regression spline (needs >= {CR_MIN_KNOTS} distinct values). Degraded to the linear \
4471 B-spline marginal the default basis builds on the same data."
4472 ));
4473 return Ok(None);
4474 }
4475 if k_cr < k_cr_requested {
4476 inference_notes.push(format!(
4477 "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis reduced from k={k_cr_requested} \
4478 to k={k_cr} to match the covariate's {n_distinct} distinct value(s) (mgcv-style \
4479 data-support cap; a cr basis cannot place more value-knots than the data has)."
4480 ));
4481 }
4482 let cr_knots = crate::basis::select_cr_knots(col, k_cr).map_err(|e| e.to_string())?;
4483 Ok(Some(BSplineKnotSpec::NaturalCubicRegression {
4484 knots: cr_knots,
4485 }))
4486}
4487
4488/// Smallest number of distinct covariate values seen within any single group
4489/// of `group_col`. For a factor smooth this is the resolution that bounds the
4490/// marginal basis: a group with `m` distinct covariate values can only inform
4491/// `m` basis coefficients, so a marginal richer than that interpolates the
4492/// group instead of estimating a penalized trend. Bits are compared exactly so
4493/// integer-valued covariates (days, dose levels) collapse to their true count.
4494fn min_per_group_unique_count(
4495 feature_col: ArrayView1<'_, f64>,
4496 group_col: ArrayView1<'_, f64>,
4497) -> usize {
4498 use std::collections::{HashMap, HashSet};
4499 let mut per_group: HashMap<u64, HashSet<u64>> = HashMap::new();
4500 for (xi, gi) in feature_col.iter().zip(group_col.iter()) {
4501 per_group
4502 .entry(gam_data::canonical_level_bits(*gi))
4503 .or_default()
4504 .insert(gam_data::canonical_level_bits(*xi));
4505 }
4506 per_group
4507 .values()
4508 .map(|s| s.len())
4509 .min()
4510 .unwrap_or(1)
4511 .max(1)
4512}
4513
4514/// Cap on the automatically inferred internal-knot count of a 1-D smooth.
4515/// Default cubic basis ≈ `MAX_DEFAULT_INTERNAL_KNOTS + degree + 1` = 12
4516/// functions, matching mgcv's lean univariate default. Named at module level
4517/// so the inference note reports the ceiling the engine applies rather than
4518/// one of its own.
4519pub(crate) const MAX_DEFAULT_INTERNAL_KNOTS: usize = 8;
4520
4521/// Default internal-knot count for an *additive* univariate smooth, derived
4522/// from the column's unique-value count.
4523///
4524/// The basis dimension is `internal_knots + degree + 1`, so the cap below maps
4525/// to a default cubic basis of ~12 functions — deliberately close to mgcv's
4526/// univariate default (`k = 10`). A penalized smooth controls its wiggliness
4527/// through the *penalty*, not the basis size: REML/LAML shrinks a too-rich
4528/// basis toward the null, but it cannot do so cleanly when the basis is so
4529/// over-sized that the design becomes weakly identified. Growing the basis with
4530/// `n` (the old `n^(1/3)`-ceilinged `unique/4` rule, which pinned to 20 internal
4531/// knots ⇒ a 24-function basis for any column with ≥80 unique values) therefore
4532/// *hurts* recovery on finite, weak-signal fits: a 4-smooth additive model on
4533/// n=120 asks for ~92 coefficients, the outer optimizer stalls on the resulting
4534/// flat two-penalty (range + null-space) REML surface, and the truth leaks into
4535/// surplus columns the penalty can't shrink away (gam#1680; the same defect was
4536/// documented for thin-plate fields in gam#1074). A k-sweep on the #1680 design
4537/// confirms a basis of ~10–15 recovers truth at RMSE ≈ 0.12 while the old
4538/// 24-function default lands at ≈ 0.39 (~3× worse) — *whether or not* the
4539/// covariates are collinear, so this is basis over-richness, not collinearity.
4540///
4541/// The cap is flat in `n`: a user who genuinely needs a wigglier fit raises `k`
4542/// explicitly (mgcv's contract — opt *in* to more flexibility), and the SPEC
4543/// requires the default to allow recovering the null rather than forcing the
4544/// user to opt out of overfitting. The 4-knot floor stays put because we still
4545/// need enough basis functions to fit a non-trivial smooth at all, and the
4546/// `unique/4` growth below the cap keeps small/sparse columns (n ≤ 32, where
4547/// `unique/4 ≤ 8`) on exactly their previous knot count.
4548pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
4549 let unique = unique_count_column(col);
4550 (unique / 4).clamp(4, MAX_DEFAULT_INTERNAL_KNOTS)
4551}
4552
4553/// Per-margin basis sizes for a tensor-product smooth (`te`/`ti`/`t2`).
4554///
4555/// The 1-D heuristic [`heuristic_knots_for_column`] is calibrated for an
4556/// *additive* margin: a well-resolved column asks for the lean univariate
4557/// default (≈12 basis functions, the mgcv-like cap of 8 internal knots; see
4558/// gam#1680), which is sensible for a single `s(x)` term.
4559/// A tensor product, however, multiplies the per-margin sizes:
4560/// `p = ∏_d k_d`. Reusing the 1-D rule per margin makes `p` explode with the
4561/// tensor dimension — a 3-D `te(x,y,z)` at the 1-D ceiling of 12/margin is
4562/// `12³ ≈ 1728` columns, and every REML evaluation pays an O(p³) dense
4563/// penalty reparameterization (the full-tensor sum-to-zero constraint is not
4564/// Kronecker-factorable), turning model selection over tensor candidates into
4565/// a multi-minute single-threaded stall (gam#813). It also requests far more
4566/// coefficients than the data can identify whenever `p ≫ n`.
4567///
4568/// mgcv's `te(...)` uses a small per-margin default (`k = 5`, i.e. `5^d`).
4569/// We match that spirit while staying data-adaptive: budget the *total* tensor
4570/// column count `p_target` and distribute it geometrically across the margins
4571/// so `∏ k_d ≈ p_target`, never asking a margin for more functions than its
4572/// own unique values (and the data set) can support.
4573fn heuristic_tensor_margin_knots(cols: &[usize], ds: &Dataset) -> Vec<usize> {
4574 let d = cols.len().max(1);
4575 let degree = DEFAULT_BSPLINE_DEGREE;
4576 let min_k = degree + 2; // smallest margin that carries a difference penalty
4577 let n = ds.values.nrows();
4578
4579 // Per-margin 1-D ceiling: never request more basis functions than the
4580 // margin's own resolution (unique values) supports. This caps each axis
4581 // independently before the joint budget is applied.
4582 let per_margin_cap: Vec<usize> = cols
4583 .iter()
4584 .map(|&c| heuristic_knots_for_column(ds.values.column(c)).max(min_k))
4585 .collect();
4586
4587 // Total-basis budget. A tensor with ∏k ≫ n coefficients is rank-deficient
4588 // and pure REML cost; cap the product at a generous fraction of n while
4589 // honoring mgcv's small default for the common small-d case. The budget
4590 // grows with n but the geometric split below keeps each margin modest.
4591 // d=2 → up to ~7²=49 (mgcv-`te`-like), d=3 → ~5³=125, larger d shrinks
4592 // per-margin further so the product never blows past the data support.
4593 let mgcv_like_per_margin = match d {
4594 2 => 7usize,
4595 3 => 5usize,
4596 _ => 4usize,
4597 };
4598 let mgcv_like_total = (mgcv_like_per_margin as f64).powi(d as i32);
4599 let data_budget = (n as f64) * 0.8;
4600 let p_target = mgcv_like_total
4601 .max(min_k.pow(d as u32) as f64)
4602 .min(data_budget);
4603
4604 // Geometric per-margin target so ∏k ≈ p_target, then clamp each margin to
4605 // its own 1-D resolution cap and the difference-penalty floor.
4606 let geo_per_margin = p_target.powf(1.0 / d as f64).round() as usize;
4607 let unclamped: Vec<usize> = per_margin_cap
4608 .iter()
4609 .map(|&cap| geo_per_margin.clamp(min_k, cap))
4610 .collect();
4611
4612 // The per-margin clamps can pull some axes below `geo_per_margin` (a
4613 // low-resolution column), leaving headroom in the joint budget. Redistribute
4614 // that headroom to the margins that can still grow, so the realized ∏k stays
4615 // close to p_target instead of systematically under-shooting it.
4616 let mut k_list = unclamped;
4617 loop {
4618 let product: f64 = k_list.iter().map(|&k| k as f64).product();
4619 if product >= p_target {
4620 break;
4621 }
4622 // Grow the axis with the most remaining headroom (cap − current),
4623 // breaking ties toward the largest cap. Stop when none can grow.
4624 let Some(idx) = k_list
4625 .iter()
4626 .zip(per_margin_cap.iter())
4627 .enumerate()
4628 .filter(|&(_, (k, cap))| k < cap)
4629 .max_by_key(|&(_, (k, cap))| (cap - k, *cap))
4630 .map(|(i, _)| i)
4631 else {
4632 break;
4633 };
4634 k_list[idx] += 1;
4635 }
4636 k_list
4637}
4638
4639// ---------------------------------------------------------------------------
4640// Smooth option parsers
4641// ---------------------------------------------------------------------------
4642
4643fn parse_endpoint_side(
4644 value: &str,
4645 context: &str,
4646) -> Result<BSplineEndpointBoundaryCondition, String> {
4647 match value.trim().to_ascii_lowercase().as_str() {
4648 "" | "none" | "open" | "unconstrained" | "free" => {
4649 Ok(BSplineEndpointBoundaryCondition::Free)
4650 }
4651 "clamped" | "clamp" | "zero_derivative" | "zero-derivative" => {
4652 Ok(BSplineEndpointBoundaryCondition::Clamped)
4653 }
4654 "anchored" | "anchor" | "zero" | "zero_value" | "zero-value" => {
4655 Ok(BSplineEndpointBoundaryCondition::Anchored { value: 0.0 })
4656 }
4657 other => Err(format!(
4658 "unsupported {context} boundary condition '{other}'; expected free, clamped, or anchored"
4659 )),
4660 }
4661}
4662
4663fn boundary_anchor_value(
4664 options: &BTreeMap<String, String>,
4665 side: &str,
4666 fallback: Option<f64>,
4667) -> Option<f64> {
4668 [
4669 format!("anchor_{side}"),
4670 format!("{side}_anchor"),
4671 format!("anchor-value-{side}"),
4672 ]
4673 .iter()
4674 .find_map(|key| option_f64(options, key))
4675 .or(fallback)
4676}
4677
4678fn apply_anchor_value(
4679 cond: BSplineEndpointBoundaryCondition,
4680 value: Option<f64>,
4681) -> BSplineEndpointBoundaryCondition {
4682 match cond {
4683 BSplineEndpointBoundaryCondition::Anchored { .. } => {
4684 BSplineEndpointBoundaryCondition::Anchored {
4685 value: value.unwrap_or(0.0),
4686 }
4687 }
4688 other => other,
4689 }
4690}
4691
4692fn parse_bspline_boundary_conditions(
4693 options: &BTreeMap<String, String>,
4694) -> Result<BSplineBoundaryConditions, String> {
4695 let fallback_anchor = option_f64(options, "anchor")
4696 .or_else(|| option_f64(options, "anchor_value"))
4697 .or_else(|| option_f64(options, "value"));
4698 // `boundary` is whitelisted on this arm as the third spelling of `bc` /
4699 // `boundary_conditions` and was read by NEITHER of the two functions that
4700 // consume the option (`parse_periodic_axes` reads it, but only for the
4701 // periodic tokens), so `s(x, boundary=clamped)` was accepted and inert
4702 // (#2781's family). A periodic token never reaches here: the arm skips this
4703 // function entirely once `bspline_boundary_declares_periodic_axis` fires.
4704 let global_boundary_conditions = options
4705 .get("boundary_conditions")
4706 .or_else(|| options.get("bc"))
4707 .or_else(|| options.get("boundary"));
4708 let mut boundary_conditions = BSplineBoundaryConditions::default();
4709
4710 if let Some(raw_boundary_conditions) = global_boundary_conditions {
4711 let cond = parse_endpoint_side(raw_boundary_conditions, "boundary_conditions")?;
4712 let side = options
4713 .get("side")
4714 .map(|s| s.trim().to_ascii_lowercase())
4715 .unwrap_or_else(|| "both".to_string());
4716 match side.as_str() {
4717 "both" | "all" | "endpoints" => {
4718 boundary_conditions.left = cond;
4719 boundary_conditions.right = cond;
4720 }
4721 "left" | "start" | "lower" => boundary_conditions.left = cond,
4722 "right" | "end" | "upper" => boundary_conditions.right = cond,
4723 other => {
4724 return Err(format!(
4725 "unsupported B-spline boundary side '{other}'; expected left, right, or both"
4726 ));
4727 }
4728 }
4729 }
4730
4731 if let Some(raw) = options
4732 .get("bc_left")
4733 .or_else(|| options.get("left_bc"))
4734 .or_else(|| options.get("bc_start"))
4735 .or_else(|| options.get("start_bc"))
4736 {
4737 boundary_conditions.left = parse_endpoint_side(raw, "left endpoint")?;
4738 }
4739 if let Some(raw) = options
4740 .get("bc_right")
4741 .or_else(|| options.get("right_bc"))
4742 .or_else(|| options.get("bc_end"))
4743 .or_else(|| options.get("end_bc"))
4744 {
4745 boundary_conditions.right = parse_endpoint_side(raw, "right endpoint")?;
4746 }
4747
4748 boundary_conditions.left = apply_anchor_value(
4749 boundary_conditions.left,
4750 boundary_anchor_value(options, "left", fallback_anchor),
4751 );
4752 boundary_conditions.right = apply_anchor_value(
4753 boundary_conditions.right,
4754 boundary_anchor_value(options, "right", fallback_anchor),
4755 );
4756
4757 // `side=` says WHICH endpoint the global condition applies to, and an
4758 // anchor value says WHAT an anchored endpoint is pinned to. Neither means
4759 // anything on its own, and both were previously accepted and discarded, so
4760 // `s(x, bc_left=anchored, anchor=2.5)` pinned the endpoint at 2.5 while
4761 // `s(x, anchor=2.5)` silently pinned nothing at all (#2781's family).
4762 if options.contains_key("side") && global_boundary_conditions.is_none() {
4763 return Err(TermBuilderError::invalid_option(
4764 "`side=` selects which endpoint a boundary condition applies to, but this smooth declares none; add bc=<condition> or drop it",
4765 )
4766 .to_string());
4767 }
4768 if !boundary_conditions.has_anchor()
4769 && let Some(key) = ANCHOR_VALUE_OPTION_KEYS
4770 .iter()
4771 .find(|key| options.contains_key(**key))
4772 {
4773 return Err(TermBuilderError::invalid_option(format!(
4774 "`{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"
4775 ))
4776 .to_string());
4777 }
4778
4779 Ok(boundary_conditions)
4780}
4781
4782/// Option keys that carry the value an anchored endpoint is pinned to. Each is
4783/// meaningless without an `anchored` endpoint to attach it to.
4784const ANCHOR_VALUE_OPTION_KEYS: [&str; 8] = [
4785 "anchor",
4786 "anchor_value",
4787 "value",
4788 "anchor_left",
4789 "left_anchor",
4790 "anchor_right",
4791 "right_anchor",
4792 "anchor-value-left",
4793];
4794
4795/// Resolve the requested internal-knot count and effective spline degree for
4796/// a 1-D penalized B-spline smooth. This mirrors the tensor-margin per-axis
4797/// degree-reduction policy: a 1-D B-spline basis with `k` functions
4798/// is well-defined for any `degree <= k - 1`, so an explicit
4799/// `s(x, bs="ps", k=3)` with default `degree=3` is interpreted as the
4800/// largest representable spline (`effective_degree = k - 1 = 2`, quadratic)
4801/// rather than rejected. The `penalty_order` carried by the caller must be
4802/// clamped to `<= effective_degree` so the marginal roughness penalty
4803/// stays well-defined; the returned `effective_degree` makes that explicit.
4804///
4805/// An explicit `k` is honoured EXACTLY: a clamped degree-`d` B-spline with
4806/// `m` internal knots has `m + d + 1` functions, so the returned count is
4807/// `k - effective_degree - 1`, zero included. `s(x, k=4)` therefore names the
4808/// same four-function cubic as `s(x, knots=0)` — the documented identity
4809/// `k = internal_knots + degree + 1` (docs/formulas.md) holds for every `k`.
4810///
4811/// Mirrors the tensor margin treatment in the `te(...)` builder so a
4812/// standalone smooth, a factor smooth, and a tensor margin all interpret
4813/// "small k" the same way.
4814fn parse_ps_internal_knots(
4815 options: &BTreeMap<String, String>,
4816 degree: usize,
4817 default_internal_knots: usize,
4818) -> Result<(usize, bool, usize), String> {
4819 // Strict variants: reject `k=-1`, `k=1.5`, `knots=-2` etc. with a
4820 // focused error instead of silently dropping the value and using the
4821 // default. Lenient `option_usize` / `option_usize_any` silently swallow
4822 // unparseable values, which leaves the user thinking they configured
4823 // something when they did not.
4824 // A list-valued `knots=[...]` carries explicit internal positions, not a
4825 // count; it is consumed by `parse_explicit_internal_knots`. Treat it as
4826 // "count not specified" here so the strict integer parse does not reject
4827 // the bracketed value (the Provided path ignores the returned count).
4828 let knots_internal = if knots_option_is_list(options) {
4829 None
4830 } else {
4831 option_usize_strict(options, "knots")?
4832 };
4833 let basis_dim = option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?;
4834 if knots_internal.is_some() && basis_dim.is_some() {
4835 return Err(TermBuilderError::incompatible_config(
4836 "ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)",
4837 )
4838 .to_string());
4839 }
4840 if let Some(k) = basis_dim {
4841 if k < 2 {
4842 return Err(TermBuilderError::invalid_option(format!(
4843 "ps/bspline smooth: k={} too small; B-spline basis requires k >= 2",
4844 k
4845 ))
4846 .to_string());
4847 }
4848 // `degree <= k - 1` is required for the B-spline basis to be
4849 // well-defined; reduce on this axis only when the user asked for
4850 // a smaller k than the cubic default supports. This matches mgcv's
4851 // behaviour (e.g. `s(x, bs="ps", k=3)` becomes a quadratic basis)
4852 // and the per-axis reduction the tensor builder already does.
4853 let effective_degree = degree.min(k - 1).max(1);
4854 // `k >= 2` and `effective_degree <= k - 1`, so this cannot underflow.
4855 // A floor of two internal knots used to sit on the un-reduced branch
4856 // and silently turned `k=4` and `k=5` into the six-function cubic, so
4857 // three requested dimensions collapsed onto one bit-identical fit
4858 // while the `knots=` spelling of the same bases was honoured exactly.
4859 // Zero internal knots is a valid basis (a single polynomial piece).
4860 let num_internal_knots = k - effective_degree - 1;
4861 Ok((num_internal_knots, false, effective_degree))
4862 } else {
4863 Ok((
4864 knots_internal.unwrap_or(default_internal_knots),
4865 knots_internal.is_none(),
4866 degree,
4867 ))
4868 }
4869}
4870
4871/// True when the `knots` option value is a *list* literal (`[...]`, `c(...)`,
4872/// or `(...)`) rather than a scalar count. mgcv's `knots=` accepts both: a
4873/// single integer is an internal-knot count, while a vector is explicit
4874/// internal knot positions. We disambiguate purely on the wrapper syntax so a
4875/// bare `knots=5` keeps its historical count meaning.
4876fn knots_option_is_list(options: &BTreeMap<String, String>) -> bool {
4877 options
4878 .get("knots")
4879 .map(|raw| {
4880 let t = raw.trim();
4881 t.starts_with('[') || t.starts_with("c(") || t.starts_with("C(") || t.starts_with('(')
4882 })
4883 .unwrap_or(false)
4884}
4885
4886/// Parse `knots=[k0, k1, ...]` (or `c(...)` / `(...)`) into explicit internal
4887/// knot positions. Returns `Ok(None)` when `knots` is absent or a scalar count
4888/// (handled by [`parse_ps_internal_knots`]); `Ok(Some(positions))` when it is a
4889/// non-empty numeric list; and an error for an empty or unparseable list.
4890fn parse_explicit_internal_knots(
4891 options: &BTreeMap<String, String>,
4892) -> Result<Option<Vec<f64>>, String> {
4893 if !knots_option_is_list(options) {
4894 return Ok(None);
4895 }
4896 let raw = options
4897 .get("knots")
4898 .expect("knots_option_is_list implies the key is present");
4899 let tokens = split_list_option(raw);
4900 if tokens.is_empty() {
4901 return Err(TermBuilderError::invalid_option(format!(
4902 "knots={raw} is an empty list; supply at least one internal knot position \
4903 (e.g. knots=[0.2, 0.5, 0.8]) or a scalar count (e.g. knots=8)"
4904 ))
4905 .to_string());
4906 }
4907 let mut positions = Vec::with_capacity(tokens.len());
4908 for tok in &tokens {
4909 let value = parse_numeric_expr(tok).map_err(|err| {
4910 TermBuilderError::invalid_option(format!(
4911 "knots list entry '{tok}' is not a numeric position: {err}"
4912 ))
4913 .to_string()
4914 })?;
4915 positions.push(value);
4916 }
4917 Ok(Some(positions))
4918}
4919
4920/// Resolve the `knot_placement=` option for an automatically generated knot
4921/// vector. Accepts `"uniform"` (the default, equal spacing on the data range)
4922/// and `"quantile"` (interior knots at empirical data quantiles, better for
4923/// skewed covariates). Unknown values are rejected so typos do not silently
4924/// fall back to uniform.
4925/// Parse a per-margin unsigned-integer tensor option (`degree=`,
4926/// `penalty_order=`).
4927///
4928/// Accepts the scalar form (`degree=2`), which broadcasts to every margin as
4929/// `docs/formulas.md` promises ("Margins requested as a single value are
4930/// broadcast across all margins"), and the per-margin list form
4931/// (`degree=[1, 3]`, `degree=c(1, 3)`), with `none` selecting the default on
4932/// that margin. Returns `None` per axis when the caller said nothing, so the
4933/// margin loop can tell "asked for the default" apart from "asked for a value
4934/// that happens to equal the default" — a distinction the cr-margin routing
4935/// below depends on.
4936///
4937/// Before #2782 both options were read with `option_usize`, which parses only a
4938/// bare integer: a list form silently fell back to the default, so
4939/// `te(x, z, degree=[1,3])` was bit-identical to `te(x, z)`.
4940fn parse_tensor_per_axis_usize(
4941 options: &BTreeMap<String, String>,
4942 key: &str,
4943 dim: usize,
4944) -> Result<Vec<Option<usize>>, String> {
4945 let Some(raw) = options.get(key) else {
4946 return Ok(vec![None; dim]);
4947 };
4948 let values = split_list_option(raw);
4949 let parse_one = |value: &str| -> Result<Option<usize>, String> {
4950 let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
4951 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
4952 return Ok(None);
4953 }
4954 trimmed.parse::<usize>().map(Some).map_err(|err| {
4955 TermBuilderError::invalid_option(format!(
4956 "tensor smooth `{key}={raw}`: '{trimmed}' is not a non-negative integer ({err})"
4957 ))
4958 .to_string()
4959 })
4960 };
4961 if values.len() == 1 {
4962 let shared = parse_one(&values[0])?;
4963 return Ok(vec![shared; dim]);
4964 }
4965 if values.len() != dim {
4966 return Err(TermBuilderError::invalid_option(format!(
4967 "tensor smooth `{key}={raw}` has {} entries but the smooth has {dim} margins; pass one \
4968 value per margin or a single value for all of them",
4969 values.len()
4970 ))
4971 .to_string());
4972 }
4973 values.iter().map(|value| parse_one(value)).collect()
4974}
4975
4976/// The polynomial degree of the natural cubic regression margin. It is not a
4977/// parameter of that basis — a "cubic regression spline" IS cubic — so a margin
4978/// that asks for any other degree cannot be realized as one.
4979const CR_MARGIN_DEGREE: usize = 3;
4980
4981/// The derivative order the natural cubic regression penalty integrates. Like
4982/// [`CR_MARGIN_DEGREE`], this is definitional rather than adjustable: the cr
4983/// penalty is the exact integrated squared SECOND derivative of the
4984/// interpolating cubic.
4985const CR_MARGIN_PENALTY_ORDER: usize = 2;
4986
4987fn parse_knot_placement(
4988 options: &BTreeMap<String, String>,
4989) -> Result<crate::basis::BSplineKnotPlacement, String> {
4990 use crate::basis::BSplineKnotPlacement;
4991 match options
4992 .get("knot_placement")
4993 .or_else(|| options.get("knot-placement"))
4994 .or_else(|| options.get("knotplacement"))
4995 {
4996 None => Ok(BSplineKnotPlacement::Uniform),
4997 Some(raw) => match raw
4998 .trim()
4999 .trim_matches('"')
5000 .trim_matches('\'')
5001 .to_ascii_lowercase()
5002 .as_str()
5003 {
5004 "uniform" | "even" | "equal" => Ok(BSplineKnotPlacement::Uniform),
5005 "quantile" | "quantiles" | "data" | "empirical" => Ok(BSplineKnotPlacement::Quantile),
5006 other => Err(TermBuilderError::invalid_option(format!(
5007 "knot_placement={other} is not recognised; expected \"uniform\" or \"quantile\""
5008 ))
5009 .to_string()),
5010 },
5011 }
5012}
5013
5014/// Like [`parse_knot_placement`] but distinguishes "unset" from an explicit
5015/// `knot_placement=uniform`.
5016///
5017/// The two are not the same request on a tensor margin: unset means "give me
5018/// mgcv's default margin", which is a natural cubic regression spline on
5019/// QUANTILE value-knots, while an explicit `uniform` asks for evenly spaced
5020/// knots — something the cr margin cannot do. Collapsing them made
5021/// `te(x, z, knot_placement='uniform')` a silent no-op that returned
5022/// quantile-placed knots (#2782).
5023fn explicit_knot_placement(
5024 options: &BTreeMap<String, String>,
5025) -> Result<Option<crate::basis::BSplineKnotPlacement>, String> {
5026 let declared = ["knot_placement", "knot-placement", "knotplacement"]
5027 .iter()
5028 .any(|key| options.contains_key(*key));
5029 if !declared {
5030 return Ok(None);
5031 }
5032 parse_knot_placement(options).map(Some)
5033}
5034
5035/// Build the non-periodic 1D B-spline knot spec for the `ps`/`bspline` and
5036/// factor-smooth marginal paths, honoring (in priority order):
5037/// 1. `knots=[...]` explicit internal positions → [`BSplineKnotSpec::Provided`]
5038/// 2. `knot_placement="quantile"` → [`BSplineKnotSpec::Automatic`]
5039/// 3. uniform generation → [`BSplineKnotSpec::Generate`]
5040///
5041/// `data` is the covariate column (used to clamp explicit positions to the
5042/// observed range and to drive quantile placement); `n_knots` is the resolved
5043/// internal-knot count from [`parse_ps_internal_knots`] used for the automatic
5044/// strategies.
5045fn resolve_nonperiodic_bspline_knotspec(
5046 options: &BTreeMap<String, String>,
5047 data: ArrayView1<'_, f64>,
5048 data_range: (f64, f64),
5049 degree: usize,
5050 n_knots: usize,
5051) -> Result<BSplineKnotSpec, String> {
5052 use crate::basis::{BSplineKnotPlacement, clamped_knot_vector_from_internal_positions};
5053 if let Some(positions) = parse_explicit_internal_knots(options)? {
5054 if option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?.is_some()
5055 {
5056 return Err(TermBuilderError::incompatible_config(
5057 "ps/bspline smooth: specify either explicit knots=[...] positions or \
5058 k=<basis_dim> (not both); the basis size is fixed by the knot vector",
5059 )
5060 .to_string());
5061 }
5062 let knots = clamped_knot_vector_from_internal_positions(data_range, &positions, degree)
5063 .map_err(|e| e.to_string())?;
5064 return Ok(BSplineKnotSpec::Provided(knots));
5065 }
5066 match parse_knot_placement(options)? {
5067 BSplineKnotPlacement::Uniform => Ok(BSplineKnotSpec::Generate {
5068 data_range,
5069 num_internal_knots: n_knots,
5070 }),
5071 BSplineKnotPlacement::Quantile => {
5072 // Validate the column up-front so an unfittable request surfaces a
5073 // user-correctable error at parse time rather than deep in basis
5074 // construction. The same data drives the eventual quantile knots.
5075 crate::basis::auto_knot_vector_1d_quantile(data, n_knots, degree)
5076 .map_err(|e| e.to_string())?;
5077 Ok(BSplineKnotSpec::Automatic {
5078 num_internal_knots: Some(n_knots),
5079 placement: BSplineKnotPlacement::Quantile,
5080 })
5081 }
5082 }
5083}
5084
5085/// Reject unknown option keys with a focused error that names the term and
5086/// the offending key, plus suggests near-matches from the known-key list.
5087/// Without this, typos like `lengt_scale=0.1` or `nyu=5/2` are silently
5088/// dropped, the term uses the default, and the user has no idea why their
5089/// option had no effect.
5090
5091// ---------------------------------------------------------------------------
5092// Per-smooth-kind option whitelists
5093//
5094// Hoisted out of the `validate_known_options` call sites so the guard test
5095// `no_whitelisted_smooth_option_is_accepted_and_inert` can enumerate them.
5096// `validate_known_options` answers "is this key spelled right?"; that guard
5097// answers the different question these three lists silently got wrong in
5098// #2781/#2782/#2783 — "does this key do anything?".
5099// ---------------------------------------------------------------------------
5100/// Options of the PENALIZED factor smooths, `bs='fs'` and `bs='sz'`: a shared
5101/// B-spline marginal replicated once per level of the grouping factor. Every
5102/// key here shapes that marginal, so every key here reaches the built design.
5103///
5104/// `bs='re'` used to share this list even though it builds no spline at all;
5105/// see [`RANDOM_EFFECT_SMOOTH_OPTION_KEYS`] and #2791.
5106pub(crate) const FACTOR_SMOOTH_OPTION_KEYS: &[&str] = &[
5107 "type",
5108 "bs",
5109 "k",
5110 "basis_dim",
5111 "basis-dim",
5112 "basisdim",
5113 "knots",
5114 "knot_placement",
5115 "knot-placement",
5116 "knotplacement",
5117 "degree",
5118 "penalty_order",
5119 "m",
5120 "double_penalty",
5121 "ordered",
5122];
5123
5124/// Options of `bs='re'`, the PARAMETRIC random intercept + slope.
5125///
5126/// `s(x, g, bs='re')` is mgcv's `(1 + x | g)`: the per-level design is the raw
5127/// line `[1, x − c]` and the penalty is an identity ridge per parametric
5128/// coordinate. There is no spline marginal, no knot vector and no difference
5129/// penalty, so none of the basis-shaping keys of
5130/// [`FACTOR_SMOOTH_OPTION_KEYS`] can be honoured — and until #2791 all ten of
5131/// them were accepted and silently discarded.
5132pub(crate) const RANDOM_EFFECT_SMOOTH_OPTION_KEYS: &[&str] = &["type", "bs", "ordered"];
5133
5134/// The keys `bs='re'` refuses with a reason rather than a bare "unknown
5135/// option": they are all spelled correctly and all valid on `bs='fs'`, so the
5136/// user's mistake is the flavour, not the spelling.
5137const RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS: &[&str] = &[
5138 "k",
5139 "basis_dim",
5140 "basis-dim",
5141 "basisdim",
5142 "knots",
5143 "knot_placement",
5144 "knot-placement",
5145 "knotplacement",
5146 "degree",
5147 "penalty_order",
5148 "m",
5149 "double_penalty",
5150];
5151
5152/// Validate the option map of a `bs='re'` term.
5153///
5154/// Refuses the basis-shaping keys with a message that names the flavour that
5155/// does honour them, then falls through to the ordinary spelling check.
5156fn validate_random_effect_smooth_options(
5157 options: &BTreeMap<String, String>,
5158) -> Result<(), String> {
5159 if let Some(key) = RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS
5160 .iter()
5161 .find(|key| options.contains_key(**key))
5162 {
5163 return Err(TermBuilderError::incompatible_config(format!(
5164 "bs='re' is a parametric random intercept + slope — the per-level line \
5165 [1, x] under an i.i.d. ridge — not a spline, so it has no basis to shape \
5166 and `{key}=` cannot be honoured. Use bs='fs' for a penalized random \
5167 smooth of x within each level (it accepts {key}=), or drop the option."
5168 ))
5169 .to_string());
5170 }
5171 validate_known_options("re", options, RANDOM_EFFECT_SMOOTH_OPTION_KEYS)
5172}
5173
5174pub(crate) const CYCLIC_SMOOTH_OPTION_KEYS: &[&str] = &[
5175 "type",
5176 "bs",
5177 "by",
5178 "k",
5179 "basis_dim",
5180 "basis-dim",
5181 "basisdim",
5182 "degree",
5183 "penalty_order",
5184 "period",
5185 "periods",
5186 "period_start",
5187 "period_end",
5188 "start",
5189 "end",
5190 "origin",
5191 "origins",
5192 "period_origin",
5193 "period-origin",
5194 "domain_origin",
5195 "double_penalty",
5196 "id",
5197 "identifiability",
5198];
5199
5200pub(crate) const BSPLINE_SMOOTH_OPTION_KEYS: &[&str] = &[
5201 "type",
5202 "bs",
5203 "by",
5204 "k",
5205 "basis_dim",
5206 "basis-dim",
5207 "basisdim",
5208 "knots",
5209 "knot_placement",
5210 "knot-placement",
5211 "knotplacement",
5212 "degree",
5213 "penalty_order",
5214 "boundary",
5215 "bc",
5216 "boundary_conditions",
5217 "bc_left",
5218 "bc_right",
5219 "left_bc",
5220 "right_bc",
5221 "start_bc",
5222 "end_bc",
5223 "side",
5224 "anchor",
5225 "anchor_value",
5226 "value",
5227 "anchor_left",
5228 "left_anchor",
5229 "anchor_right",
5230 "right_anchor",
5231 "periodic",
5232 "period",
5233 "periods",
5234 "period_start",
5235 "period_end",
5236 "origin",
5237 "double_penalty",
5238 "id",
5239 "identifiability",
5240];
5241
5242pub(crate) const THINPLATE_SMOOTH_OPTION_KEYS: &[&str] = &[
5243 "type",
5244 "bs",
5245 "by",
5246 "length_scale",
5247 "centers",
5248 "k",
5249 "basis_dim",
5250 "basis-dim",
5251 "basisdim",
5252 "knots",
5253 "include_intercept",
5254 "double_penalty",
5255 "id",
5256 "identifiability",
5257 "periodic",
5258 "cyclic",
5259 "period",
5260 "period_start",
5261 "period_end",
5262 "scale_dims",
5263];
5264
5265pub(crate) const SPHERE_SMOOTH_OPTION_KEYS: &[&str] = &[
5266 "type",
5267 "bs",
5268 "by",
5269 "centers",
5270 "k",
5271 "basis_dim",
5272 "basis-dim",
5273 "basisdim",
5274 "knots",
5275 "penalty_order",
5276 "m",
5277 "double_penalty",
5278 "id",
5279 "kernel",
5280 "method",
5281 "radians",
5282 "units",
5283 "degree",
5284 "l",
5285 "max_degree",
5286 "max-degree",
5287 "lmax",
5288 "l_max",
5289 "l-max",
5290];
5291
5292pub(crate) const CURVATURE_SMOOTH_OPTION_KEYS: &[&str] = &[
5293 "type",
5294 "bs",
5295 "by",
5296 "centers",
5297 "k",
5298 "basis_dim",
5299 "basis-dim",
5300 "basisdim",
5301 "knots",
5302 "kappa",
5303 "length_scale",
5304 "double_penalty",
5305 "id",
5306];
5307
5308pub(crate) const MEASURE_JET_SMOOTH_OPTION_KEYS: &[&str] = &[
5309 "type",
5310 "bs",
5311 "by",
5312 "centers",
5313 "k",
5314 "basis_dim",
5315 "basis-dim",
5316 "basisdim",
5317 "knots",
5318 "s",
5319 "alpha",
5320 "tau",
5321 "scales",
5322 "length_scale",
5323 "double_penalty",
5324 "multiscale",
5325 "learn_length_scale",
5326 "id",
5327];
5328
5329pub(crate) const MATERN_SMOOTH_OPTION_KEYS: &[&str] = &[
5330 "type",
5331 "bs",
5332 "by",
5333 "nu",
5334 "length_scale",
5335 "centers",
5336 "k",
5337 "basis_dim",
5338 "basis-dim",
5339 "basisdim",
5340 "knots",
5341 "include_intercept",
5342 "double_penalty",
5343 "id",
5344 "identifiability",
5345 "periodic",
5346 "cyclic",
5347 "period",
5348 "period_start",
5349 "period_end",
5350 "scale_dims",
5351];
5352
5353pub(crate) const DUCHON_SMOOTH_OPTION_KEYS: &[&str] = &[
5354 "type",
5355 "bs",
5356 "by",
5357 "length_scale",
5358 "centers",
5359 "k",
5360 "basis_dim",
5361 "basis-dim",
5362 "basisdim",
5363 "knots",
5364 "rank",
5365 "power",
5366 "p",
5367 "nullspace_order",
5368 "order",
5369 "identifiability",
5370 "periodic",
5371 "cyclic",
5372 "period",
5373 "period_start",
5374 "period_end",
5375 "scale_dims",
5376 "double_penalty",
5377 "id",
5378];
5379
5380pub(crate) const TENSOR_SMOOTH_OPTION_KEYS: &[&str] = &[
5381 "type",
5382 "bs",
5383 "by",
5384 "k",
5385 "basis_dim",
5386 "basis-dim",
5387 "basisdim",
5388 "knot_placement",
5389 "knot-placement",
5390 "knotplacement",
5391 "degree",
5392 "penalty_order",
5393 "double_penalty",
5394 "periodic",
5395 "cyclic",
5396 "period",
5397 "periods",
5398 "period_start",
5399 "period_end",
5400 "origin",
5401 "origins",
5402 "period_origin",
5403 "period-origin",
5404 "domain_origin",
5405 "boundary",
5406 "bc",
5407 "identifiability",
5408 "id",
5409];
5410
5411pub(crate) const PCA_SMOOTH_OPTION_KEYS: &[&str] = &[
5412 "type",
5413 "bs",
5414 "by",
5415 "k",
5416 "basis_dim",
5417 "basis-dim",
5418 "basisdim",
5419 "lazy_path",
5420 "path",
5421 "pca_basis_path",
5422 "chunk_size",
5423 "smooth_penalty",
5424 "centered",
5425 "double_penalty",
5426 "id",
5427];
5428
5429/// Prefix of the engine-injected option namespace. Options the pipeline adds
5430/// to a term's option map on the user's behalf — the `by=` column index the
5431/// `BySmooth` wrapper resolves (`__by_col`), the secondary-predictor center cap
5432/// ([`SECONDARY_CENTER_CAP_OPTION`]) — carry this prefix. They are never typed
5433/// in a formula, so [`validate_known_options`] does not judge them: that
5434/// validator answers "is this USER key spelled right?", and an engine key is
5435/// neither a user key nor a member of any one arm's vocabulary. Listing
5436/// `__by_col` in every arm's whitelist, and the cap in none, is how a
5437/// location-scale `noise_formula` with a `thinplate()` smooth came to refuse on
5438/// its own injected option (gam#2781 tightened the whitelists).
5439pub const ENGINE_OPTION_PREFIX: &str = "__";
5440
5441/// Whether `key` belongs to the engine-injected option namespace.
5442pub fn is_engine_option(key: &str) -> bool {
5443 key.starts_with(ENGINE_OPTION_PREFIX)
5444}
5445
5446pub fn validate_known_options(
5447 term_name: &str,
5448 options: &BTreeMap<String, String>,
5449 known: &[&str],
5450) -> Result<(), String> {
5451 let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
5452 for key in options.keys() {
5453 if is_engine_option(key) {
5454 continue;
5455 }
5456 if !known_set.contains(&key.as_str()) {
5457 if term_name == "tensor" && is_tensor_k_axis_option_key(key) {
5458 continue;
5459 }
5460 // Suggest near-matches (substring or shared prefix ≥ 3).
5461 let key_l = key.to_ascii_lowercase();
5462 let mut suggestions: Vec<&str> = known
5463 .iter()
5464 .filter(|k| {
5465 let kl = k.to_ascii_lowercase();
5466 kl.contains(&key_l) || key_l.contains(&kl) || {
5467 let n = kl
5468 .chars()
5469 .zip(key_l.chars())
5470 .take_while(|(a, b)| a == b)
5471 .count();
5472 n >= 3
5473 }
5474 })
5475 .copied()
5476 .collect();
5477 suggestions.sort_unstable();
5478 suggestions.dedup();
5479 let hint = if suggestions.is_empty() {
5480 String::new()
5481 } else {
5482 format!(" — did you mean one of [{}]?", suggestions.join(", "))
5483 };
5484 return Err(TermBuilderError::invalid_option(format!(
5485 "{term_name}() does not accept option `{key}`{hint}. Valid options: [{}]",
5486 {
5487 let mut sorted = known.to_vec();
5488 sorted.sort_unstable();
5489 sorted.join(", ")
5490 }
5491 ))
5492 .to_string());
5493 }
5494 }
5495 Ok(())
5496}
5497
5498/// Private (engine-injected) option that caps the *default* spatial center
5499/// count for a secondary (distributional) predictor's smooth — see
5500/// `solver::fit_orchestration::apply_secondary_predictor_basis_parsimony` and #501.
5501///
5502/// It is deliberately NOT one of the user-facing count aliases recognised by
5503/// [`has_explicit_countwith_basis_alias`], so it never flips the spatial basis
5504/// onto the explicit (hard) center-placement strategy: the cap lowers the
5505/// *default* count while the `Auto` strategy is retained, so the count is still
5506/// softly reduced when the data can't support it.
5507pub const SECONDARY_CENTER_CAP_OPTION: &str = "__secondary_center_cap";
5508
5509/// Apply the secondary-predictor center cap to a *default* spatial center
5510/// count. A no-op when the cap option is absent (the common case) or when the
5511/// user supplied an explicit count (then `default_count` is ignored downstream
5512/// by [`parse_countwith_basis_alias`] anyway).
5513pub(crate) fn cap_default_spatial_centers(
5514 options: &BTreeMap<String, String>,
5515 default_count: usize,
5516) -> usize {
5517 match option_usize(options, SECONDARY_CENTER_CAP_OPTION) {
5518 Some(cap) => default_count.min(cap),
5519 None => default_count,
5520 }
5521}
5522
5523fn default_matern_center_count(
5524 n: usize,
5525 d: usize,
5526 planned_count: usize,
5527 univariate_floor: usize,
5528) -> usize {
5529 // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) was DELETED here too — it
5530 // masked the same over-sizing/under-penalization defect by shrinking the basis
5531 // rather than fixing the optimizer. The default now uses the generic n-scaling
5532 // plan. A small-n floor against a numerically-fragile two-column kernel block
5533 // is a legitimate degenerate guard and is kept. Explicit `k`/`centers` still
5534 // take full effect upstream.
5535 let low_n_floor = (d + 4).min(n);
5536 // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
5537 // `default_num_centers` starves a 1-D radial basis BELOW the resolution the
5538 // univariate B-spline `s(x)` is handed on the SAME data (e.g. 7 vs 11 basis
5539 // functions at n=30), so `matern(x)`/`duchon(x)` over-smooth sparse
5540 // oscillations that `s(x)` recovers cleanly. Smoothness is set by the REML
5541 // penalty λ, not by the raw center count (see `default_num_centers`), so a
5542 // radial smooth competing with `s(x)` must not be dimensioned coarser than
5543 // it. `univariate_floor` carries that spline-equivalent resolution for a 1-D
5544 // smooth (0 for d>1, where there is no direct univariate analogue) and is
5545 // bounded by n. Explicit `k`/`centers` still override upstream.
5546 planned_count
5547 .max(low_n_floor)
5548 .max(univariate_floor.min(n))
5549 .max(1)
5550}
5551
5552fn default_duchon_center_count(
5553 n: usize,
5554 d: usize,
5555 planned_count: usize,
5556 polynomial_cols: usize,
5557 univariate_floor: usize,
5558) -> usize {
5559 // #1757: Duchon fits pay a larger setup cost than Matérn/TPS because the
5560 // constrained radial block is rotated through its center Gram and several
5561 // operator-collocation penalties. The old generic spatial default handed a
5562 // 2-D Gaussian Duchon at n≈500 more than one hundred centers, so cold fits
5563 // spent most of their time in dense O(k³) eigensolves even though the REML
5564 // smoother uses a low-rank basis. mgcv's Duchon spline default is the
5565 // thin-plate-style `k = 10 * 3^(d - 1)` (30 in 2-D); use that as the
5566 // implicit low-rank cap while preserving the user's explicit `centers=`/`k=`
5567 // request above. The polynomial null space must still fit, so tiny
5568 // high-order bases are raised to the smallest admissible count.
5569 let mgcv_default = 10usize.saturating_mul(3usize.saturating_pow(d.saturating_sub(1) as u32));
5570 let low_n_floor = (polynomial_cols + 1).min(n).max(1);
5571 // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
5572 // `default_num_centers` starves `planned_count` below the univariate spline
5573 // resolution the competing `s(x)` gets on the SAME data, so `duchon(x)`
5574 // over-smooths sparse oscillations. `univariate_floor` (0 for d>1) carries
5575 // that spline-equivalent basis dimension and floors the 1-D default,
5576 // bounded by n; smoothness is set by the REML penalty, not the raw count.
5577 // Explicit `k`/`centers` still override upstream.
5578 planned_count
5579 .min(mgcv_default)
5580 .max(low_n_floor)
5581 .max(univariate_floor.min(n))
5582}
5583
5584pub fn parse_countwith_basis_alias(
5585 options: &BTreeMap<String, String>,
5586 primarykey: &str,
5587 default_count: usize,
5588) -> Result<usize, String> {
5589 // Strict: reject unparseable values (e.g. `centers=many`, `centers=-1`,
5590 // `centers=1.5`) instead of silently dropping them and falling through
5591 // to the default. Without this the user gets the auto-inferred count
5592 // silently and never realizes their explicit option was ignored.
5593 let primary = option_usize_strict(options, primarykey)?;
5594 let basis_dim = option_usize_any_strict(
5595 options,
5596 &["k", "basis_dim", "basis-dim", "basisdim", "knots"],
5597 )?;
5598 if primary.is_some() && basis_dim.is_some() {
5599 return Err(TermBuilderError::incompatible_config(format!(
5600 "specify either {}=<count> or k=<basis_dim> (not both)",
5601 primarykey
5602 ))
5603 .to_string());
5604 }
5605 Ok(primary.or(basis_dim).unwrap_or(default_count))
5606}
5607
5608/// Resolve the wiggliness penalty's derivative/difference order from its two
5609/// documented spellings.
5610///
5611/// `penalty_order=` and `m=` name the SAME knob here and in mgcv. Reading one
5612/// and falling through to the other (`option_usize(.., "penalty_order")
5613/// .or_else(|| option_usize(.., "m"))`) silently drops the loser when both are
5614/// given, and the lenient `option_usize` silently drops an unparseable value on
5615/// top of that. This refuses the conflict the way `parse_countwith_basis_alias`
5616/// refuses `centers=` together with `k=`, and parses strictly so `m=1.5` is a
5617/// user mistake rather than "m not specified".
5618pub fn parse_penalty_order_alias(
5619 options: &BTreeMap<String, String>,
5620) -> Result<Option<usize>, String> {
5621 let primary = option_usize_strict(options, "penalty_order")?;
5622 let alias = option_usize_strict(options, "m")?;
5623 match (primary, alias) {
5624 (Some(primary), Some(alias)) if primary != alias => {
5625 Err(TermBuilderError::incompatible_config(format!(
5626 "penalty_order={primary} and m={alias} are two spellings of the same \
5627 option (the order of the penalised derivative), so they cannot disagree; \
5628 specify one of them"
5629 ))
5630 .to_string())
5631 }
5632 (Some(primary), _) => Ok(Some(primary)),
5633 (None, alias) => Ok(alias),
5634 }
5635}
5636
5637pub fn has_explicit_countwith_basis_alias(
5638 options: &BTreeMap<String, String>,
5639 primarykey: &str,
5640) -> bool {
5641 options.contains_key(primarykey)
5642 || ["k", "basis_dim", "basis-dim", "basisdim", "knots"]
5643 .iter()
5644 .any(|alias| options.contains_key(*alias))
5645}
5646
5647pub fn parse_cyclic_boundary(
5648 options: &BTreeMap<String, String>,
5649 minv: f64,
5650 maxv: f64,
5651) -> Result<OneDimensionalBoundary, String> {
5652 let cyclic = option_bool(options, "cyclic")
5653 .or_else(|| option_bool(options, "periodic"))
5654 .unwrap_or(false);
5655 if !cyclic {
5656 return Ok(OneDimensionalBoundary::Open);
5657 }
5658 let start = match option_numeric_expr(options, "period_start")? {
5659 Some(v) => v,
5660 None => option_numeric_expr(options, "start")?.unwrap_or(minv),
5661 };
5662 let end = match option_numeric_expr(options, "period_end")? {
5663 Some(v) => v,
5664 None => option_numeric_expr(options, "end")?.unwrap_or(maxv),
5665 };
5666 if end <= start {
5667 return Err(format!(
5668 "cyclic smooth requires period_end/end ({end}) > period_start/start ({start})"
5669 ));
5670 }
5671 Ok(OneDimensionalBoundary::Cyclic { start, end })
5672}
5673
5674/// Parse the periodic-uniform domain for a one-dimensional cyclic smooth.
5675///
5676/// Returns the `(domain_start, period)` pair derived from
5677/// `period_start` / `start`, `period_end` / `end`, falling back to the
5678/// data range `[minv, maxv)` when neither bound is provided. The period
5679/// must be strictly positive.
5680pub fn parse_periodic_domain_1d(
5681 options: &BTreeMap<String, String>,
5682 minv: f64,
5683 maxv: f64,
5684) -> Result<(f64, f64), String> {
5685 let start_opt = match option_numeric_expr(options, "period_start")? {
5686 Some(v) => Some(v),
5687 None => option_numeric_expr(options, "start")?,
5688 };
5689 let end_opt = match option_numeric_expr(options, "period_end")? {
5690 Some(v) => Some(v),
5691 None => option_numeric_expr(options, "end")?,
5692 };
5693 // Reject the pure data-range fallback. A B-spline periodic smooth that takes
5694 // its wrap from the observed [min, max] is sample-dependent and silently
5695 // wrong: uniform draws on a true period of 2π land on [ε, 2π−ε], so using
5696 // (max−min) as the period seams the curve with an off-by-ε discontinuity and
5697 // the fit drifts with the sample. (Unlike the radial closed-lattice Duchon
5698 // path, whose centers DO tile a full period, so its span-derive is exact —
5699 // see `parse_periodic_axes_option`.) Require the caller to name the period
5700 // explicitly via `period=`/`period_end`. The end is only defaulted to `maxv`
5701 // when a `period_start`/`start` was given (a half-open declaration); a bare
5702 // periodic smooth with neither bound is an error.
5703 if end_opt.is_none() && start_opt.is_none() {
5704 return Err(
5705 "periodic B-spline smooth requires an explicit period: pass period=<value> \
5706 (e.g. period=2*pi) or period_start=/period_end=. Deriving the period from the \
5707 observed data range is sample-dependent and produces an off-by-ε seam, so it is \
5708 not inferred."
5709 .to_string(),
5710 );
5711 }
5712 let start = start_opt.unwrap_or(minv);
5713 let end = end_opt.unwrap_or(maxv);
5714 if !(start.is_finite() && end.is_finite()) {
5715 return Err(format!(
5716 "periodic smooth domain requires finite endpoints, got ({start}, {end})"
5717 ));
5718 }
5719 if end <= start {
5720 return Err(format!(
5721 "periodic smooth requires period_end/end ({end}) > period_start/start ({start})"
5722 ));
5723 }
5724 Ok((start, end - start))
5725}
5726
5727fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
5728 let trimmed = raw.trim();
5729 let lowered = trimmed.to_ascii_lowercase();
5730 // Exact spellings of the half-integer smoothnesses that have closed-form
5731 // kernels; anything else falls through to the numeric parse below.
5732 let named = match lowered.as_str() {
5733 "1/2" | "0.5" | "half" => Some(MaternNu::Half),
5734 "3/2" | "1.5" => Some(MaternNu::ThreeHalves),
5735 "5/2" | "2.5" => Some(MaternNu::FiveHalves),
5736 "7/2" | "3.5" => Some(MaternNu::SevenHalves),
5737 "9/2" | "4.5" => Some(MaternNu::NineHalves),
5738 _ => None,
5739 };
5740 if let Some(nu) = named {
5741 return Ok(nu);
5742 }
5743
5744 let value = if let Some((num, den)) = trimmed.split_once('/') {
5745 let num = num
5746 .trim()
5747 .parse::<f64>()
5748 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
5749 let den = den
5750 .trim()
5751 .parse::<f64>()
5752 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
5753 if den == 0.0 || !num.is_finite() || !den.is_finite() {
5754 return Err(unsupported_matern_nu_message(raw));
5755 }
5756 num / den
5757 } else {
5758 trimmed
5759 .parse::<f64>()
5760 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?
5761 };
5762
5763 const TOL: f64 = 1e-12;
5764 if (value - 0.5).abs() <= TOL {
5765 Ok(MaternNu::Half)
5766 } else if (value - 1.5).abs() <= TOL {
5767 Ok(MaternNu::ThreeHalves)
5768 } else if (value - 2.5).abs() <= TOL {
5769 Ok(MaternNu::FiveHalves)
5770 } else if (value - 3.5).abs() <= TOL {
5771 Ok(MaternNu::SevenHalves)
5772 } else if (value - 4.5).abs() <= TOL {
5773 Ok(MaternNu::NineHalves)
5774 } else {
5775 Err(unsupported_matern_nu_message(raw))
5776 }
5777}
5778
5779fn unsupported_matern_nu_message(raw: &str) -> String {
5780 TermBuilderError::unsupported_feature(format!(
5781 "unsupported Matern nu '{raw}'; supported half-integer values are 1/2, 3/2, 5/2, 7/2, and 9/2"
5782 ))
5783 .to_string()
5784}
5785
5786#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
5787pub enum DuchonPowerPolicy {
5788 Explicit(f64),
5789 /// No explicit `power=` given: defer to the cubic structural default, which
5790 /// the builder resolves dimension-aware as `s = (d − 1)/2` (so `φ(r) = r³`
5791 /// in every dimension). There is no triple-operator minimum any more.
5792 CubicStructuralDefault,
5793}
5794
5795pub fn parse_duchon_power_policy(
5796 options: &BTreeMap<String, String>,
5797) -> Result<DuchonPowerPolicy, String> {
5798 if let Some(raw_nu) = options.get("nu") {
5799 return Err(TermBuilderError::incompatible_config(format!(
5800 "Duchon smooths use power=<number>, not nu='{}'. Use power=1.5, power=2, etc.",
5801 raw_nu
5802 ))
5803 .to_string());
5804 }
5805 // `p` is the Duchon whitelist's alias of `power` and was read nowhere, so
5806 // `duchon(x, z, p=2)` was accepted and silently used the structural default
5807 // (#2781's family).
5808 match options.get("power").or_else(|| options.get("p")) {
5809 Some(raw) => {
5810 let value = raw.parse::<f64>().map_err(|err| {
5811 TermBuilderError::invalid_option(format!(
5812 "invalid Duchon power '{}'; expected a non-negative number such as power=1.5 or power=2: {}",
5813 raw, err
5814 ))
5815 .to_string()
5816 })?;
5817 if !value.is_finite() || value < 0.0 {
5818 return Err(TermBuilderError::invalid_option(format!(
5819 "invalid Duchon power '{}'; expected a finite non-negative number such as power=1.5 or power=2",
5820 raw
5821 ))
5822 .to_string());
5823 }
5824 Ok(DuchonPowerPolicy::Explicit(value))
5825 }
5826 None => Ok(DuchonPowerPolicy::CubicStructuralDefault),
5827 }
5828}
5829
5830/// Like [`parse_duchon_order`] but reports ABSENCE, so a caller whose default
5831/// happens to equal `Linear` can still tell "the user named the affine null
5832/// space" apart from "the user named nothing". The `duchon` arm needs that
5833/// distinction: its structural cubic default supplies a jointly chosen
5834/// `(order, power)` PAIR, and it used to take the order from that pair even
5835/// when the caller had named one (#2781's family) — contradicting this module's
5836/// own contract that "an explicit `order=0` still selects the constant-only
5837/// space".
5838pub fn parse_duchon_order_opt(
5839 options: &BTreeMap<String, String>,
5840) -> Result<Option<DuchonNullspaceOrder>, String> {
5841 if !options.contains_key("order") && !options.contains_key("nullspace_order") {
5842 return Ok(None);
5843 }
5844 parse_duchon_order(options).map(Some)
5845}
5846
5847pub fn parse_duchon_order(
5848 options: &BTreeMap<String, String>,
5849) -> Result<DuchonNullspaceOrder, String> {
5850 // `nullspace_order` is the whitelist's alias of `order` and was read
5851 // nowhere (#2781's family).
5852 match options.get("order").or_else(|| options.get("nullspace_order")) {
5853 // Structural cubic Duchon is affine-by-default: an unspecified order is
5854 // the `Linear` (constant + linear) null space, matching the magic
5855 // default. An explicit `order=0` still selects the constant-only space.
5856 None => Ok(DuchonNullspaceOrder::Linear),
5857 Some(raw) => match raw.parse::<usize>() {
5858 Ok(0) => Ok(DuchonNullspaceOrder::Zero),
5859 Ok(1) => Ok(DuchonNullspaceOrder::Linear),
5860 Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
5861 Err(_) => Err(TermBuilderError::invalid_option(format!(
5862 "invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
5863 raw
5864 ))
5865 .to_string()),
5866 },
5867 }
5868}
5869
5870fn parse_matern_identifiability(
5871 options: &BTreeMap<String, String>,
5872) -> Result<MaternIdentifiability, TermBuilderError> {
5873 let Some(raw) = options.get("identifiability").map(String::as_str) else {
5874 return Ok(MaternIdentifiability::default());
5875 };
5876 match raw.trim().to_ascii_lowercase().as_str() {
5877 "none" => Ok(MaternIdentifiability::None),
5878 "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered" => {
5879 Ok(MaternIdentifiability::CenterSumToZero)
5880 }
5881 "linear" | "center_linear_orthogonal" | "center-linear-orthogonal" => {
5882 Ok(MaternIdentifiability::CenterLinearOrthogonal)
5883 }
5884 other => Err(TermBuilderError::unsupported_feature(format!(
5885 "invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
5886 ))),
5887 }
5888}
5889
5890fn parse_spatial_identifiability(
5891 options: &BTreeMap<String, String>,
5892) -> Result<SpatialIdentifiability, TermBuilderError> {
5893 let Some(raw) = options.get("identifiability").map(String::as_str) else {
5894 return Ok(SpatialIdentifiability::default());
5895 };
5896 match raw.trim().to_ascii_lowercase().as_str() {
5897 "none" => Ok(SpatialIdentifiability::None),
5898 "orthogonal"
5899 | "orthogonal_to_parametric"
5900 | "orthogonal-to-parametric"
5901 | "parametric_orthogonal" => Ok(SpatialIdentifiability::OrthogonalToParametric),
5902 "frozen" => Err(TermBuilderError::unsupported_feature(
5903 "spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric",
5904 )),
5905 other => Err(TermBuilderError::unsupported_feature(format!(
5906 "invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
5907 ))),
5908 }
5909}
5910
5911#[cfg(test)]
5912mod tests;